fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis - Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt - optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht - Titel-Schalter mit fester Beschriftung + aria-pressed - Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog - Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck - Uebersetzungen und CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
@@ -73,11 +77,21 @@ interface BoundCall {
|
||||
|
||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
dashboardBackground: unknown;
|
||||
}
|
||||
|
||||
interface FakePrisma {
|
||||
dashboardImage: ModelMethods;
|
||||
user: ModelMethods;
|
||||
__rows: ImageRow[];
|
||||
__users: UserRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeBoundClient(
|
||||
tenantId: string,
|
||||
userId?: string,
|
||||
): { dashboardImage: ModelMethods; user: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
|
||||
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
|
||||
const user: ModelMethods = {
|
||||
updateMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
|
||||
data: { dashboardBackground: unknown };
|
||||
};
|
||||
const [key] = args.where.dashboardBackground.path;
|
||||
let count = 0;
|
||||
for (const u of users) {
|
||||
const bg = u.dashboardBackground as Record<string, unknown> | null;
|
||||
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
|
||||
continue;
|
||||
u.dashboardBackground = args.data.dashboardBackground;
|
||||
count++;
|
||||
}
|
||||
return { count };
|
||||
}),
|
||||
};
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
count: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string } };
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
return rows.filter(
|
||||
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
|
||||
).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
const created = makeRow({
|
||||
id: `new-${rows.length + 1}`,
|
||||
...args.data,
|
||||
createdAt: new Date('2026-02-02'),
|
||||
});
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
for (const method of Object.keys(methods)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
boundCallLog.push({ tenantId, userId, model, method });
|
||||
return methods[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
return {
|
||||
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
|
||||
user: wrapModel('user', user, tenantId, userId),
|
||||
};
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
user,
|
||||
__rows: rows,
|
||||
__users: users,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
return wrap(tenantId, userId);
|
||||
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||
for (const r of result) {
|
||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(Object.keys(r).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'mimeType',
|
||||
'originalName',
|
||||
'size',
|
||||
]);
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
select: Record<string, boolean>;
|
||||
};
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.originalName).toBe('irgendwas.txt');
|
||||
expect(result.size).toBe(PNG.length);
|
||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'mimeType',
|
||||
'originalName',
|
||||
'size',
|
||||
]);
|
||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
||||
await expect(
|
||||
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
||||
const foreign = Array.from({ length: 30 }, (_, i) =>
|
||||
makeRow({ id: `f${i}`, userId: 'user-2' }),
|
||||
);
|
||||
const prisma = makeFakePrisma(foreign);
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.__rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
|
||||
const users: UserRow[] = [
|
||||
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||
];
|
||||
const prisma = makeFakePrisma(
|
||||
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
|
||||
users,
|
||||
);
|
||||
const service = makeService(prisma);
|
||||
|
||||
await service.remove('eigen', 'user-1', 'tenant-1');
|
||||
|
||||
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
|
||||
// nur die eigene Zeile
|
||||
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
|
||||
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
||||
expect(call).toEqual({
|
||||
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
|
||||
data: { dashboardBackground: { kind: 'none' } },
|
||||
});
|
||||
|
||||
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
|
||||
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
|
||||
await service.remove('zweites', 'user-1', 'tenant-1');
|
||||
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
|
||||
});
|
||||
|
||||
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
|
||||
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
|
||||
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'eigen',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
|
||||
expect(methods).toEqual([
|
||||
'findMany',
|
||||
'count',
|
||||
'create',
|
||||
'findUnique',
|
||||
'findUnique',
|
||||
'delete',
|
||||
'updateMany',
|
||||
]);
|
||||
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
|
||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(prisma.__rows[0].storagePath).toBe(
|
||||
`user-files/dashboard-images/user-1/${result.id}.png`,
|
||||
);
|
||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||
// die Zeile.
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(createArgs.data.id).toBe(result.id);
|
||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(result.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(createArgs.data).not.toHaveProperty('data');
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||
makeRow({
|
||||
id: 'datei-fehlt',
|
||||
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
|
||||
}),
|
||||
]);
|
||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'weg',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -5,12 +8,9 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
type DashboardImageMime,
|
||||
@@ -314,6 +314,15 @@ export class DashboardImagesService {
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*
|
||||
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
|
||||
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
|
||||
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
|
||||
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
|
||||
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
|
||||
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
|
||||
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
|
||||
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -323,6 +332,19 @@ export class DashboardImagesService {
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
try {
|
||||
await tenantPrisma.user.updateMany({
|
||||
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
|
||||
data: { dashboardBackground: { kind: 'none' } },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const absolute = absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user