6 Commits

Author SHA1 Message Date
schalli 86ad95f74e docs: Windows-Test bestanden, Review-Fixes seit 26.09., Uebergabe verbraucht
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m30s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:23:59 +02:00
schalli 12214a948e 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>
2026-09-30 03:20:16 +02:00
schalli 071082983b fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche
- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli c2e4467dd8 fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli be1e0035e0 fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500
- Modulansicht unterscheidet Ladefehler von "nicht gefunden"
- Formular/Loeschdialog nennen 403 und 400 eigens
- eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten
- neue Eintraege sind mit "Eigene Module" vorbelegt
- Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli f7213f5e45 wip: pausiert nach 1.7.0-Folgearbeiten, Windows-Test offen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:15 +02:00
50 changed files with 2489 additions and 480 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren." stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-23T15:30:00.000Z" last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-23 last_activity: 2026-09-30
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0 last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
Progress: [██████████] 99% Progress: [██████████] 99%
+10
View File
@@ -10,10 +10,20 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert ### Geändert
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts. - Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
### Behoben ### Behoben
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet. - Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es. - Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen. - Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category'); expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name'); expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
}); });
it.each([
'name',
'url',
'category',
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
});
}); });
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist, * `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld * aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`). * dank `whitelist: true`).
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
*/ */
export class UpdateCustomModuleDto extends PartialType( export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const), OmitType(CreateCustomModuleDto, ['shared'] as const),
{ skipNullProperties: false },
) {} ) {}
@@ -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 type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service'; import { DashboardImagesService } from './dashboard-images.service';
@@ -73,11 +77,21 @@ interface BoundCall {
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>; type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
interface UserRow {
id: string;
dashboardBackground: unknown;
}
interface FakePrisma { interface FakePrisma {
dashboardImage: ModelMethods; dashboardImage: ModelMethods;
user: ModelMethods;
__rows: ImageRow[]; __rows: ImageRow[];
__users: UserRow[];
__boundCallLog: BoundCall[]; __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]); 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; return out;
} }
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma { function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
const boundCallLog: BoundCall[] = []; 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 = { const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => { findMany: vi.fn(async (raw: unknown) => {
const args = raw as { const args = raw as {
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}), }),
count: vi.fn(async (raw: unknown) => { count: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string } }; 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) => { create: vi.fn(async (raw: unknown) => {
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> }; 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); rows.push(created);
return pick(created, args.select); 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 = {}; const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) { for (const method of Object.keys(methods)) {
wrapped[method] = async (...args: unknown[]) => { wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method }); boundCallLog.push({ tenantId, userId, model, method });
return dashboardImage[method](...args); 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 = { const fake: FakePrisma = {
dashboardImage, dashboardImage,
user,
__rows: rows, __rows: rows,
__users: users,
__boundCallLog: boundCallLog, __boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) { __makeBoundClient(tenantId: string, userId?: string) {
return wrap(tenantId, userId); return wrap(tenantId, userId);
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const result = await makeService(prisma).list('user-1', 'tenant-1'); const result = await makeService(prisma).list('user-1', 'tenant-1');
expect(result.map((r) => r.id)).toEqual(['a', 'b']); expect(result.map((r) => r.id)).toEqual(['a', 'b']);
for (const r of result) { 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.data).toBeUndefined();
expect(call.select.storagePath).toBeUndefined(); expect(call.select.storagePath).toBeUndefined();
}); });
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(result.mimeType).toBe('image/png'); expect(result.mimeType).toBe('image/png');
expect(result.originalName).toBe('irgendwas.txt'); expect(result.originalName).toBe('irgendwas.txt');
expect(result.size).toBe(PNG.length); 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].userId).toBe('user-1');
expect(prisma.__rows[0].tenantId).toBe('tenant-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 () => { it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
const prisma = makeFakePrisma(); 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.'), new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
); );
expect(prisma.dashboardImage.create).not.toHaveBeenCalled(); 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 () => { 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); const prisma = makeFakePrisma(foreign);
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({ await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png', mimeType: 'image/png',
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => { it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]); 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 () => { it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]); const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
const service = makeService(prisma); const service = makeService(prisma);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); 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 () => { 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); const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' }); 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']); 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-user', 'user-1', 'tenant-1')).rejects.toThrow(
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException); NotFoundException,
);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
expect(prisma.__rows).toHaveLength(2); 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 () => { it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
const prisma = makeFakePrisma([]); const prisma = makeFakePrisma([]);
const service = makeService(prisma); 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 // Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n). // MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
const methods = prisma.__boundCallLog.map((c) => c.method); 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(); expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
for (const c of prisma.__boundCallLog) { for (const c of prisma.__boundCallLog) {
expect(c.tenantId).toBe('tenant-1'); 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); const onDisk = storedFile('user-1', result.id);
expect(fs.existsSync(onDisk)).toBe(true); expect(fs.existsSync(onDisk)).toBe(true);
expect(fs.readFileSync(onDisk).equals(PNG)).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 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 Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile. // 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.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id); 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(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled(); 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 // Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da. // dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
const prisma = makeFakePrisma([ 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( await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException, NotFoundException,
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', 'weg'); const onDisk = storedFile('user-1', 'weg');
expect(fs.existsSync(onDisk)).toBe(true); 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(prisma.__rows).toHaveLength(0);
expect(fs.existsSync(onDisk)).toBe(false); 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 { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@@ -5,12 +8,9 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { import {
DASHBOARD_IMAGE_MAX_COUNT, DASHBOARD_IMAGE_MAX_COUNT,
type DashboardImageMime, type DashboardImageMime,
@@ -314,6 +314,15 @@ export class DashboardImagesService {
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht. * Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird * Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
* protokolliert und geschluckt (T-HK4-04). * 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 }> { async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -323,6 +332,19 @@ export class DashboardImagesService {
} }
await tenantPrisma.dashboardImage.delete({ where: { id } }); 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); const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) { if (absolute !== null) {
try { try {
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
import { Agent } from 'undici'; import { Agent } from 'undici';
import { import {
discardBody,
IconDiscoveryService, IconDiscoveryService,
isPublicHttpUrl, isPublicHttpUrl,
normalizeUrl, normalizeUrl,
readTextCapped,
} from './icon-discovery.service'; } from './icon-discovery.service';
function mockResponse(options: { function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
contentType?: string;
body?: ArrayBuffer;
}): Response {
const body = options.body ?? new ArrayBuffer(10); const body = options.body ?? new ArrayBuffer(10);
return { return {
ok: true, ok: true,
status: 200, status: 200,
headers: { headers: {
get: (name: string) => get: (name: string) =>
name.toLowerCase() === 'content-type' name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
? (options.contentType ?? 'image/png')
: null,
}, },
arrayBuffer: async () => body, arrayBuffer: async () => body,
} as unknown as Response; } as unknown as Response;
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
status: 200, status: 200,
headers: { headers: {
get: (n: string) => get: (n: string) =>
n.toLowerCase() === 'content-type' n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
? 'text/html; charset=utf-8'
: null,
}, },
text: async () => html, text: async () => html,
}), }),
@@ -164,7 +159,8 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
}); });
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => { it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>'; const html =
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html))); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x'); const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
@@ -173,7 +169,10 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
}); });
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => { it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } })); vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
);
await expect( await expect(
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'), new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
@@ -203,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
}); });
it('rejects when Content-Type is not an image', async () => { it('rejects when Content-Type is not an image', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'), /not an image/,
).rejects.toThrow(/not an image/); );
}); });
it('rejects when the SSRF guard blocks the target', async () => { it('rejects when the SSRF guard blocks the target', async () => {
@@ -221,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://127.0.0.1/favicon.ico'), /blocked or failed/,
).rejects.toThrow(/blocked or failed/); );
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
}); });
@@ -236,9 +232,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
await expect( await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'), /size limit/,
).rejects.toThrow(/size limit/); );
}); });
}); });
@@ -258,10 +254,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
}); });
it('still returns a URL string', async () => { it('still returns a URL string', async () => {
vi.stubGlobal( vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
const service = new IconDiscoveryService(); const service = new IconDiscoveryService();
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
@@ -335,3 +328,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher); expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
}); });
}); });
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
function countingStream(chunks: number, chunkChars: number) {
const state = { pulled: 0, cancelled: false };
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.pulled >= chunks) {
controller.close();
return;
}
state.pulled += 1;
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
const { body, state } = countingStream(1000, 1000);
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
expect(text).toHaveLength(2500);
expect(state.pulled).toBeLessThan(10);
expect(state.cancelled).toBe(true);
});
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
// danach kommt nichts mehr, der Stream bleibt offen
},
cancel() {
cancelled = true;
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
expect(text).toBe('<link rel="icon">');
expect(cancelled).toBe(true);
});
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
const bytes = new TextEncoder().encode('<p>Grüße</p>');
const body = new ReadableStream<Uint8Array>({
start(controller) {
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
controller.enqueue(bytes.slice(0, 5));
controller.enqueue(bytes.slice(5));
controller.close();
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
expect(text).toBe('<p>Grüße</p>');
});
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
const text = await readTextCapped(
{ body: null, text: async () => 'x'.repeat(50) } as never,
10,
);
expect(text).toBe('x'.repeat(10));
});
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
const { body, state } = countingStream(5, 10);
discardBody({ body } as never);
expect(state.cancelled).toBe(true);
expect(() => discardBody({ body: null } as never)).not.toThrow();
});
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
const { body, state } = countingStream(5, 10);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: {
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
},
body,
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
expect(state.cancelled).toBe(true);
});
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
const encoder = new TextEncoder();
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
state.pulled += 1;
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
},
cancel() {
state.cancelled = true;
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
body,
text: async () => {
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
},
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
expect(icon).toBe('http://8.8.8.8/klein.png');
expect(state.cancelled).toBe(true);
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
expect(state.pulled).toBeLessThan(10);
});
});
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'node:dns/promises'; import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici'; import { Injectable } from '@nestjs/common';
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
/** /**
* Server-side favicon / icon discovery with SSRF protection (T-08-05). * Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -58,9 +58,7 @@ function isPrivateIpv4(address: string): boolean {
if ( if (
parts.length !== 4 || parts.length !== 4 ||
parts.some( parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
(part) => !Number.isInteger(part) || part < 0 || part > 255,
)
) { ) {
return true; return true;
} }
@@ -117,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean {
function isBlockedHostname(hostname: string): boolean { function isBlockedHostname(hostname: string): boolean {
const h = hostname.trim().toLowerCase(); const h = hostname.trim().toLowerCase();
return ( return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
h === 'localhost' ||
h.endsWith('.localhost') ||
h.endsWith('.local') ||
h === '0.0.0.0'
);
} }
export async function isPublicHttpUrl(url: URL): Promise<boolean> { export async function isPublicHttpUrl(url: URL): Promise<boolean> {
@@ -204,11 +197,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
} }
} }
function extractIconFromHtml( function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
html: string,
baseUrl: string,
linkTagsOnly = false,
): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? []; const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? []; const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -220,27 +209,19 @@ function extractIconFromHtml(
})) }))
.filter((c) => c.href); .filter((c) => c.href);
const appleTouchIcon = linkCandidates.find((c) => const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
c.rel.includes('apple-touch-icon'),
)?.href;
if (appleTouchIcon) return appleTouchIcon; if (appleTouchIcon) return appleTouchIcon;
const icon = linkCandidates.find((c) => const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
c.rel.split(/\s+/).includes('icon'),
)?.href;
if (icon) return icon; if (icon) return icon;
const shortcutIcon = linkCandidates.find((c) => const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
c.rel.includes('shortcut icon'),
)?.href;
if (shortcutIcon) return shortcutIcon; if (shortcutIcon) return shortcutIcon;
const imageSrc = linkCandidates.find((c) => const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
c.rel.includes('image_src'),
)?.href;
if (imageSrc) return imageSrc; if (imageSrc) return imageSrc;
@@ -257,9 +238,7 @@ function extractIconFromHtml(
.find( .find(
(c) => (c) =>
c.content && c.content &&
(c.property === 'og:image' || (c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
c.property === 'og:logo' ||
c.property === 'twitter:image'),
)?.content; )?.content;
return metaImage ?? null; return metaImage ?? null;
@@ -327,6 +306,71 @@ async function fetchWithRedirectGuard(
return null; return null;
} }
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
/**
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
* gelesen/abgebrochen) sind egal.
*/
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
try {
response.body?.cancel().catch(() => {});
} catch {
// Body gesperrt oder schon verbraucht — nichts zu tun.
}
}
/**
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
*/
export async function readTextCapped(
response: BodyResponse,
maxChars: number,
timeoutMs = HTML_FETCH_TIMEOUT_MS,
): Promise<string> {
if (!response.body) {
return (await response.text()).slice(0, maxChars);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
// cancel() beendet ein haengendes read() mit done: true.
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
text += decoder.decode();
break;
}
text += decoder.decode(value, { stream: true });
if (text.length >= maxChars) {
await reader.cancel().catch(() => {});
break;
}
}
} finally {
clearTimeout(deadline);
}
return text.slice(0, maxChars);
}
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> { async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, { const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*', accept: 'text/html,application/xhtml+xml,*/*',
@@ -340,12 +384,18 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const contentType = result.response.headers.get('content-type') ?? ''; const contentType = result.response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('text/html')) return null; if (!contentType.toLowerCase().includes('text/html')) {
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
discardBody(result.response);
return null;
}
const html = await result.response.text(); // T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
return { return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap html,
finalUrl: result.finalUrl.toString(), finalUrl: result.finalUrl.toString(),
ok: result.response.ok, ok: result.response.ok,
}; };
@@ -370,10 +420,7 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback; if (!htmlResult) return fallback;
return ( return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
fallback
);
} catch { } catch {
return fallback; return fallback;
} }
@@ -388,9 +435,7 @@ export class IconDiscoveryService {
* or an oversized body. Callers must not return a placeholder image; let * or an oversized body. Callers must not return a placeholder image; let
* the caller map the failure to an HTTP error status instead. * the caller map the failure to an HTTP error status instead.
*/ */
async fetchIconBytes( async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
iconUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
const url = new URL(iconUrl); const url = new URL(iconUrl);
const result = await fetchWithRedirectGuard(url, { const result = await fetchWithRedirectGuard(url, {
+30 -2
View File
@@ -1,4 +1,3 @@
import { PartialType } from '@nestjs/mapped-types';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@@ -7,6 +6,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
ValidateIf,
} from 'class-validator'; } from 'class-validator';
const trimString = ({ value }: { value: unknown }) => const trimString = ({ value }: { value: unknown }) =>
@@ -44,12 +44,40 @@ export class CreateReminderDto {
emailEnabled?: boolean; emailEnabled?: boolean;
} }
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
/** /**
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. * Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer * Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`). * gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
*
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
* fehlende Felder.
*/ */
export class UpdateReminderDto extends PartialType(CreateReminderDto) {} export class UpdateReminderDto {
@whenPresent
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(200)
title?: string;
@whenPresent
@IsString()
@MaxLength(2000)
description?: string;
@whenPresent
@IsISO8601({ strict: true })
dueAt?: string;
@whenPresent
@IsBoolean()
emailEnabled?: boolean;
}
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */ /** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
export class SnoozeReminderDto { export class SnoozeReminderDto {
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
const sameTime = (a: Date | null, b: Date | null) => const sameTime = (a: Date | null, b: Date | null) =>
a === null || b === null ? a === b : a.getTime() === b.getTime(); a === null || b === null ? a === b : a.getTime() === b.getTime();
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) { function makeStore(
rows: Row[],
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
inactive: string[] = [],
) {
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => { const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
let list = rows.filter( let list = rows.filter(
(r) => (r) =>
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
r.dueAt.getTime() <= where.dueAt.lte.getTime() && r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
r.dueAt.getTime() >= where.dueAt.gte.getTime(), r.dueAt.getTime() >= where.dueAt.gte.getTime(),
); );
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime()); if (orderBy?.dueAt === 'asc')
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt })); list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list
.slice(0, take)
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
}); });
const boundLog: string[] = []; const boundLog: string[] = [];
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
boundLog.push(`updateMany:${tenantId}`); boundLog.push(`updateMany:${tenantId}`);
let count = 0; let count = 0;
for (const r of rows) { for (const r of rows) {
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue; if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
continue;
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue; if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue; if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue; if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue; if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
continue;
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt; if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment; if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
count++; count++;
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
}), }),
}, },
user: { user: {
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })), findFirst: vi.fn(async ({ where }: any) => {
const isActive = !inactive.includes(where.id);
if ('isActive' in where && where.isActive !== isActive) return null;
return { email: emails[where.id] ?? null };
}),
}, },
}); });
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
return { prisma, rows, systemFindMany, boundLog }; return { prisma, rows, systemFindMany, boundLog };
} }
function makeScheduler( function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
prisma: any,
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
) {
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() }; const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) }; const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
const mail = { const mail = {
@@ -112,7 +122,12 @@ function makeScheduler(
return opts.sendResult ?? true; return opts.sendResult ?? true;
}), }),
}; };
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any); const scheduler = new ReminderMailScheduler(
registry as any,
prisma,
settings as any,
mail as any,
);
return { scheduler, registry, settings, mail }; return { scheduler, registry, settings, mail };
} }
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
const one = makeScheduler(store.prisma); const one = makeScheduler(store.prisma);
const two = makeScheduler(store.prisma); const two = makeScheduler(store.prisma);
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]); await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1); expect(
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
).toBe(1);
expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1); expect(store.rows[0].emailAttempts).toBe(1);
}); });
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
expect(mail.sendReminderEmail).not.toHaveBeenCalled(); expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW); expect(store.rows[0].emailSentAt).toEqual(NOW);
}); });
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
const { scheduler, mail } = makeScheduler(store.prisma);
const spy = vi.spyOn(store.prisma, '__tenantClient');
await scheduler.runTick(NOW);
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1);
const client = spy.mock.results[0].value;
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
id: 'u1',
tenantId: 't1',
isActive: true,
});
});
}); });
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => { describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
@@ -26,7 +26,7 @@ const BATCH = 200;
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei * Scheitert der Transport, gibt der Planer den Anspruch wieder frei
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht — * (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse * hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und * des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben * wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`). * („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
* *
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
await this.processCandidate(candidate, now); await this.processCandidate(candidate, now);
} catch (err) { } catch (err) {
// Eine kaputte Zeile darf die uebrigen nicht anhalten. // Eine kaputte Zeile darf die uebrigen nicht anhalten.
this.logger.error( this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
);
} }
} }
} finally { } finally {
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt }, where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
select: { title: true, description: true, dueAt: true }, select: { title: true, description: true, dueAt: true },
}); });
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
const user = await tenantPrisma.user.findFirst({ const user = await tenantPrisma.user.findFirst({
where: { id: c.userId, tenantId: c.tenantId }, where: { id: c.userId, tenantId: c.tenantId, isActive: true },
select: { email: true }, select: { email: true },
}); });
const smtp = await this.settingsService.getSmtpConfig(c.tenantId); const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
if (!row || !user?.email || smtp === null) { if (!row || !user?.email || smtp === null) {
this.logger.log( this.logger.log(
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`, `Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
); );
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04) return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
} }
@@ -1,5 +1,5 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = RemindersController.prototype as any; const proto = RemindersController.prototype as any;
describe('RemindersController — Rollen', () => { describe('RemindersController — Rollen', () => {
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { it.each([
'list',
'emailStatus',
'create',
'update',
'snooze',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
}); });
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
const controller = new RemindersController(makeService() as any); const controller = new RemindersController(makeService() as any);
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect( await expect(
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
expect(out).toEqual({ title: 'b' }); expect(out).toEqual({ title: 'b' });
}); });
it.each([
'title',
'description',
'dueAt',
'emailEnabled',
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
).resolves.toEqual({});
});
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => { it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true }); const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform( const out: any = await pipe.transform(
@@ -16,7 +16,13 @@ function makeFakePrisma() {
const reminder = { const reminder = {
create: vi.fn(async ({ data }: { data: any }) => { create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`; const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data }; const row = {
id,
createdAt: new Date(),
updatedAt: new Date(),
emailEnabled: false,
...data,
};
rows.set(id, row); rows.set(id, row);
return row; return row;
}), }),
@@ -37,6 +43,14 @@ function makeFakePrisma() {
rows.set(where.id, row); rows.set(where.id, row);
return row; return row;
}), }),
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
rows.set(where.id, { ...r, ...data });
return { count: 1 };
}),
delete: vi.fn(async ({ where }: { where: any }) => { delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id); rows.delete(where.id);
}), }),
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }), service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen // ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy(); await expect(
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
).resolves.toBeTruthy();
}); });
}); });
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => { ])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', past(), owner); seed(prisma, 'x', past(), owner);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException); await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException); NotFoundException,
);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.has('x')).toBe(true); expect(prisma.rows.has('x')).toBe(true);
}); });
it('eine unbekannte Kennung ergibt 404', async () => { it('eine unbekannte Kennung ergibt 404', async () => {
const { service } = setup(); const { service } = setup();
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException); await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
NotFoundException,
);
}); });
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => { it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
tenantId: 't1', tenantId: 't1',
userId: 'u1', userId: 'u1',
}); });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({ expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
id: 'x', id: 'x',
tenantId: 't1', tenantId: 't1',
userId: 'u1', userId: 'u1',
dueAt: { gt: expect.any(Date) },
}); });
}); });
}); });
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => { it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', past()); seed(prisma, 'x', past());
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException); await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
ConflictException,
);
}); });
it('eine vergangene neue Faelligkeit ergibt 400', async () => { it('eine vergangene neue Faelligkeit ergibt 400', async () => {
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
const { prisma, service } = setup(); const { prisma, service } = setup();
seed(prisma, 'x', future()); seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' }); await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' }); expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
const newDue = inHours(9); const newDue = inHours(9);
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' }); await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({ expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
description: '', description: '',
dueAt: new Date(newDue), dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
}); });
}); });
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.rows.get('x').emailAttempts).toBe(2);
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
const newDue = inHours(5);
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.rows.get('x')).toMatchObject({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
expect(out.dueAt).toEqual(new Date(newDue));
});
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const r = prisma.rows.get('x');
const loaded = { ...r };
prisma.rows.set('x', { ...r, dueAt: past() });
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.rows.get('x').title).toBe('alt');
});
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const loaded = { ...prisma.rows.get('x') };
prisma.rows.delete('x');
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
}); });
describe('RemindersService — spaeter erinnern (D-03)', () => { describe('RemindersService — spaeter erinnern (D-03)', () => {
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
hasEmail: false, hasEmail: false,
}); });
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail'); expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' }); expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
id: 'u-ohne-mail',
tenantId: 't1',
});
}); });
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => { it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup(); const noMail = setup();
await expect( await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }), noMail.service.create('t1', 'u-ohne-mail', {
title: 'a',
dueAt: inHours(1),
emailEnabled: true,
}),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(noSmtp.prisma.rows.size).toBe(0); expect(noSmtp.prisma.rows.size).toBe(0);
expect(noMail.prisma.rows.size).toBe(0); expect(noMail.prisma.rows.size).toBe(0);
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
BadRequestException, BadRequestException,
); );
await service.update('t1', 'u1', 'x', { emailEnabled: false }); await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false }); expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
}); });
}); });
+38 -6
View File
@@ -5,8 +5,8 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SettingsService } from '../settings/settings.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { SettingsService } from '../settings/settings.service';
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ /** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
@@ -107,26 +107,58 @@ export class RemindersService {
/** /**
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu * Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03). * (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
*
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
*
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
*/ */
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) { async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id); const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
if (row.dueAt.getTime() <= Date.now()) { const now = Date.now();
if (row.dueAt.getTime() <= now) {
throw new ConflictException('Die Erinnerung ist bereits fällig'); throw new ConflictException('Die Erinnerung ist bereits fällig');
} }
const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {}; const data: {
title?: string;
description?: string;
dueAt?: Date;
emailEnabled?: boolean;
emailSentAt?: null;
emailAttempts?: number;
} = {};
if (dto.title !== undefined) data.title = dto.title; if (dto.title !== undefined) data.title = dto.title;
if (dto.description !== undefined) data.description = dto.description; if (dto.description !== undefined) data.description = dto.description;
if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt); if (dto.dueAt !== undefined) {
data.dueAt = this.assertValidDueAt(dto.dueAt);
data.emailSentAt = null;
data.emailAttempts = 0;
}
if (dto.emailEnabled !== undefined) { if (dto.emailEnabled !== undefined) {
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId); if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
data.emailEnabled = dto.emailEnabled; data.emailEnabled = dto.emailEnabled;
} }
return tenantPrisma.reminder.update({ const result = await tenantPrisma.reminder.updateMany({
where: { id, tenantId, userId }, where: { id, tenantId, userId, dueAt: { gt: new Date() } },
data, data,
});
if (result.count !== 1) {
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const updated = await tenantPrisma.reminder.findFirst({
where: { id, tenantId, userId },
select: REMINDER_SELECT, select: REMINDER_SELECT,
}); });
if (!updated) {
throw new NotFoundException('Erinnerung nicht gefunden');
}
return updated;
} }
/** /**
+197 -16
View File
@@ -1,4 +1,5 @@
use semver::Version; use semver::Version;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use tauri::{ use tauri::{
@@ -48,6 +49,59 @@ struct VersionResponse {
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das. /// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
struct PendingUpdate(Mutex<Option<(Update, String)>>); struct PendingUpdate(Mutex<Option<(Update, String)>>);
/// Ablauf-Merker des Updaters neben `PendingUpdate`.
///
/// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im
/// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt).
/// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die
/// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads
/// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein
/// zweiter Klick startete dann einen parallelen Download samt Installer.
///
/// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version …
/// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug
/// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick
/// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker
/// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick
/// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder
/// der Server wechselt.
#[derive(Default)]
struct UpdateFlow {
installing: AtomicBool,
offered: AtomicBool,
}
impl UpdateFlow {
/// Beansprucht die Installation. `false`, wenn bereits eine laeuft --
/// dann darf der Aufrufer nichts starten.
fn try_begin_install(&self) -> bool {
self.installing
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
/// Gibt die Installation nach einem Fehler wieder frei.
fn install_failed(&self) {
self.installing.store(false, Ordering::SeqCst);
}
fn is_installing(&self) -> bool {
self.installing.load(Ordering::SeqCst)
}
fn set_offered(&self, offered: bool) {
self.offered.store(offered, Ordering::SeqCst);
}
/// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn
/// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde
/// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach
/// einem Angebot).
fn install_on_click(&self, had_pending: bool) -> bool {
had_pending || self.offered.load(Ordering::SeqCst)
}
}
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen /// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut; /// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden /// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
@@ -429,28 +483,44 @@ fn grant_server_notifications(app: &AppHandle, url: &str) {
); );
return; return;
}; };
{ let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
let mut granted = match GRANTED_SERVER_ORIGINS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.contains(&pattern) {
return;
}
granted.push(pattern.clone());
}
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications") let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
.remote(pattern) .remote(pattern.to_string())
.local(false) .local(false)
.window("main"); .window("main");
for permission in SERVER_NOTIFICATION_PERMISSIONS { for permission in SERVER_NOTIFICATION_PERMISSIONS {
capability = capability.permission(permission); capability = capability.permission(permission);
} }
if let Err(e) = app.add_capability(capability) { app.add_capability(capability)
});
if let Err(e) = result {
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e); eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
} }
} }
/// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre
/// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige
/// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH
/// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug
/// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch.
/// Bereits vermerkt: `Ok(())` ohne Aufruf.
fn grant_origin_once<E>(
granted: &Mutex<Vec<String>>,
pattern: &str,
grant: impl FnOnce(&str) -> Result<(), E>,
) -> Result<(), E> {
let mut granted = match granted.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if granted.iter().any(|p| p == pattern) {
return Ok(());
}
grant(pattern)?;
granted.push(pattern.to_string());
Ok(())
}
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide /// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in /// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
/// `update_labels`. /// `update_labels`.
@@ -596,7 +666,13 @@ fn clear_check_notice(app: &AppHandle) {
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein /// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem /// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
/// Server-Update passten Datei und Signatur nicht mehr zusammen. /// Server-Update passten Datei und Signatur nicht mehr zusammen.
///
/// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion
/// nichts: kein Menuetext, kein Leeren, keine Ablage.
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) { fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
if app.state::<UpdateFlow>().is_installing() {
return;
}
let update_item = app.state::<TrayItems>().update.clone(); let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_CHECKING); let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
let _ = update_item.set_enabled(false); let _ = update_item.set_enabled(false);
@@ -615,7 +691,14 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein"); .expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match check_for_update(&app, endpoint).await { let result = check_for_update(&app, endpoint).await;
// Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife)
// kann inzwischen eine Installation ausgeloest haben: dann weder
// Menuetext noch Ablage anfassen.
if app.state::<UpdateFlow>().is_installing() {
return;
}
match result {
Ok(Some(mut update)) => { Ok(Some(mut update)) => {
let (menu_text, body) = Version::parse(&update.version) let (menu_text, body) = Version::parse(&update.version)
.map(|release| release_labels(&current, &release)) .map(|release| release_labels(&current, &release))
@@ -640,10 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool)
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text)); *pending = Some((update, menu_text));
} }
app.state::<UpdateFlow>().set_offered(true);
} }
Ok(None) => { Ok(None) => {
let _ = update_item.set_text(UPDATE_ITEM_NONE); let _ = update_item.set_text(UPDATE_ITEM_NONE);
let _ = update_item.set_enabled(true); let _ = update_item.set_enabled(true);
app.state::<UpdateFlow>().set_offered(false);
clear_check_notice(&app); clear_check_notice(&app);
} }
// Der Release-Bau verweigert http-Endpunkte (config.rs // Der Release-Bau verweigert http-Endpunkte (config.rs
@@ -692,7 +777,13 @@ fn open_download_page(app: &AppHandle) {
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder /// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als /// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
/// Rueckfall. /// Rueckfall.
///
/// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation,
/// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall.
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) { fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
if !app.state::<UpdateFlow>().try_begin_install() {
return;
}
let item = app.state::<TrayItems>().update.clone(); let item = app.state::<TrayItems>().update.clone();
let _ = item.set_enabled(false); let _ = item.set_enabled(false);
let _ = item.set_text("Update wird heruntergeladen…"); let _ = item.set_text("Update wird heruntergeladen…");
@@ -738,6 +829,7 @@ fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text.clone())); *pending = Some((update, menu_text.clone()));
} }
app.state::<UpdateFlow>().install_failed();
let _ = item.set_enabled(true); let _ = item.set_enabled(true);
let _ = app let _ = app
.notification() .notification()
@@ -796,6 +888,8 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen. // Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
grant_server_notifications(&app, &normalized); grant_server_notifications(&app, &normalized);
apply_server(&app, Some(&normalized)); apply_server(&app, Some(&normalized));
// Ein Angebot des alten Servers gilt nicht fuer den neuen.
app.state::<UpdateFlow>().set_offered(false);
spawn_version_check(app.clone(), normalized.clone(), false); spawn_version_check(app.clone(), normalized.clone(), false);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
@@ -993,6 +1087,7 @@ pub fn run() {
update: update.clone(), update: update.clone(),
}); });
app.manage(PendingUpdate(Mutex::new(None))); app.manage(PendingUpdate(Mutex::new(None)));
app.manage(UpdateFlow::default());
app.manage(LastCheckNotice(Mutex::new(String::new()))); app.manage(LastCheckNotice(Mutex::new(String::new())));
let autostart_for_menu = autostart.clone(); let autostart_for_menu = autostart.clone();
@@ -1021,6 +1116,14 @@ pub fn run() {
// nur erneut. Der Browser-Weg (`open_download_page`) // nur erneut. Der Browser-Weg (`open_download_page`)
// bleibt Rueckfall einer fehlgeschlagenen Installation. // bleibt Rueckfall einer fehlgeschlagenen Installation.
// Ohne gespeicherte Adresse gibt es nichts zu pruefen. // Ohne gespeicherte Adresse gibt es nichts zu pruefen.
// Waehrend einer Installation tut der Klick nichts
// (`UpdateFlow`). War vorher schon ein Update
// angeboten (Stand nach fehlgeschlagener Pruefung
// weg), installiert der Klick trotzdem direkt.
let flow = app.state::<UpdateFlow>();
if flow.is_installing() {
return;
}
let had_pending = app let had_pending = app
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -1029,7 +1132,8 @@ pub fn run() {
.and_then(|mut guard| guard.take()) .and_then(|mut guard| guard.take())
.is_some(); .is_some();
if let Some(url) = stored_server_url(app) { if let Some(url) = stored_server_url(app) {
spawn_version_check(app.clone(), url, had_pending); let install_after = flow.install_on_click(had_pending);
spawn_version_check(app.clone(), url, install_after);
} }
} }
"autostart" => { "autostart" => {
@@ -1081,11 +1185,16 @@ pub fn run() {
// ein Update, wird der Durchlauf uebersprungen: die Pruefung // ein Update, wird der Durchlauf uebersprungen: die Pruefung
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden. // wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
// Waehrend einer Installation ist der Stand per `take()` leer; // Waehrend einer Installation ist der Stand per `take()` leer;
// faellt die 4-h-Marke genau in den Download, wird nur der // darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert). // und ueberspringt den Durchlauf (vorher: Pruefung mitten im
// Download legte ein neues Update ab und schaltete den Eintrag
// wieder aktiv -- zweiter paralleler Download moeglich).
let handle = app.handle().clone(); let handle = app.handle().clone();
std::thread::spawn(move || loop { std::thread::spawn(move || loop {
std::thread::sleep(UPDATE_CHECK_INTERVAL); std::thread::sleep(UPDATE_CHECK_INTERVAL);
if handle.state::<UpdateFlow>().is_installing() {
continue;
}
let pending = handle let pending = handle
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -1493,6 +1602,78 @@ mod tests {
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich"); assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600)); assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
} }
// --- UpdateFlow ---
#[test]
fn update_flow_nur_eine_installation_gleichzeitig() {
let flow = UpdateFlow::default();
assert!(!flow.is_installing());
assert!(flow.try_begin_install());
assert!(flow.is_installing());
assert!(
!flow.try_begin_install(),
"zweite Installation muss abgewiesen werden"
);
}
#[test]
fn update_flow_fehler_gibt_installation_frei() {
let flow = UpdateFlow::default();
assert!(flow.try_begin_install());
flow.install_failed();
assert!(!flow.is_installing());
assert!(flow.try_begin_install());
}
#[test]
fn update_flow_klick_installiert_bei_abgelegtem_stand() {
let flow = UpdateFlow::default();
assert!(flow.install_on_click(true));
assert!(!flow.install_on_click(false));
}
#[test]
fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() {
let flow = UpdateFlow::default();
flow.set_offered(true);
// Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl.
assert!(flow.install_on_click(false));
// "Kein Update" oder Serverwechsel leert das Angebot.
flow.set_offered(false);
assert!(!flow.install_on_click(false));
}
// --- grant_origin_once ---
#[test]
fn grant_origin_once_vermerkt_erst_nach_erfolg() {
let granted = Mutex::new(Vec::new());
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt"));
assert_eq!(r, Err("kaputt"));
assert!(granted.lock().unwrap().is_empty());
let mut calls = 0;
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| {
calls += 1;
assert_eq!(p, "https://a");
Ok(())
});
assert_eq!(r, Ok(()));
assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen");
assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]);
}
#[test]
fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() {
let granted = Mutex::new(vec!["https://a".to_string()]);
let r: Result<(), &str> =
grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen"));
assert_eq!(r, Ok(()));
let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(()));
assert_eq!(r, Ok(()));
assert_eq!(granted.lock().unwrap().len(), 2);
}
// --- server_origin_* (quick-260929-if2, E-01) --- // --- server_origin_* (quick-260929-if2, E-01) ---
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern { fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.', urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.', saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.', deleteError: 'Das Modul konnte nicht gelöscht werden.',
forbidden: 'Sie haben keine Berechtigung für diese Aktion.',
invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
deleteConfirm: { deleteConfirm: {
title: 'Eigenes Modul löschen', title: 'Eigenes Modul löschen',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.', bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug, ({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
})); }));
const { const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
mockList,
mockCreate,
mockUpdate,
mockDelete,
mockBump,
} = vi.hoisted(() => ({
mockList: vi.fn(), mockList: vi.fn(),
mockCreate: vi.fn(), mockCreate: vi.fn(),
mockUpdate: vi.fn(), mockUpdate: vi.fn(),
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
selector({ user: { role: mockRole } }), selector({ user: { role: mockRole } }),
})); }));
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
import AdminCustomModulesPage from './page'; import AdminCustomModulesPage from './page';
const wiki = { const wiki = {
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument(); expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument(); expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0); expect(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
).toBeGreaterThan(0);
}); });
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => { it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => { it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument(); expect(
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => { it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: leerer Name zeigt die Meldung', async () => { it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' '); await userEvent.type(screen.getByLabelText('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => { it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite '); await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
}); });
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => { it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure'); screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
});
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ category: 'custom-modules' }),
);
});
});
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
});
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
mockList.mockResolvedValue([wiki]);
mockUpdate.mockRejectedValue(
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
const dialog = screen.getByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
);
}); });
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => { it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
mockCreate.mockRejectedValue(new Error('500')); mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge'); await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]); await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite'); await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog'); const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument(); expect(
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' })); await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1')); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
mockList.mockResolvedValue([wiki]); mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' })); await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
);
expect(mockDelete).not.toHaveBeenCalled(); expect(mockDelete).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
mockDelete.mockRejectedValue(new Error('500')); mockDelete.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />); render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' })); await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument(); expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
mockList.mockResolvedValue([wiki]);
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
});
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
});
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => { it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
wiki, wiki,
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
render(<CustomModulesSettingsPage />); render(<CustomModulesSettingsPage />);
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument(); expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument(); expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
expect( expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length, 0,
).toBeGreaterThan(0); );
}); });
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => { it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
expect(mockCreate).toHaveBeenCalledWith({ expect(mockCreate).toHaveBeenCalledWith({
name: 'Mein Wiki', name: 'Mein Wiki',
url: 'https://example.com', url: 'https://example.com',
category: 'infrastructure', category: 'custom-modules',
}); });
}); });
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared'); expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
@@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => {
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument(); expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled(); expect(mockBump).not.toHaveBeenCalled();
}); });
@@ -1,10 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared'; import { useState } from 'react';
import { import {
type CustomModule, type CustomModule,
CustomModuleRequestError,
checkCustomModuleUrl, checkCustomModuleUrl,
createCustomModule, createCustomModule,
updateCustomModule, updateCustomModule,
@@ -27,6 +28,23 @@ interface CustomModuleFormModalProps {
const NAME_MAX = 100; const NAME_MAX = 100;
const URL_MAX = 2048; const URL_MAX = 2048;
/**
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
*/
export function customModuleErrorText(
err: unknown,
t: (key: string, values?: Record<string, string>) => string,
fallbackKey: 'saveError' | 'deleteError',
): string {
if (err instanceof CustomModuleRequestError) {
if (err.status === 403) return t('forbidden');
if (err.status === 400) return t('invalidInput');
}
return t(fallbackKey);
}
/** /**
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`; * Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame, * quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
@@ -47,7 +65,9 @@ export function CustomModuleFormModal({
const [name, setName] = useState(mod?.name ?? ''); const [name, setName] = useState(mod?.name ?? '');
const [url, setUrl] = useState(mod?.url ?? ''); const [url, setUrl] = useState(mod?.url ?? '');
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure'); // Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
// Bearbeiten bleibt die gespeicherte Kategorie.
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -81,8 +101,8 @@ export function CustomModuleFormModal({
} }
onSaved(); onSaved();
onClose(); onClose();
} catch { } catch (err) {
setError(t('saveError')); setError(customModuleErrorText(err, t, 'saveError'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 <h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
>
{texts.title} {texts.title}
</h1> </h1>
<button type="button" onClick={openCreate} className="btn btn-primary"> <button type="button" onClick={openCreate} className="btn btn-primary">
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
</div> </div>
)} )}
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? ( ) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2> <h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p> <p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th> <th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th> {t('name')}
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th> </th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground"> <th className="px-4 py-3 text-right font-medium text-muted-foreground">
{tManage('actions')} {tManage('actions')}
</th> </th>
@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api'; import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
import { customModuleErrorText } from './custom-module-form-modal';
interface DeleteCustomModuleDialogProps { interface DeleteCustomModuleDialogProps {
mod: CustomModule; mod: CustomModule;
@@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({
await deleteCustomModule(mod.id); await deleteCustomModule(mod.id);
onDeleted(); onDeleted();
onClose(); onClose();
} catch { } catch (err) {
setError(t('deleteError')); setError(customModuleErrorText(err, t, 'deleteError'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground"> <h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
{t('deleteConfirm.title')} {t('deleteConfirm.title')}
</h2> </h2>
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p> <p className="mb-4 text-sm text-foreground">
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
name: mod.name,
})}
</p>
{error && ( {error && (
<div <div
@@ -1,4 +1,4 @@
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store'; import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args), updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
})); }));
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
// Erscheinungsbild und die Bilderliste.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}));
const fetchDashboardImages = vi.fn();
vi.mock('@/lib/dashboard-images-api', () => ({
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
uploadDashboardImage: vi.fn(),
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
}));
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
const LEGACY_KEY = 'tessera.dashboardBackground.u1'; const LEGACY_KEY = 'tessera.dashboardBackground.u1';
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser { function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled(); expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
}); });
}); });
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
let rejectFirst: (e: Error) => void = () => {};
updateDashboardBackgroundAction
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce({ success: true });
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
const { result } = await renderBackgroundHook();
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
await act(async () => {
rejectFirst(new Error('offline'));
await Promise.resolve();
});
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
kind: 'preset',
id: 'dunes',
});
});
});
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
class FailingImage {
onerror: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onerror?.(), 0);
}
}
vi.stubGlobal('Image', FailingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
});
it('ein ladbares Bild bleibt gewaehlt', async () => {
class LoadingImage {
onerror: (() => void) | null = null;
onload: (() => void) | null = null;
set src(_value: string) {
setTimeout(() => this.onload?.(), 0);
}
}
vi.stubGlobal('Image', LoadingImage);
useAuthStore.setState({
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
});
const { result } = await renderBackgroundHook();
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
});
});
describe('BackgroundPicker (quick-260930)', () => {
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
const { BackgroundPicker } = await import('./dashboard-background');
const onChange = vi.fn();
render(<BackgroundPicker value={value} onChange={onChange} />);
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
}
beforeEach(() => {
fetchDashboardImages.mockReset();
fetchDashboardImages.mockResolvedValue([]);
});
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const first = screen.getByRole('button', { name: 'none' });
expect(dialog).toContainElement(first);
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
await screen.findByRole('dialog');
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog')).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
const { trigger } = await renderPicker({ kind: 'none' });
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog');
const buttons = Array.from(dialog.querySelectorAll('button'));
const first = buttons[0];
const last = buttons[buttons.length - 1];
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
fetchDashboardImages.mockResolvedValue([
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
]);
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
fireEvent.click(trigger);
await screen.findByRole('button', { name: 'urlaub.jpg' });
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
useAuthStore.getState().setUser({ ...current, dashboardBackground: value }); useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
} }
/**
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
* Klicks sonst die zweite Wahl ueberschreiben.
*/
function revertStoredBackground(
userId: string,
optimistic: DashboardBackground,
previous: DashboardBackground | null,
) {
const current = useAuthStore.getState().user;
if (!current || current.id !== userId) return;
if (current.dashboardBackground !== optimistic) return;
setStoredBackground(userId, previous);
}
/** /**
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj). * Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
* *
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck. * die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor, * Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
* wird sie genau einmal je Benutzerkennung uebernommen. * wird sie genau einmal je Benutzerkennung uebernommen.
*
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
* ist nur Anzeige; gespeichert wird dabei nichts.
*/ */
export function useDashboardBackground() { export function useDashboardBackground() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const userId = user?.id ?? null; const userId = user?.id ?? null;
const stored = user?.dashboardBackground ?? null; const stored = user?.dashboardBackground ?? null;
const background = stored ?? NO_BACKGROUND; const imageId = stored?.kind === 'image' ? stored.imageId : null;
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
const background =
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
const migratedFor = useRef<string | null>(null); const migratedFor = useRef<string | null>(null);
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
// Wahl als „kein Hintergrund“ angezeigt.
useEffect(() => {
if (imageId === null || typeof Image === 'undefined') return;
let active = true;
const probe = new Image();
probe.onerror = () => {
if (active) setBrokenImageId(imageId);
};
probe.src = dashboardImageSrc(imageId);
return () => {
active = false;
probe.onerror = null;
};
}, [imageId]);
const save = useCallback( const save = useCallback(
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => { (id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
setStoredBackground(id, value); setStoredBackground(id, value);
const revert = () => setStoredBackground(id, previous); const revert = () => revertStoredBackground(id, value, previous);
updateDashboardBackgroundAction(value) updateDashboardBackgroundAction(value)
.then((result) => { .then((result) => {
if (!result.success) revert(); if (!result.success) revert();
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
const [images, setImages] = useState<DashboardImageMeta[] | null>(null); const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
const [imagesListed, setImagesListed] = useState(false);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
// (quick-260930).
const shown: DashboardBackground =
value.kind === 'image' &&
imagesListed &&
images !== null &&
!images.some((image) => image.id === value.imageId)
? NO_BACKGROUND
: value;
useEffect(() => { useEffect(() => {
if (!open || images !== null) return; if (!open || images !== null) return;
fetchDashboardImages() fetchDashboardImages()
.then(setImages) .then((list) => {
setImages(list);
setImagesListed(true);
})
.catch(() => setImages([])); .catch(() => setImages([]));
}, [open, images]); }, [open, images]);
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
useEffect(() => {
if (open) {
wasOpen.current = true;
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
const timer = window.setTimeout(() => {
const active = document.activeElement;
if (!active || active === document.body || rootRef.current?.contains(active)) {
triggerRef.current?.focus();
}
}, 0);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onPointer(e: MouseEvent) { function onPointer(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') {
setOpen(false);
return;
}
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
if (e.key === 'Tab' && dialogRef.current) {
const items = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (!active || !dialogRef.current.contains(active)) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
} }
document.addEventListener('mousedown', onPointer); document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
preview: CSSProperties, preview: CSSProperties,
content?: React.ReactNode, content?: React.ReactNode,
) => { ) => {
const selected = sameChoice(value, choice); const selected = sameChoice(shown, choice);
return ( return (
<li key={key}> <li key={key}>
<button <button
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-expanded={open} aria-expanded={open}
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
{open && ( {open && (
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-label={t('title')} aria-label={t('title')}
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border" className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
@@ -1,5 +1,5 @@
import { Children, isValidElement } from 'react';
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid'; import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
@@ -137,9 +137,7 @@ describe('DashboardGrid', () => {
}); });
it('renders widget instance when widgets are provided', async () => { it('renders widget instance when widgets are provided', async () => {
const widgets = [ const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
];
const layouts = { const layouts = {
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }], lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
md: [], md: [],
@@ -167,9 +165,7 @@ describe('DashboardGrid', () => {
}); });
it('shows edit affordances (delete button) when isEditMode is true', async () => { it('shows edit affordances (delete button) when isEditMode is true', async () => {
const widgets = [ const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
{ id: 'inst-2', widgetType: 'clock', config: {} },
];
const layouts = { const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [], md: [],
@@ -312,7 +308,7 @@ describe('DashboardGrid', () => {
expect(layouts.lg[0].minH).toBe(8); expect(layouts.lg[0].minH).toBe(8);
}); });
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => { it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
captured.props = null; captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus. // So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
@@ -341,25 +337,108 @@ describe('DashboardGrid', () => {
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>; const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt. // Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 }); expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 }); expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 }); expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
// Auch der data-grid-Wert der Kinder traegt das neue Minimum. // Auch der data-grid-Wert der Kinder traegt das neue Minimum.
const children = Children.toArray(captured.props?.children as React.ReactNode); const children = Children.toArray(captured.props?.children as React.ReactNode);
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>; const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
expect(grid.minW).toBe(8); 'data-grid'
] as Record<string, unknown>;
expect(grid.minW).toBe(11);
}); });
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
lg: [
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
// Kalender am rechten Rand: x 40 + 11 > 48.
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'cal-1', widgetType: 'calendar', config: {} },
{ id: 'clk-1', widgetType: 'clock', config: {} },
{ id: 'cal-2', widgetType: 'calendar', config: {} },
{ id: 'cal-3', widgetType: 'calendar', config: {} },
{ id: 'clk-2', widgetType: 'clock', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
});
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const layouts = {
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
xxs: [
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
],
};
render(
<DashboardGrid
layouts={layouts}
widgets={[
{ id: 'srch-1', widgetType: 'search', config: {} },
{ id: 'cal-1', widgetType: 'calendar', config: {} },
]}
isEditMode
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
);
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
});
const CANCEL =
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => { it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
const { DashboardGrid } = await import('./dashboard-grid'); const { DashboardGrid } = await import('./dashboard-grid');
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
captured.props = null; captured.props = null;
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(captured.props?.dragConfig).toEqual({ expect(captured.props?.dragConfig).toEqual({
enabled: true, enabled: true,
@@ -372,7 +451,13 @@ describe('DashboardGrid', () => {
captured.props = null; captured.props = null;
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(captured.props?.dragConfig).toEqual({ expect(captured.props?.dragConfig).toEqual({
enabled: false, enabled: false,
@@ -400,7 +485,10 @@ describe('DashboardGrid', () => {
type: unknown; type: unknown;
allowOverlap: boolean; allowOverlap: boolean;
preventCollision?: boolean; preventCollision?: boolean;
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>; compact: (
layout: Array<Record<string, unknown>>,
cols: number,
) => Array<Record<string, unknown>>;
}; };
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true }); expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
expect(typeof compactor.compact).toBe('function'); expect(typeof compactor.compact).toBe('function');
@@ -423,10 +511,22 @@ describe('DashboardGrid', () => {
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL); expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }]; const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] }; const layouts = {
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
};
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={true}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement; const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
@@ -455,7 +555,13 @@ describe('DashboardGrid', () => {
cleanup(); cleanup();
render( render(
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />, <DashboardGrid
layouts={layouts}
widgets={widgets}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
/>,
); );
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull(); expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
expect(document.querySelector('.widget-drag-handle')).toBeNull(); expect(document.querySelector('.widget-drag-handle')).toBeNull();
@@ -512,7 +618,9 @@ describe('DashboardGrid', () => {
expect(captured.props?.width).toBe(1000); expect(captured.props?.width).toBe(1000);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800)); vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
new DOMRect(0, 0, 1600, 800),
);
act(() => { act(() => {
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
}); });
@@ -528,11 +636,16 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 }, { i: 'fav', x: 0, y: 0, w: 6, h: 10 },
{ i: 'below', x: 0, y: 10, w: 6, h: 8 }, { i: 'below', x: 0, y: 10, w: 6, h: 8 },
]; ];
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4]; const context = { layout } as unknown as Parameters<
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
>[4];
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0]; const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => { it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
w: 2,
h: 10,
});
}); });
it('laesst eine freie Groesse unveraendert', () => { it('laesst eine freie Groesse unveraendert', () => {
@@ -1,13 +1,19 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Responsive, noCompactor } from 'react-grid-layout';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { noCompactor, Responsive } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core'; import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry'; import {
clampWidthToCols,
GRID_COLS,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetType,
} from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon'; import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper'; import { WidgetWrapper } from './widgets/widget-wrapper';
@@ -23,7 +29,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration // damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position. // einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }; // Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
// weil auch der Store sie fuer neue Kacheln braucht.
const COLS = GRID_COLS;
// quick-260916-dyv: Ziehen zuverlaessig. // quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -138,6 +146,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich — * chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten. * der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert. * Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
*
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
* verschoben ist.
*/ */
function applyConstraintMinima( function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>, layouts: Record<string, Array<LayoutItemShape>>,
@@ -150,18 +169,42 @@ function applyConstraintMinima(
for (const key of Object.keys(layouts)) { for (const key of Object.keys(layouts)) {
const entries = layouts[key]; const entries = layouts[key];
if (!Array.isArray(entries)) continue; if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => { const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
const targetW = new Map<string, number>();
const next = entries.map((entry) => {
const type = typeById.get(entry.i); const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined; const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry }; if (!constraints) return { ...entry };
const minW = clampWidthToCols(constraints.minW, key);
const w = Math.min(entry.w, cols);
if (w < minW) targetW.set(entry.i, minW);
return { return {
...entry, ...entry,
w: Math.max(entry.w, constraints.minW), w,
h: Math.max(entry.h, constraints.minH), h: Math.max(entry.h, constraints.minH),
minW: constraints.minW, minW,
minH: constraints.minH, minH: constraints.minH,
}; };
}); });
for (const item of next) {
const target = targetW.get(item.i);
if (target === undefined) continue;
const others = next.filter((o) => o !== item);
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
// entstehen nicht durch das Anheben.
const blockers = others.filter((o) => !overlaps(item, o));
while (
item.w < target &&
item.x + item.w + 1 <= cols &&
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
) {
item.w += 1;
}
}
result[key] = next;
} }
return result; return result;
} }
@@ -259,7 +302,10 @@ export function DashboardGrid({
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
// stabil bleibt. // stabil bleibt.
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); const effectiveLayouts = useMemo(
() => applyConstraintMinima(layouts, widgets),
[layouts, widgets],
);
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne // Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten // Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
@@ -269,7 +315,18 @@ export function DashboardGrid({
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center"> <div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8"> <div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground"> <span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="9" rx="1.5" /> <rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" /> <rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" /> <rect x="14" y="12" width="7" height="9" rx="1.5" />
@@ -296,7 +353,19 @@ export function DashboardGrid({
)} )}
{onOpenCatalog && ( {onOpenCatalog && (
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5"> <button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('addWidget')} {t('addWidget')}
</button> </button>
)} )}
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
// Stift gesperrt und bittet ums Neuladen.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
vi.mock('@/lib/stores/dashboard-store', () => ({
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { EditModeToggle } from './edit-mode-toggle';
beforeEach(() => {
storeState.layoutFromNewerVersion = false;
});
describe('EditModeToggle (quick-260930)', () => {
it('normal: Stift ist bedienbar', () => {
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeEnabled();
fireEvent.click(button);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
storeState.layoutFromNewerVersion = true;
const onToggle = vi.fn();
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
const button = screen.getByRole('button', { name: 'editShort' });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('title', 'reloadRequired');
expect(button).toHaveAccessibleDescription('reloadRequired');
fireEvent.click(button);
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useId } from 'react';
import { useDashboardStore } from '@/lib/stores/dashboard-store';
interface EditModeToggleProps { interface EditModeToggleProps {
isEditMode: boolean; isEditMode: boolean;
@@ -13,18 +15,29 @@ interface EditModeToggleProps {
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der * Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus * Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
* der gelbe Knopf „Fertig“. * der gelbe Knopf „Fertig“.
*
* quick-260930: Stammt die geladene Anordnung von einem neueren
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
* ueberschreiben.
*/ */
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) { export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
const hintId = useId();
return ( return (
<>
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`} disabled={locked}
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
aria-pressed={isEditMode} aria-pressed={isEditMode}
aria-label={isEditMode ? undefined : t('editShort')} aria-label={isEditMode ? undefined : t('editShort')}
title={isEditMode ? t('saveChanges') : t('editMode')} aria-describedby={locked ? hintId : undefined}
title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
> >
{isEditMode ? ( {isEditMode ? (
// Checkmark icon // Checkmark icon
@@ -63,5 +76,11 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
)} )}
{isEditMode ? t('done') : null} {isEditMode ? t('done') : null}
</button> </button>
{locked && (
<span id={hintId} className="sr-only">
{t('reloadRequired')}
</span>
)}
</>
); );
} }
@@ -1,13 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import { WIDGET_TYPES } from '@tessera/shared'; import { WIDGET_TYPES } from '@tessera/shared';
import { describe, expect, it, vi } from 'vitest';
import { import {
clampWidthToCols,
GRID_COLS,
registerWidget,
visibleWidgetTypes,
WIDGET_CONSTRAINTS, WIDGET_CONSTRAINTS,
WIDGET_REGISTRY, WIDGET_REGISTRY,
type WidgetDefinition, type WidgetDefinition,
type WidgetProps, type WidgetProps,
type WidgetType, type WidgetType,
registerWidget,
visibleWidgetTypes,
} from './widget-registry'; } from './widget-registry';
/** /**
@@ -38,16 +40,15 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
describe('WIDGET_CONSTRAINTS (DASH-11)', () => { describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('contains entries for all expected widget types', () => { it('contains entries for all expected widget types', () => {
for (const type of ALL_WIDGET_TYPES) { for (const type of ALL_WIDGET_TYPES) {
expect( expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
WIDGET_CONSTRAINTS, type,
`WIDGET_CONSTRAINTS must contain key "${type}"`, );
).toHaveProperty(type);
} }
}); });
it.each(ALL_WIDGET_TYPES)( it.each(
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', ALL_WIDGET_TYPES,
(type) => { )('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
const constraints = WIDGET_CONSTRAINTS[type]; const constraints = WIDGET_CONSTRAINTS[type];
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number'); expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
@@ -57,10 +58,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1); expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1); expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW); expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH); constraints.minW,
},
); );
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
constraints.minH,
);
});
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => { it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator'); expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
@@ -71,12 +75,13 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => { it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8 // Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24). // (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
expect(WIDGET_CONSTRAINTS).toEqual({ expect(WIDGET_CONSTRAINTS).toEqual({
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 }, clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 }, search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 }, calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 }, calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 }, favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
@@ -98,6 +103,38 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
}); });
}); });
describe('Kalender-minW (quick-260930)', () => {
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
const margin = 12;
const cols = GRID_COLS.lg;
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
const px = (w: number) => w * colWidth + (w - 1) * margin;
const { minW } = WIDGET_CONSTRAINTS.calendar;
expect(px(minW)).toBeGreaterThanOrEqual(250);
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
expect(px(minW - 1)).toBeLessThan(250);
});
});
describe('clampWidthToCols (quick-260930)', () => {
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
expect(clampWidthToCols(24, 'xxs')).toBe(4);
expect(clampWidthToCols(24, 'xs')).toBe(16);
expect(clampWidthToCols(24, 'lg')).toBe(24);
expect(clampWidthToCols(3, 'xxs')).toBe(3);
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
});
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
for (const bp of Object.keys(GRID_COLS)) {
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
}
}
});
});
/** /**
* quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau * quick-260922-m1h: Deckungsgleichheit. Die Typliste steht seit diesem Umbau
* EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die * EINMAL in `packages/shared`; Registry, Constraints-Tabelle und die
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react';
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared'; import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
import type { ComponentType } from 'react';
/** /**
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in * Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
@@ -57,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter // Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
// den Monat nur ein Termin. // den Monat nur ein Termin.
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von // quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster, // rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px // bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite. // damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 }, // der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
// soweit rechts Platz ist (applyConstraintMinima).
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 }, note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
// Phase 8 new widgets (D-01) // Phase 8 new widgets (D-01)
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
@@ -98,6 +104,24 @@ export const WIDGET_CONSTRAINTS: Record<
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
}; };
/**
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
*/
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
/**
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
* Breakpoints bleiben unbegrenzt.
*/
export function clampWidthToCols(width: number, breakpoint: string): number {
const cols = GRID_COLS[breakpoint];
return cols === undefined ? width : Math.min(width, cols);
}
/** /**
* Definition for a widget type in the catalog. * Definition for a widget type in the catalog.
*/ */
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import { import {
createReminder,
type Reminder, type Reminder,
type ReminderEmailStatus, type ReminderEmailStatus,
ReminderRequestError, ReminderRequestError,
createReminder,
updateReminder, updateReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
@@ -85,9 +85,15 @@ export function ReminderFormModal({
setError(t('reminder.titleRequired')); setError(t('reminder.titleRequired'));
return; return;
} }
const dueAt = localInputsToIso(date, time); // Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen. // Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) { if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
setError(t('reminder.pastError')); setError(t('reminder.pastError'));
return; return;
} }
@@ -98,16 +104,15 @@ export function ReminderFormModal({
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400 // ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen. // ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled; const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
const input = { const common = {
title: trimmedTitle, title: trimmedTitle,
description: description.trim(), description: description.trim(),
dueAt,
...(emailChanged ? { emailEnabled } : {}), ...(emailChanged ? { emailEnabled } : {}),
}; };
if (reminder) { if (reminder) {
await updateReminder(reminder.id, input); await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
} else { } else if (dueAt) {
await createReminder(input); await createReminder({ ...common, dueAt });
} }
onSaved(); onSaved();
onClose(); onClose();
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
}); });
import { import {
ReminderRequestError,
createReminder, createReminder,
deleteReminder, deleteReminder,
getReminderEmailStatus, getReminderEmailStatus,
listReminders, listReminders,
ReminderRequestError,
snoozeReminder, snoozeReminder,
updateReminder, updateReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
@@ -79,7 +79,9 @@ async function openForm() {
function fillAndSubmit(title: string) { function fillAndSubmit(title: string) {
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } }); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2099-03-04' },
});
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } }); fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
fireEvent.click(screen.getByText('reminder.save')); fireEvent.click(screen.getByText('reminder.save'));
} }
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
render(<ReminderWidget {...props} />); render(<ReminderWidget {...props} />);
await openForm(); await openForm();
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } }); fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } }); fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2001-01-01' },
});
fireEvent.click(screen.getByText('reminder.save')); fireEvent.click(screen.getByText('reminder.save'));
expect(await screen.findByText('reminder.pastError')).toBeTruthy(); expect(await screen.findByText('reminder.pastError')).toBeTruthy();
expect(mockCreate).not.toHaveBeenCalled(); expect(mockCreate).not.toHaveBeenCalled();
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
expect(requestPermission).not.toHaveBeenCalled(); expect(requestPermission).not.toHaveBeenCalled();
}); });
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
const due = new Date(Date.now() + 30_000);
due.setSeconds(due.getSeconds(), 500);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(screen.queryByText('reminder.pastError')).toBeNull();
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
});
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
const due = new Date(Date.now() + 2 * 3600_000);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const next = new Date(Date.now() + 26 * 3600_000);
const pad = (n: number) => String(n).padStart(2, '0');
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
});
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => { it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
mockSnooze.mockRejectedValue(new ReminderRequestError(409)); mockSnooze.mockRejectedValue(new ReminderRequestError(409));
@@ -313,6 +349,27 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
}); });
}); });
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
afterEach(() => {
vi.useRealTimers();
});
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
mockList.mockResolvedValue([
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
]);
mockSnooze.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.snooze'));
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() { async function openFormAndWaitForStatus() {
await openForm(); await openForm();
@@ -383,7 +440,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => { it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true }); mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([ mockList.mockResolvedValue([
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true }, {
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
emailEnabled: true,
},
]); ]);
mockUpdate.mockResolvedValue({}); mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />); render(<ReminderWidget {...props} />);
@@ -3,15 +3,15 @@
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { browserPermissionState } from '@/lib/reminder-notify'; import { browserPermissionState } from '@/lib/reminder-notify';
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import { import {
deleteReminder,
getReminderEmailStatus,
listReminders,
REMINDERS_CHANGED_EVENT, REMINDERS_CHANGED_EVENT,
type Reminder, type Reminder,
type ReminderEmailStatus, type ReminderEmailStatus,
ReminderRequestError, ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder, snoozeReminder,
} from '@/lib/reminders-api'; } from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry'; import type { WidgetProps } from '../widget-registry';
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */ /** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
const handleFailure = (err: unknown, conflictKey: string) => { const handleFailure = (err: unknown, conflictKey: string) => {
setNotice( setNotice(
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'), err instanceof ReminderRequestError && err.status === 409
? t(conflictKey)
: t('reminder.saveError'),
); );
void refetch(); void refetch();
}; };
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
} }
}; };
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
const tomorrowLabel = (r: Reminder) => {
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
const time = timeOnly.format(target);
return isSameLocalDay(target, new Date(now))
? t('reminder.snoozeToday', { time })
: t('reminder.snoozeTomorrow', { time });
};
const remove = async (id: string) => { const remove = async (id: string) => {
setNotice(null); setNotice(null);
setConfirmDeleteId(null); setConfirmDeleteId(null);
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
} }
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p> <p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{r.title}
</p>
{r.emailEnabled && ( {r.emailEnabled && (
<span <span
className="shrink-0 text-muted-foreground" className="shrink-0 text-muted-foreground"
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
> >
{preset === '10m' && t('reminder.snooze10m')} {preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')} {preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' && {preset === 'tomorrow' && tomorrowLabel(r)}
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
</button> </button>
))} ))}
</div> </div>
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()} onRemove={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' })); const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true }); expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
// im Bearbeitungsmodus bleibt der Titel sichtbar // im Bearbeitungsmodus bleibt der Titel sichtbar
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title'); expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
onRemove={vi.fn()} onRemove={vi.fn()}
/>, />,
); );
fireEvent.click(screen.getByRole('button', { name: 'showTitle' })); // quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
const toggle = screen.getByRole('button', { name: 'hideTitle' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
fireEvent.click(toggle);
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false }); expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
rerender( rerender(
@@ -48,7 +48,12 @@ interface WidgetWrapperProps {
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx. * .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
* Renders the actual widget component via WIDGET_REGISTRY lookup. * Renders the actual widget component via WIDGET_REGISTRY lookup.
*/ */
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) { export function WidgetWrapper({
widget,
isEditMode,
onRemove,
enterIndex = 0,
}: WidgetWrapperProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType]; const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
const WidgetComponent = definition?.component; const WidgetComponent = definition?.component;
@@ -103,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
void setWidgetConfig(widget.id, { hideTitle: !titleHidden }); void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
}} }}
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`} className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
aria-label={titleHidden ? t('showTitle') : t('hideTitle')} // quick-260930: feste Beschriftung plus aria-pressed — ein
title={titleHidden ? t('showTitle') : t('hideTitle')} // Umschalter, dessen Name mit dem Zustand wechselt, liest sich
// doppelt („Titel einblenden, gedrueckt“).
aria-label={t('hideTitle')}
title={t('hideTitle')}
aria-pressed={titleHidden} aria-pressed={titleHidden}
data-no-drag="" data-no-drag=""
> >
@@ -186,9 +194,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */ stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3"> <div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
{t('unavailable')}
</p>
</div> </div>
)} )}
</div> </div>
+54 -10
View File
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
})); }));
vi.mock('next/link', () => ({ vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => ( default: ({
<a href={href} className={className}>{children}</a> children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
), ),
})); }));
@@ -100,9 +110,7 @@ const mockActiveModules = [
function stubFetch() { function stubFetch() {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn(() => vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
),
); );
} }
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument(); expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
}); });
const groups = Array.from( const groups = Array.from(
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'), screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent); ).map((n) => n.textContent);
expect(groups.at(-1)).toBe('custom-modules'); expect(groups.at(-1)).toBe('custom-modules');
expect(groups).toContain('fleet'); expect(groups).toContain('fleet');
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
render(<Sidebar />); render(<Sidebar />);
const hrefs = () => const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href')); Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => { await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-1'); expect(hrefs()).toContain('/modules/custom/cm-1');
}); });
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1'); expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1')); expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
hrefs().indexOf('/modules/custom/cm-1'),
);
});
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
];
mockSidebarCollapsed(true);
const Sidebar = await importSidebar();
render(<Sidebar />);
const hrefs = () =>
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
a.getAttribute('href'),
);
await waitFor(() => {
expect(hrefs()).toContain('/modules/custom/cm-2');
});
expect(hrefs()).toEqual([
'/modules/Domain-Tools/domaincheck',
'/modules/custom/cm-1',
'/modules/Utilities/converter',
'/modules/custom/cm-2',
]);
}); });
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
.getByText('Domain-Tools') .getByText('Domain-Tools')
.closest('li') .closest('li')
?.querySelectorAll('a span.truncate'); ?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']); expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Domaincheck',
'Aaa Eigenes',
]);
}); });
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => { it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
expect( expect(
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'), screen
.getByText('Domaincheck')
.closest('a')
?.querySelector('[data-testid="nav-selection-pill"]'),
).toBeNull(); ).toBeNull();
}); });
+120 -45
View File
@@ -1,20 +1,20 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { ModuleTile } from '@/components/modules/module-tile';
import { useCategoryLabel } from '@/lib/use-category-label';
import { useNavStore } from '@/lib/stores/nav-store';
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting'; import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api'; import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */ /** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
function SelectionPill() { function SelectionPill() {
@@ -109,13 +109,17 @@ export function Sidebar() {
const linkClass = (href: string) => itemClass(isActive(href)); const linkClass = (href: string) => itemClass(isActive(href));
const isEntryActive = (entry: SidebarEntry) => const isEntryActive = (entry: SidebarEntry) =>
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref)); isActive(entry.href) ||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die // Moduleintraege unter einer Kategorie eine Stufe kleiner als die
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift; // Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
// die 20-px-Modulkachel passt weiter hinein. // die 20-px-Modulkachel passt weiter hinein.
const moduleItemClass = (active: boolean) => const moduleItemClass = (active: boolean) =>
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]'); itemClass(active)
.replace('h-9', 'h-8')
.replace('gap-3', 'gap-2.5')
.replace('text-sm', 'text-[13px]');
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt // Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch // 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
@@ -186,28 +190,29 @@ export function Sidebar() {
}); });
}; };
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“ // Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// also auch hier immer zuletzt. // die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
const list = categories.get(entry.category);
if (list) list.push(entry);
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
}, [entries]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
const collapsedEntries = useMemo( const collapsedEntries = useMemo(
() => () => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
[...entries].sort( [orderedCategories],
(a, b) =>
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
),
[entries],
); );
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>(); const ordered = orderedCategories;
for (const entry of entries) {
if (!categories.has(entry.category)) categories.set(entry.category, []);
categories.get(entry.category)!.push(entry);
}
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
const ordered = Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
);
if (!searchQuery) return ordered; if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
@@ -221,10 +226,22 @@ export function Sidebar() {
} }
} }
return result; return result;
}, [entries, searchQuery, categoryLabel]); }, [orderedCategories, searchQuery, categoryLabel]);
const navIcon = (children: React.ReactNode) => ( const navIcon = (children: React.ReactNode) => (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width={isCollapsed ? 22 : 20}
height={isCollapsed ? 22 : 20}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
{children} {children}
</svg> </svg>
); );
@@ -234,7 +251,12 @@ export function Sidebar() {
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}> <nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}> <ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
<li> <li>
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}> <Link
href="/"
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
aria-label={t('dashboard')}
title={isCollapsed ? t('dashboard') : undefined}
>
{isActive('/') && <SelectionPill />} {isActive('/') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -248,7 +270,16 @@ export function Sidebar() {
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}> <Link
href="/marketplace"
className={
isCollapsed
? collapsedItemClass(isActive('/marketplace'))
: linkClass('/marketplace')
}
aria-label={t('marketplace')}
title={isCollapsed ? t('marketplace') : undefined}
>
{isActive('/marketplace') && <SelectionPill />} {isActive('/marketplace') && <SelectionPill />}
{navIcon( {navIcon(
<> <>
@@ -268,9 +299,18 @@ export function Sidebar() {
const active = isEntryActive(entry); const active = isEntryActive(entry);
return ( return (
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}> <Link
href={entry.href}
className={collapsedItemClass(active)}
aria-label={entry.name}
title={entry.name}
>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={24}
tone={active ? 'accent' : 'neutral'}
/>
</Link> </Link>
</li> </li>
); );
@@ -290,9 +330,7 @@ export function Sidebar() {
{t('categories.empty')} {t('categories.empty')}
</div> </div>
) : filteredCategories.length === 0 ? ( ) : filteredCategories.length === 0 ? (
<div className="px-2.5 py-2 text-sm text-muted-foreground"> <div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
{t('noResults')}
</div>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{filteredCategories.map(([category, categoryEntries]) => { {filteredCategories.map(([category, categoryEntries]) => {
@@ -307,7 +345,19 @@ export function Sidebar() {
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
> >
<span className="truncate">{categoryLabel(category)}</span> <span className="truncate">{categoryLabel(category)}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
>
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
</svg> </svg>
</button> </button>
@@ -319,7 +369,11 @@ export function Sidebar() {
<li key={entry.key}> <li key={entry.key}>
<Link href={entry.href} className={moduleItemClass(active)}> <Link href={entry.href} className={moduleItemClass(active)}>
{active && <SelectionPill />} {active && <SelectionPill />}
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} /> <ModuleTile
slug={entry.tileSlug}
size={20}
tone={active ? 'accent' : 'neutral'}
/>
<span className="truncate">{entry.name}</span> <span className="truncate">{entry.name}</span>
</Link> </Link>
</li> </li>
@@ -353,7 +407,19 @@ export function Sidebar() {
aria-label={isCollapsed ? t('expand') : t('collapse')} aria-label={isCollapsed ? t('expand') : t('collapse')}
title={isCollapsed ? t('expand') : undefined} title={isCollapsed ? t('expand') : undefined}
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
>
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" /> <path d="M9 3v18" />
<path d="m16 15-3-3 3-3" /> <path d="m16 15-3-3 3-3" />
@@ -377,9 +443,7 @@ export function Sidebar() {
<aside <aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block" className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
style={{ style={{
width: isCollapsed width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
}} }}
> >
{sidebarContent} {sidebarContent}
@@ -408,7 +472,18 @@ export function Sidebar() {
aria-label={tCommon('close')} aria-label={tCommon('close')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground" className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
> >
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
@@ -1,12 +1,14 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
vi.mock('next-intl', () => ({ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => { useTranslations: () => (key: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
openInNewTab: 'In neuem Tab öffnen', openInNewTab: 'In neuem Tab öffnen',
notFound: 'Dieses Modul gibt es nicht mehr.', notFound: 'Dieses Modul gibt es nicht mehr.',
loadError: 'Dieses Modul konnte nicht geladen werden.',
invalidUrl: 'Ungültige Adresse', invalidUrl: 'Ungültige Adresse',
loading: 'Wird geladen', loading: 'Wird geladen',
}; };
@@ -106,4 +108,19 @@ describe('CustomModuleView', () => {
}); });
expect(container.querySelector('iframe')).toBeNull(); expect(container.querySelector('iframe')).toBeNull();
}); });
it.each([
['500', new CustomModuleRequestError(500, 'Internal server error')],
['403', new CustomModuleRequestError(403, 'Forbidden')],
['Netzwerkfehler', new TypeError('Failed to fetch')],
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
mockGetCustomModule.mockRejectedValue(err);
const { container } = render(<CustomModuleView id="abc" />);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Dieses Modul konnte nicht geladen werden.',
);
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
expect(container.querySelector('iframe')).toBeNull();
});
}); });
@@ -1,13 +1,17 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api'; import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule }; type LoadState =
| { status: 'loading' }
| { status: 'missing' }
| { status: 'failed' }
| { status: 'ready'; mod: CustomModule };
/** /**
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen * Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
@@ -19,6 +23,8 @@ type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'read
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne * Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit * Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03). * `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
*/ */
export function CustomModuleView({ id }: { id: string }) { export function CustomModuleView({ id }: { id: string }) {
const t = useTranslations('customModules'); const t = useTranslations('customModules');
@@ -37,7 +43,7 @@ export function CustomModuleView({ id }: { id: string }) {
setState(mod ? { status: 'ready', mod } : { status: 'missing' }); setState(mod ? { status: 'ready', mod } : { status: 'missing' });
}) })
.catch(() => { .catch(() => {
if (!cancelled) setState({ status: 'missing' }); if (!cancelled) setState({ status: 'failed' });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@@ -50,6 +56,13 @@ export function CustomModuleView({ id }: { id: string }) {
if (state.status === 'missing') { if (state.status === 'missing') {
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>; return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
} }
if (state.status === 'failed') {
return (
<div role="alert" className="p-4 text-sm text-destructive">
{t('loadError')}
</div>
);
}
const { mod } = state; const { mod } = state;
const urlOk = checkCustomModuleUrl(mod.url) === 'ok'; const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
}); });
vi.mock('@/lib/reminder-notify', async () => { vi.mock('@/lib/reminder-notify', async () => {
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify'); const actual =
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
return { ...actual, showReminderNotification: vi.fn(async () => undefined) }; return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
}); });
import { showReminderNotification } from '@/lib/reminder-notify'; import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
import { ReminderRequestError, listReminders } from '@/lib/reminders-api'; import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
import { ReminderNotifier } from './reminder-notifier'; import { ReminderNotifier } from './reminder-notifier';
const mockList = listReminders as ReturnType<typeof vi.fn>; const mockList = listReminders as ReturnType<typeof vi.fn>;
@@ -53,6 +54,7 @@ beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
window.localStorage.clear(); window.localStorage.clear();
resetNotifiedMemoryForTests();
mockList.mockReset(); mockList.mockReset();
mockShow.mockClear(); mockShow.mockClear();
}); });
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
}); });
it('meldet nichts, solange nichts faellig ist', async () => { it('meldet nichts, solange nichts faellig ist', async () => {
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]); mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
]);
render(<ReminderNotifier />); render(<ReminderNotifier />);
await advance(30_000); await advance(30_000);
expect(mockShow).not.toHaveBeenCalled(); expect(mockShow).not.toHaveBeenCalled();
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => { it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
mockList.mockResolvedValue([ mockList.mockResolvedValue([
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }), reminder({
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
description: 'x'.repeat(300),
}),
]); ]);
render(<ReminderNotifier />); render(<ReminderNotifier />);
await advance(0); await advance(0);
+59 -9
View File
@@ -36,7 +36,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts(altLayouts()); const { layouts, migrated } = migrateGridLayouts(altLayouts());
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false, i: 'a',
x: 4,
y: 4,
w: 8,
h: 6,
minW: 8,
minH: 4,
moved: false,
static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
@@ -53,7 +61,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 }); const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
expect(layouts.lg[0]).toEqual({ expect(layouts.lg[0]).toEqual({
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false, i: 'a',
x: 2,
y: 2,
w: 4,
h: 3,
minW: 4,
minH: 2,
moved: false,
static: false,
}); });
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 }); expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 }); expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
@@ -131,6 +147,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
const future = migrateGridLayouts({ ...alt, __gridVersion: 4 }); const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
expect(future.layouts).toEqual(alt); expect(future.layouts).toEqual(alt);
expect(future.migrated).toBe(false); expect(future.migrated).toBe(false);
expect(future.newer).toBe(true);
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' }); const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
expect(stringMarker.migrated).toBe(true); expect(stringMarker.migrated).toBe(true);
@@ -138,24 +155,57 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY); expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
const odd = migrateGridLayouts({ const odd = migrateGridLayouts({
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }], lg: [
{
i: 'c',
x: 'kaputt',
y: 1,
w: 2,
h: 2,
resizeHandles: ['se'],
moved: true,
static: false,
},
],
}); });
expect(odd.layouts.lg[0]).toEqual({ expect(odd.layouts.lg[0]).toEqual({
i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false, i: 'c',
x: 'kaputt',
y: 2,
w: 8,
h: 4,
resizeHandles: ['se'],
moved: true,
static: false,
}); });
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false); expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
expect(odd.migrated).toBe(true); expect(odd.migrated).toBe(true);
}); });
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => { it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] }); const r = migrateGridLayouts({
lg: 'kaputt',
md: null,
sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }],
});
expect(Object.keys(r.layouts)).toEqual(['sm']); expect(Object.keys(r.layouts)).toEqual(['sm']);
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 }); expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true); expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false });
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false }); expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: false });
});
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
const alt = altLayouts();
expect(migrateGridLayouts(alt).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: 2 }).newer).toBe(false);
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION }).newer).toBe(false);
const future = migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION + 1 });
expect(future.newer).toBe(true);
// Unveraendert uebernommen, nichts skaliert.
expect(future.layouts).toEqual(alt);
}); });
}); });
+16 -3
View File
@@ -34,6 +34,13 @@
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))` * Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`. * liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
* *
* Neuere Marke (quick-260930): Traegt das JSON eine HOEHERE Marke als
* `GRID_VERSION`, hat es ein neuerer Programmstand geschrieben (etwa ein
* frisch geladener Tab neben einem alten). Umgerechnet wird dann nichts, aber
* `newer: true` gemeldet — der Store darf diese Anordnung NIE speichern: er
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
* sie beim naechsten Laden ein zweites Mal skalieren.
*
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API * Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem * das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne * GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
@@ -72,14 +79,20 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob * Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern). * etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
*/ */
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } { export function migrateGridLayouts(raw: unknown): {
layouts: GridLayouts;
migrated: boolean;
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
newer: boolean;
} {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
return { layouts: {}, migrated: false }; return { layouts: {}, migrated: false, newer: false };
} }
const markerValue = raw[GRID_VERSION_KEY]; const markerValue = raw[GRID_VERSION_KEY];
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt. // Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
const version = typeof markerValue === 'number' ? markerValue : 1; const version = typeof markerValue === 'number' ? markerValue : 1;
const newer = version > GRID_VERSION;
const steps = MIGRATION_STEPS.filter((step) => version <= step.from); const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
const needsScaling = steps.length > 0; const needsScaling = steps.length > 0;
@@ -108,7 +121,7 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
}); });
} }
return { layouts, migrated }; return { layouts, migrated, newer };
} }
/** /**
+27 -1
View File
@@ -5,6 +5,7 @@ import {
isTauriWebview, isTauriWebview,
remindersToNotify, remindersToNotify,
requestBrowserPermissionOnce, requestBrowserPermissionOnce,
resetNotifiedMemoryForTests,
showReminderNotification, showReminderNotification,
withNotifyLock, withNotifyLock,
} from './reminder-notify'; } from './reminder-notify';
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
resetNotifiedMemoryForTests();
}); });
afterEach(() => { afterEach(() => {
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}'); const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
expect(Object.keys(stored)).toEqual(['neu']); expect(Object.keys(stored)).toEqual(['neu']);
}); });
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('SecurityError');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError');
});
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
});
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
throw new Error('SecurityError');
});
expect(claimNotification('k', NOW)).toBe(true);
expect(claimNotification('k', NOW + 10_000)).toBe(false);
});
}); });
describe('remindersToNotify', () => { describe('remindersToNotify', () => {
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
const invoke = installTauri(); const invoke = installTauri();
invoke.mockRejectedValue(new Error('nope')); invoke.mockRejectedValue(new Error('nope'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined(); await expect(
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledTimes(1);
}); });
+23 -9
View File
@@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */ /** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export type BrowserPermissionState = export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
| 'desktop'
| 'granted'
| 'default'
| 'denied'
| 'unsupported';
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>; type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
@@ -135,6 +130,19 @@ function readNotified(): Record<string, number> {
} }
} }
/**
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
*/
const notifiedInMemory = new Map<string, number>();
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
export function resetNotifiedMemoryForTests(): void {
notifiedInMemory.clear();
}
/** /**
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so * Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere * meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
@@ -143,16 +151,22 @@ function readNotified(): Record<string, number> {
* aelter als 7 Tage werden dabei entfernt. * aelter als 7 Tage werden dabei entfernt.
*/ */
export function claimNotification(key: string, nowMs: number): boolean { export function claimNotification(key: string, nowMs: number): boolean {
for (const [k, at] of notifiedInMemory) {
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
}
const record = readNotified(); const record = readNotified();
for (const [k, at] of Object.entries(record)) { for (const [k, at] of Object.entries(record)) {
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k]; if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
} }
const first = !(key in record); const first = !(key in record) && !notifiedInMemory.has(key);
if (first) record[key] = nowMs; if (first) {
record[key] = nowMs;
notifiedInMemory.set(key, nowMs);
}
try { try {
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record)); window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
} catch { } catch {
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */ /* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
} }
return first; return first;
} }
+25 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
defaultNewReminderInputs, defaultNewReminderInputs,
isoToLocalInputs, isoToLocalInputs,
isSameLocalDay,
localInputsToIso, localInputsToIso,
snoozeTarget, snoozeTarget,
} from './reminder-time'; } from './reminder-time';
@@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => {
}); });
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => { it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime()); expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
local(2026, 9, 30, 14, 0).getTime(),
);
}); });
it('morgen ueber das Monatsende', () => { it('morgen ueber das Monatsende', () => {
@@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => {
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime()); expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
}); });
}); });
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
const n = local(2026, 9, 30, 0, 10);
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
expect(isSameLocalDay(target, n)).toBe(true);
});
it('der Normalfall liegt am naechsten Tag', () => {
const n = local(2026, 9, 29, 14, 5);
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
expect(isSameLocalDay(target, n)).toBe(false);
});
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
});
});
+13 -1
View File
@@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow';
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit- * Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00, * sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig, * verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. * landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
* (`isSameLocalDay`), nicht aus dem Namen der Option.
*/ */
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date { export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000); if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
@@ -56,3 +59,12 @@ export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Dat
} while (d.getTime() <= now.getTime()); } while (d.getTime() <= now.getTime());
return d; return d;
} }
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
export function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
+142 -8
View File
@@ -53,6 +53,7 @@ beforeEach(() => {
isDirty: false, isDirty: false,
isLoading: false, isLoading: false,
error: null, error: null,
layoutFromNewerVersion: false,
}); });
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]); vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
@@ -68,7 +69,11 @@ afterEach(() => {
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => { describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => { it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
}); });
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
@@ -88,7 +93,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => { it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 3,
}); });
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
@@ -129,7 +139,11 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => { it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
}); });
vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed')); vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -197,7 +211,9 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => { it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]); vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] }); useDashboardStore
.getState()
.updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
vi.mocked(api.saveLayout).mockClear(); vi.mocked(api.saveLayout).mockClear();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([]); vi.mocked(api.fetchWidgets).mockResolvedValue([]);
@@ -213,9 +229,16 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.fetchLayout).mockResolvedValue({ vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3, lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 3,
}); });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); vi.mocked(api.fetchWidgets).mockResolvedValue([
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
]);
await useDashboardStore.getState().selectDashboard('dash-2'); await useDashboardStore.getState().selectDashboard('dash-2');
@@ -253,7 +276,11 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => { it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => {
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 }); vi.mocked(api.renameDashboardTab).mockResolvedValue({
id: 'dash-1',
name: 'Finanzen',
position: 0,
});
await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen'); await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen');
@@ -265,7 +292,9 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
await useDashboardStore.getState().loadDashboard(); await useDashboardStore.getState().loadDashboard();
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined); vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY }); vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]); vi.mocked(api.fetchWidgets).mockResolvedValue([
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
]);
await useDashboardStore.getState().deleteDashboard('dash-1'); await useDashboardStore.getState().deleteDashboard('dash-1');
@@ -320,3 +349,108 @@ describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, T
expect(errorSpy).toHaveBeenCalledTimes(1); expect(errorSpy).toHaveBeenCalledTimes(1);
}); });
}); });
describe('dashboard-store — Anordnung eines neueren Programmstands (quick-260930)', () => {
const FUTURE = {
lg: [{ i: 'a', x: 2, y: 0, w: 8, h: 4 }],
md: [],
sm: [],
xs: [],
xxs: [],
__gridVersion: 99,
};
it('Test 18: hoehere Marke -> Merker gesetzt, nichts skaliert, nie gespeichert, Bearbeiten gesperrt', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...FUTURE });
await useDashboardStore.getState().loadDashboard();
const state = useDashboardStore.getState();
expect(state.layoutFromNewerVersion).toBe(true);
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 0, w: 8, h: 4 });
expect(api.saveLayout).not.toHaveBeenCalled();
// Bearbeiten bleibt aus.
useDashboardStore.getState().setEditMode(true);
expect(useDashboardStore.getState().isEditMode).toBe(false);
// Auch ein ausdrueckliches Speichern (etwa nach einer Aenderung) schreibt nichts.
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }] });
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 19: Wechsel von einem Reiter mit neuerer Marke speichert ihn nicht; ein normaler Reiter hebt den Merker wieder auf', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...FUTURE });
await useDashboardStore.getState().loadDashboard();
useDashboardStore.setState({ isDirty: true });
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...EMPTY, __gridVersion: 3 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(api.saveLayout).not.toHaveBeenCalled();
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
useDashboardStore.getState().setEditMode(true);
expect(useDashboardStore.getState().isEditMode).toBe(true);
});
it('Test 20: normale Anordnung -> Merker aus, Speichern wie gewohnt', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY, __gridVersion: 3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
});
describe('dashboard-store — Breiten je Breakpoint (quick-260930)', () => {
it('Test 21: neue Kachel ist in keinem Breakpoint breiter als dessen Spaltenzahl', async () => {
await useDashboardStore.getState().loadDashboard();
useDashboardStore.setState({ layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] } });
vi.mocked(api.addWidget).mockResolvedValue({ id: 's1', widgetType: 'search', config: {} });
await useDashboardStore.getState().addWidget('search');
const { layouts } = useDashboardStore.getState();
expect(layouts.lg[0].w).toBe(24);
expect(layouts.xs[0].w).toBe(16);
expect(layouts.xxs[0].w).toBe(4);
});
});
describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)', () => {
it('Test 22: scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
useDashboardStore.setState({ widgets: [{ id: 'w1', widgetType: 'clock', config: {} }] });
let rejectFirst: (e: Error) => void = () => {};
vi.mocked(api.updateWidgetConfig)
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce(undefined as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const first = useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: false });
rejectFirst(new Error('PATCH failed'));
await first;
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('Test 23: scheitert der einzige Klick, kommt der vorige Wert zurueck', async () => {
useDashboardStore.setState({
widgets: [{ id: 'w1', widgetType: 'clock', config: { hideTitle: false } }],
});
vi.mocked(api.updateWidgetConfig).mockRejectedValue(new Error('PATCH failed'));
vi.spyOn(console, 'error').mockImplementation(() => {});
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
});
});
+42 -13
View File
@@ -1,8 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { DashboardTab } from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry'; import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry'; import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import type { DashboardTab } from '@/lib/dashboard-api';
import * as api from '@/lib/dashboard-api';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration'; import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
export interface WidgetInstance { export interface WidgetInstance {
@@ -21,6 +21,13 @@ interface DashboardState {
isDirty: boolean; isDirty: boolean;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
/**
* Die geladene Anordnung traegt eine hoehere Rastermarke, als dieser
* Programmstand kennt (quick-260930) — ein neuerer Stand hat sie
* geschrieben. Solange das gilt, wird nie gespeichert und der
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
*/
layoutFromNewerVersion: boolean;
setEditMode: (mode: boolean) => void; setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void; updateLayouts: (layouts: Record<string, unknown>) => void;
@@ -79,6 +86,12 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard` * Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie * durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
* `loadDashboard`. * `loadDashboard`.
*
* quick-260930: Traegt die geladene Anordnung eine HOEHERE Marke als
* `GRID_VERSION` (ein neuerer Programmstand hat sie geschrieben, dieser Tab
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
* Neuladen. Der Merker gilt je geladenem Reiter.
*/ */
export const useDashboardStore = create<DashboardState>()((set, get) => ({ export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [], dashboards: [],
@@ -90,8 +103,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isDirty: false, isDirty: false,
isLoading: false, isLoading: false,
error: null, error: null,
layoutFromNewerVersion: false,
setEditMode: (mode: boolean) => { setEditMode: (mode: boolean) => {
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
if (mode && get().layoutFromNewerVersion) return;
const prev = get().isEditMode; const prev = get().isEditMode;
set({ isEditMode: mode }); set({ isEditMode: mode });
// On exit edit mode, auto-save if dirty (D-01) // On exit edit mode, auto-save if dirty (D-01)
@@ -125,7 +141,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
i: newWidget.id, i: newWidget.id,
x: 0, x: 0,
y: maxY, y: maxY,
w: constraints.defaultW, // quick-260930: nie breiter als das Raster dieses Breakpoints.
w: clampWidthToCols(constraints.defaultW, bp),
h: constraints.defaultH, h: constraints.defaultH,
}); });
newLayouts[bp] = arr; newLayouts[bp] = arr;
@@ -168,12 +185,18 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set((state) => ({ set((state) => ({
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)), widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
})); }));
patch({ ...before, ...partial }); const optimistic = { ...before, ...partial };
patch(optimistic);
try { try {
await api.updateWidgetConfig(id, partial); await api.updateWidgetConfig(id, partial);
} catch (err) { } catch (err) {
console.error('Failed to update widget config:', err); console.error('Failed to update widget config:', err);
patch(before); // quick-260930: nur zuruecksetzen, wenn noch GENAU dieser optimistische
// Stand angezeigt wird. Hat inzwischen ein zweiter Klick etwas anderes
// gewaehlt, wuerde der Fehler des ersten sonst die zweite Wahl
// ueberschreiben.
const current = get().widgets.find((w) => w.id === id)?.config;
if (current === optimistic) patch(before);
} }
}, },
@@ -191,13 +214,15 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
api.fetchLayout(first.id), api.fetchLayout(first.id),
api.fetchWidgets(first.id), api.fetchWidgets(first.id),
]); ]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts); const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
set({ set({
dashboards, dashboards,
activeDashboardId: first.id, activeDashboardId: first.id,
layouts: migratedLayouts, layouts: migratedLayouts,
widgets, widgets,
isLoading: false, isLoading: false,
layoutFromNewerVersion: newer,
...(newer ? { isEditMode: false, isDirty: false } : {}),
}); });
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set, // Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener // damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
@@ -220,6 +245,9 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
saveLayout: async () => { saveLayout: async () => {
const dashboardId = get().activeDashboardId; const dashboardId = get().activeDashboardId;
if (!dashboardId) return; if (!dashboardId) return;
// quick-260930: NIE eine Anordnung eines neueren Programmstands speichern —
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
if (get().layoutFromNewerVersion) return;
try { try {
await api.saveLayout(dashboardId, withGridVersion(get().layouts)); await api.saveLayout(dashboardId, withGridVersion(get().layouts));
set({ isDirty: false }); set({ isDirty: false });
@@ -246,17 +274,16 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set({ isSwitchingDashboard: true, error: null }); set({ isSwitchingDashboard: true, error: null });
try { try {
const [rawLayouts, widgets] = await Promise.all([ const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
api.fetchLayout(id), const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts);
api.fetchWidgets(id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({ set({
activeDashboardId: id, activeDashboardId: id,
layouts: migratedLayouts, layouts: migratedLayouts,
widgets, widgets,
isDirty: false, isDirty: false,
isSwitchingDashboard: false, isSwitchingDashboard: false,
layoutFromNewerVersion: newer,
...(newer ? { isEditMode: false } : {}),
}); });
if (migrated) { if (migrated) {
try { try {
@@ -332,7 +359,9 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
reorderDashboards: async (ids: string[]) => { reorderDashboards: async (ids: string[]) => {
const previous = get().dashboards; const previous = get().dashboards;
const byId = new Map(previous.map((d) => [d.id, d])); const byId = new Map(previous.map((d) => [d.id, d]));
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined); const optimistic = ids
.map((id) => byId.get(id))
.filter((d): d is DashboardTab => d !== undefined);
set({ dashboards: optimistic }); set({ dashboards: optimistic });
try { try {
+5 -1
View File
@@ -246,7 +246,6 @@
"catalogClose": "Dialog schließen", "catalogClose": "Dialog schließen",
"deleteTooltip": "Widget entfernen", "deleteTooltip": "Widget entfernen",
"hideTitle": "Titel ausblenden", "hideTitle": "Titel ausblenden",
"showTitle": "Titel einblenden",
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben", "dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
"editMode": "Dashboard bearbeiten", "editMode": "Dashboard bearbeiten",
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
@@ -498,6 +497,7 @@
"snooze10m": "In 10 Minuten", "snooze10m": "In 10 Minuten",
"snooze1h": "In 1 Stunde", "snooze1h": "In 1 Stunde",
"snoozeTomorrow": "Morgen um {time}", "snoozeTomorrow": "Morgen um {time}",
"snoozeToday": "Heute um {time}",
"edit": "Erinnerung bearbeiten", "edit": "Erinnerung bearbeiten",
"editTitle": "Erinnerung bearbeiten", "editTitle": "Erinnerung bearbeiten",
"delete": "Erinnerung löschen", "delete": "Erinnerung löschen",
@@ -515,6 +515,7 @@
}, },
"editShort": "Bearbeiten", "editShort": "Bearbeiten",
"done": "Fertig", "done": "Fertig",
"reloadRequired": "Das Dashboard wurde von einer neueren Version von Tessera gespeichert. Bitte laden Sie die Seite neu, um es zu bearbeiten.",
"toolbarLabel": "Dashboard-Aktionen", "toolbarLabel": "Dashboard-Aktionen",
"emptyState": { "emptyState": {
"title": "Ihr Dashboard ist noch leer", "title": "Ihr Dashboard ist noch leer",
@@ -1402,6 +1403,7 @@
"customModules": { "customModules": {
"openInNewTab": "In neuem Tab öffnen", "openInNewTab": "In neuem Tab öffnen",
"notFound": "Dieses Modul gibt es nicht mehr.", "notFound": "Dieses Modul gibt es nicht mehr.",
"loadError": "Dieses Modul konnte nicht geladen werden. Bitte versuchen Sie es später erneut.",
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.", "invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
"loading": "Wird geladen …", "loading": "Wird geladen …",
"form": { "form": {
@@ -1416,6 +1418,8 @@
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.", "urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.", "saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.", "deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"invalidInput": "Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.",
"deleteConfirm": { "deleteConfirm": {
"title": "Eigenes Modul löschen", "title": "Eigenes Modul löschen",
"bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.", "bodyShared": "Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.",
+5 -1
View File
@@ -246,7 +246,6 @@
"catalogClose": "Close dialog", "catalogClose": "Close dialog",
"deleteTooltip": "Remove widget", "deleteTooltip": "Remove widget",
"hideTitle": "Hide title", "hideTitle": "Hide title",
"showTitle": "Show title",
"dragHint": "Drag the tile to move it", "dragHint": "Drag the tile to move it",
"editMode": "Edit dashboard", "editMode": "Edit dashboard",
"saveChanges": "Save changes", "saveChanges": "Save changes",
@@ -498,6 +497,7 @@
"snooze10m": "In 10 minutes", "snooze10m": "In 10 minutes",
"snooze1h": "In 1 hour", "snooze1h": "In 1 hour",
"snoozeTomorrow": "Tomorrow at {time}", "snoozeTomorrow": "Tomorrow at {time}",
"snoozeToday": "Today at {time}",
"edit": "Edit reminder", "edit": "Edit reminder",
"editTitle": "Edit reminder", "editTitle": "Edit reminder",
"delete": "Delete reminder", "delete": "Delete reminder",
@@ -515,6 +515,7 @@
}, },
"editShort": "Edit", "editShort": "Edit",
"done": "Done", "done": "Done",
"reloadRequired": "This dashboard was saved by a newer version of Tessera. Please reload the page to edit it.",
"toolbarLabel": "Dashboard actions", "toolbarLabel": "Dashboard actions",
"emptyState": { "emptyState": {
"title": "Your dashboard is still empty", "title": "Your dashboard is still empty",
@@ -1402,6 +1403,7 @@
"customModules": { "customModules": {
"openInNewTab": "Open in new tab", "openInNewTab": "Open in new tab",
"notFound": "This module no longer exists.", "notFound": "This module no longer exists.",
"loadError": "This module could not be loaded. Please try again later.",
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.", "invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
"loading": "Loading …", "loading": "Loading …",
"form": { "form": {
@@ -1416,6 +1418,8 @@
"urlCredentials": "The address must not contain a user name or a password.", "urlCredentials": "The address must not contain a user name or a password.",
"saveError": "The module could not be saved. Please try again.", "saveError": "The module could not be saved. Please try again.",
"deleteError": "The module could not be deleted. Please try again.", "deleteError": "The module could not be deleted. Please try again.",
"forbidden": "You do not have permission for this action.",
"invalidInput": "The details were not accepted. Please check name, address and category.",
"deleteConfirm": { "deleteConfirm": {
"title": "Delete custom module", "title": "Delete custom module",
"bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.", "bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.",
@@ -116,6 +116,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Energiequellen', 'Energiequellen',
'neue', 'neue',
'neuen', 'neuen',
'neueren',
'neuere', 'neuere',
'Neue', 'Neue',
'Neues', 'Neues',
@@ -730,6 +730,7 @@ werden.
| apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. | | apps/api/src/bug-reports/bug-reports.service.ts | user | muss-mandantengebunden | gebunden | Fehler-melden-Knopf (quick-260914-m97): eine gebundene Leseoperation auf die Zeile des angemeldeten Benutzers (Anzeigename, E-Mail, Rolle fuer den Bericht), Mandant ausschliesslich aus dem Sitzungsnachweis. |
| apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). | | apps/api/src/calendar/calendar.service.ts | calendarSource | muss-mandantengebunden | gebunden | Kalenderquellen eines Nutzers je Mandant gebunden (encryptedPassword traegt Zugangsdaten zu externen Exchange-/CalDAV-Servern), `tenantId`-Spalte vorhanden. Seit 260911-cwh (Aufgabe 2) laufen alle zwoelf Zugriffe (`getSources`, `addSource`, beide Abfragen von `updateSource`/`deleteSource`, alle drei Abfragen von `testConnection`, Laden plus beide Synchronstatus-Rueckschreibungen von `fetchAndCacheEvents`) ueber `forTenant()`, ein Klient je Methode; `fetchAndCacheEvents`/`refreshCacheInBackground` nehmen die Mandantenkennung als Parameter, Letztere traegt die Kennung der urspruenglichen Anfrage. Die drei Besitzpruefungen (`updateSource`/`deleteSource`/`testConnection`, Vergleich gegen `userId` aus dem Sitzungsnachweis) bleiben zusaetzlich bestehen — die Regel auf `CalendarSource` kennt keine Benutzerdimension (260911-cwh, Aufgabe 1, gemessen), sie sind bis zur Etappe-3-Entscheidung (2) der einzige Schutz zwischen Kollegen DESSELBEN Mandanten. Benutzerdimension seit 20260911120000 (260911-nke). |
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). | | apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | **quick-260924-m4n:** Stand zurück von `system-gebunden` auf `gebunden`. Stufe 2 der Umstellung (Migration 20260924120000_dashboard_image_drop_data) löscht die Spalte `data` und macht `storagePath` zur Pflicht; der Bootstrap-Umzug hatte auf allen Servern gearbeitet und ist samt seinem einzigen `forSystem()`-Aufruf entfernt (Eintrag aus `FORSYSTEM_ALLOWED_CALL_SITES` gestrichen). Dieselbe Migration entfernt die `system_read_policy` auf "DashboardImage" — auf der Tabelle bleibt allein `tenant_isolation_policy` (Mandant UND Benutzer). Alle vier Anfragewege laufen wie bisher ausschließlich über `forTenant(this.prisma, tenantId, userId)`; der Upload vergibt die UUID jetzt selbst und legt die Zeile gleich mit Pfad an. Vorher: **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
| apps/api/src/dashboard/dashboard-images.service.ts | user | muss-mandantengebunden | gebunden | **quick-260930:** `remove` setzt im selben Vorgang die Hintergrund-Wahl des Benutzers (`User.dashboardBackground`) auf `{ kind: 'none' }` zurück, wenn sie auf das gelöschte Bild zeigte — ein bedingtes `updateMany` mit `where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } }` über denselben gebundenen Klienten `forTenant(this.prisma, tenantId, userId)` wie der Rest der Methode. Nur die eigene Zeile, kein Kennungsparameter von außen (die Bild-Kennung ist zu diesem Zeitpunkt schon als eigenes Bild geprüft). |
| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. | | apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. | | apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
| apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. | | apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. |