From 6879c756f233c9d1be51b0e9c82581af3cd2b90c Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 29 Sep 2026 14:10:06 +0200 Subject: [PATCH] feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste - E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage - MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung - Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + apps/api/src/mail/mail.service.spec.ts | 66 ++++ apps/api/src/mail/mail.service.ts | 47 +++ .../src/prisma/rls-access-inventory.spec.ts | 9 + apps/api/src/reminders/dto/reminder.dto.ts | 18 +- .../reminders/reminder-mail.scheduler.spec.ts | 322 ++++++++++++++++++ .../src/reminders/reminder-mail.scheduler.ts | 169 +++++++++ .../reminders/reminders.controller.spec.ts | 35 +- .../api/src/reminders/reminders.controller.ts | 8 +- apps/api/src/reminders/reminders.module.ts | 12 +- .../src/reminders/reminders.service.spec.ts | 76 ++++- apps/api/src/reminders/reminders.service.ts | 39 ++- .../dashboard/widgets/reminder-form-modal.tsx | 52 ++- .../widgets/reminder-widget.test.tsx | 89 +++++ .../dashboard/widgets/reminder-widget.tsx | 46 ++- apps/web/src/lib/reminders-api.test.ts | 9 + apps/web/src/lib/reminders-api.ts | 12 + apps/web/src/messages/de.json | 4 + apps/web/src/messages/en.json | 4 + docs/anleitung-anwender.md | 3 +- ...andantentrennung-zugriffsklassifikation.md | 35 +- 21 files changed, 1038 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/reminders/reminder-mail.scheduler.spec.ts create mode 100644 apps/api/src/reminders/reminder-mail.scheduler.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 059e9e2..379b96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ## Unveröffentlicht +### Neu + +- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version. + ### Geändert - 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. diff --git a/apps/api/src/mail/mail.service.spec.ts b/apps/api/src/mail/mail.service.spec.ts index 063ac2b..ff8200c 100644 --- a/apps/api/src/mail/mail.service.spec.ts +++ b/apps/api/src/mail/mail.service.spec.ts @@ -248,3 +248,69 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260 expect(errorSpy).toHaveBeenCalled(); }); }); + +describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => { + const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit) + + function make() { + return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any); + } + + it('sendet Betreff "Erinnerung: " mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => { + const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', { + title: 'Zahnarzt', + description: 'Kartenlesegeraet mitnehmen', + dueAt, + }); + expect(ok).toBe(true); + const sent = mockSendMail.mock.calls[0][0] as any; + expect(sent.to).toBe('alice@a.example.invalid'); + expect(sent.from).toBe('noreply@a.example.invalid'); + expect(sent.subject).toBe('Erinnerung: Zahnarzt'); + expect(sent.html).toBeUndefined(); + expect(sent.text).toContain('14:30 Uhr'); + expect(sent.text).toContain('Zahnarzt'); + expect(sent.text).toContain('Kartenlesegeraet mitnehmen'); + expect(sent.text).toContain('http://localhost:3000'); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => { + await make().sendReminderEmail('t1', 'a@a.example.invalid', { + title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`, + description: '', + dueAt, + }); + const sent = mockSendMail.mock.calls[0][0] as any; + expect(sent.subject).not.toMatch(/[\r\n]/); + expect(sent.subject.length).toBe(150); + expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true); + }); + + it('laesst die Beschreibung weg, wenn sie leer ist', async () => { + await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt }); + const sent = mockSendMail.mock.calls[0][0] as any; + expect(sent.text.split('\n')).toEqual([ + 'Guten Tag,', + '', + expect.stringContaining('eine Erinnerung für'), + '', + 'T', + '', + 'http://localhost:3000', + ]); + }); + + it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => { + mockSendMail = vi.fn(async () => { + throw new Error('SMTP down'); + }); + const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', { + title: 'T', + description: '', + dueAt, + }); + expect(ok).toBe(false); + expect(mockClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index fcf07a2..7abff36 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -316,4 +316,51 @@ export class MailService { await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome'); } + + /** + * Erinnerungs-E-Mail (quick-260929-if2): eine Mail je faelliger Erinnerung an + * die eigene Adresse des Besitzers, ueber den SMTP-Transport seines Mandanten. + * Gibt `true` zurueck, wenn der Versand gelang, `false` bei einem + * Transportfehler — der Planer (`ReminderMailScheduler`) entscheidet daran, + * ob er den Anspruch wieder freigibt (E-04). Wirft nie. + * + * Nur Text, kein HTML (T-IF2-05, kein HTML-Einschleusen). Der Betreff hat + * Zeilenumbrueche durch Leerzeichen ersetzt (Header-Einschleusung) und ist + * auf 150 Zeichen gekuerzt. Die Zeit steht in `Europe/Berlin` (E-07): im + * Benutzer ist keine Zeitzone gespeichert, das Haus arbeitet in deutscher Zeit. + */ + async sendReminderEmail( + tenantId: string, + to: string, + reminder: { title: string; description: string; dueAt: Date }, + ): Promise { + const when = `${new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', + dateStyle: 'full', + timeStyle: 'short', + }).format(reminder.dueAt)} Uhr`; + const subject = `Erinnerung: ${reminder.title}`.replace(/[\r\n]+/g, ' ').slice(0, 150); + const lines = [ + 'Guten Tag,', + '', + `Sie haben in Tessera eine Erinnerung für ${when} gesetzt:`, + '', + reminder.title, + ]; + if (reminder.description.trim() !== '') { + lines.push('', reminder.description); + } + lines.push('', this.appUrl); + + try { + await this.deliver(tenantId, { to, subject, text: lines.join('\n') }, 'Reminder'); + return true; + } catch (error) { + this.logger.error( + `Failed to send Reminder email to ${to}`, + error instanceof Error ? error.stack : String(error), + ); + return false; + } + } } diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index 4d71636..7f9a0c1 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -181,11 +181,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set(['apps/api/src/tenders/backfill * seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration * nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu: * 5 Dateien, 6 Aufrufe. + * + * quick-260929-if2 (Aufgabe 3): eine sechste Datei kommt hinzu — + * `reminders/reminder-mail.scheduler.ts`, EIN Aufruf: die Kandidatenabfrage + * des E-Mail-Planers fuer Erinnerungen (`reminder.findMany`, nur skalarer + * Select, alle Mandanten). Alle Schreib- und Folgezugriffe laufen je Zeile + * gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist + * `system_read_policy ... FOR SELECT` auf "Reminder" (Migration + * 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe. */ const FORSYSTEM_ALLOWED_CALL_SITES = new Map([ ['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/ldap/ldap-config.service.ts', 2], ['apps/api/src/proxmox/proxmox.service.ts', 1], + ['apps/api/src/reminders/reminder-mail.scheduler.ts', 1], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1], ['apps/api/src/tenders/tender-matching.service.ts', 1], ]); diff --git a/apps/api/src/reminders/dto/reminder.dto.ts b/apps/api/src/reminders/dto/reminder.dto.ts index 5b87cca..669a8fe 100644 --- a/apps/api/src/reminders/dto/reminder.dto.ts +++ b/apps/api/src/reminders/dto/reminder.dto.ts @@ -1,6 +1,13 @@ import { PartialType } from '@nestjs/mapped-types'; import { Transform } from 'class-transformer'; -import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsBoolean, + IsISO8601, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; const trimString = ({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value; @@ -26,6 +33,15 @@ export class CreateReminderDto { /** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */ @IsISO8601({ strict: true }) dueAt!: string; + + /** + * Zusaetzlich per E-Mail erinnern (Aufgabe 3). `true` ist nur erlaubt, wenn + * der Mandant einen E-Mail-Versand eingerichtet UND der Benutzer eine + * Adresse hat (sonst 400 im Dienst). + */ + @IsOptional() + @IsBoolean() + emailEnabled?: boolean; } /** diff --git a/apps/api/src/reminders/reminder-mail.scheduler.spec.ts b/apps/api/src/reminders/reminder-mail.scheduler.spec.ts new file mode 100644 index 0000000..2128685 --- /dev/null +++ b/apps/api/src/reminders/reminder-mail.scheduler.spec.ts @@ -0,0 +1,322 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * ReminderMailScheduler.spec (quick-260929-if2, Aufgabe 3). Ein handgebauter, + * Prisma-foermiger Speicher (Konvention dieses Repos, vgl. + * tender-digest.scheduler.spec.ts). Wichtig: `updateMany` prueft und setzt + * OHNE `await` dazwischen — wie die eine SQL-Anweisung in der Datenbank ist der + * Anspruch damit atomar, und zwei Durchlaeufe ueber DENSELBEN Speicher koennen + * ihn nur einmal gewinnen (T-IF2-06). + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__tenantClient(tenantId)), + forSystem: vi.fn((prisma: any) => prisma.__systemClient()), +})); + +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; +import { ReminderMailScheduler } from './reminder-mail.scheduler'; + +const NOW = new Date('2026-09-29T12:00:00.000Z'); +const minutesAgo = (m: number) => new Date(NOW.getTime() - m * 60_000); + +interface Row { + id: string; + tenantId: string; + userId: string; + title: string; + description: string; + dueAt: Date; + emailEnabled: boolean; + emailSentAt: Date | null; + emailAttempts: number; +} + +function row(over: Partial & { id: string }): Row { + return { + tenantId: 't1', + userId: 'u1', + title: `Titel ${over.id}`, + description: '', + dueAt: minutesAgo(1), + emailEnabled: true, + emailSentAt: null, + emailAttempts: 0, + ...over, + }; +} + +const sameTime = (a: Date | null, b: Date | null) => + a === null || b === null ? a === b : a.getTime() === b.getTime(); + +function makeStore(rows: Row[], emails: Record = { u1: 'u1@example.invalid' }) { + const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => { + let list = rows.filter( + (r) => + r.emailEnabled === where.emailEnabled && + r.emailSentAt === where.emailSentAt && + r.emailAttempts < where.emailAttempts.lt && + r.dueAt.getTime() <= where.dueAt.lte.getTime() && + r.dueAt.getTime() >= where.dueAt.gte.getTime(), + ); + if (orderBy?.dueAt === 'asc') 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 tenantClient = (tenantId: string) => ({ + reminder: { + updateMany: vi.fn(async ({ where, data }: any) => { + boundLog.push(`updateMany:${tenantId}`); + let count = 0; + for (const r of rows) { + if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue; + if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue; + if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue; + if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue; + if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue; + if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt; + if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment; + count++; + } + return { count }; + }), + findFirst: vi.fn(async ({ where }: any) => { + boundLog.push(`findFirst:${tenantId}`); + const r = rows.find( + (x) => x.id === where.id && x.tenantId === tenantId && sameTime(x.dueAt, where.dueAt), + ); + return r ? { title: r.title, description: r.description, dueAt: r.dueAt } : null; + }), + }, + user: { + findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })), + }, + }); + + const prisma: any = { + __systemClient: () => ({ reminder: { findMany: systemFindMany } }), + __tenantClient: tenantClient, + }; + return { prisma, rows, systemFindMany, boundLog }; +} + +function makeScheduler( + prisma: any, + opts: { smtp?: unknown; sendResult?: boolean | Error } = {}, +) { + const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() }; + const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) }; + const mail = { + sendReminderEmail: vi.fn(async () => { + if (opts.sendResult instanceof Error) throw opts.sendResult; + return opts.sendResult ?? true; + }), + }; + const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any); + return { scheduler, registry, settings, mail }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => { + it('zwei Instanzen ueber denselben Speicher senden genau eine Mail', async () => { + const store = makeStore([row({ id: 'a' })]); + const one = makeScheduler(store.prisma); + const two = makeScheduler(store.prisma); + 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(store.rows[0].emailSentAt).toEqual(NOW); + expect(store.rows[0].emailAttempts).toBe(1); + }); + + it('ein zweiter Durchlauf danach sendet nicht erneut', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler, mail } = makeScheduler(store.prisma); + await scheduler.runTick(NOW); + await scheduler.runTick(new Date(NOW.getTime() + 30_000)); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); + }); + + it('ein ueberlappender Durchlauf im selben Prozess wird uebersprungen (running)', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler } = makeScheduler(store.prisma); + const first = scheduler.runTick(NOW); + await scheduler.runTick(NOW); // laeuft, waehrend der erste noch nicht fertig ist + await first; + expect(store.systemFindMany).toHaveBeenCalledTimes(1); + // danach ist der Riegel wieder offen + await scheduler.runTick(NOW); + expect(store.systemFindMany).toHaveBeenCalledTimes(2); + }); + + it('sendet mit Titel, Beschreibung und Faelligkeit an die Adresse des Besitzers, gebunden an dessen Mandanten', async () => { + const store = makeStore([row({ id: 'a', tenantId: 't7', userId: 'u1', description: 'Text' })], { + u1: 'anna@example.invalid', + }); + const { scheduler, mail, settings } = makeScheduler(store.prisma); + await scheduler.runTick(NOW); + expect(mail.sendReminderEmail).toHaveBeenCalledWith('t7', 'anna@example.invalid', { + title: 'Titel a', + description: 'Text', + dueAt: store.rows[0].dueAt, + }); + expect(settings.getSmtpConfig).toHaveBeenCalledWith('t7'); + expect(forTenant).toHaveBeenCalledWith(store.prisma, 't7'); + }); +}); + +describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => { + it('ein Transportfehler gibt den Anspruch frei; der naechste Durchlauf versucht es erneut', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false }); + await scheduler.runTick(NOW); + expect(store.rows[0].emailSentAt).toBeNull(); + expect(store.rows[0].emailAttempts).toBe(1); + await scheduler.runTick(new Date(NOW.getTime() + 30_000)); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2); + }); + + it('nach drei Versuchen ist die Erinnerung kein Kandidat mehr', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false }); + for (let i = 0; i < 5; i++) await scheduler.runTick(new Date(NOW.getTime() + i * 30_000)); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(3); + expect(store.rows[0].emailAttempts).toBe(3); + }); + + it('wirft der Versand, wird der Anspruch ebenfalls freigegeben und der Durchlauf bricht nicht ab', async () => { + const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]); + const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: new Error('kaputt') }); + await expect(scheduler.runTick(NOW)).resolves.toBeUndefined(); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2); + expect(store.rows.every((r) => r.emailSentAt === null)).toBe(true); + }); + + it('die Freigabe trifft nur den eigenen Anspruch (gleicher Zeitstempel)', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler } = makeScheduler(store.prisma, { sendResult: false }); + const spy = vi.spyOn(store.prisma, '__tenantClient'); + await scheduler.runTick(NOW); + const client = spy.mock.results[0].value; + const releaseCall = client.reminder.updateMany.mock.calls[1][0]; + expect(releaseCall.where).toEqual({ id: 'a', tenantId: 't1', emailSentAt: NOW }); + expect(releaseCall.data).toEqual({ emailSentAt: null }); + }); + + it('ohne SmtpConfig: kein Versand, Anspruch bleibt, keine Wiederholung', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler, mail } = makeScheduler(store.prisma, { smtp: null }); + 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); + }); + + it('ohne E-Mail-Adresse des Benutzers: kein Versand, Anspruch bleibt', async () => { + const store = makeStore([row({ id: 'a' })], { u1: null }); + const { scheduler, mail } = makeScheduler(store.prisma); + 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); + }); +}); + +describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => { + it('die Abfrage waehlt nur emailEnabled, ohne Anspruch, unter 3 Versuchen, faellig und nicht aelter als 24 h', async () => { + const store = makeStore([ + row({ id: 'ok' }), + row({ id: 'aus', emailEnabled: false }), + row({ id: 'schon', emailSentAt: minutesAgo(1) }), + row({ id: 'drei', emailAttempts: 3 }), + row({ id: 'zukunft', dueAt: new Date(NOW.getTime() + 60_000) }), + row({ id: 'alt', dueAt: new Date(NOW.getTime() - 25 * 3600_000) }), + ]); + const { scheduler, mail } = makeScheduler(store.prisma); + await scheduler.runTick(NOW); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); + const args = store.systemFindMany.mock.calls[0][0]; + expect(args.where).toEqual({ + emailEnabled: true, + emailSentAt: null, + emailAttempts: { lt: 3 }, + dueAt: { lte: NOW, gte: new Date(NOW.getTime() - 24 * 3600_000) }, + }); + expect(args.take).toBe(200); + expect(args.orderBy).toEqual({ dueAt: 'asc' }); + }); + + it('der Systemklient liest nur skalar, ohne Relation', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler } = makeScheduler(store.prisma); + await scheduler.runTick(NOW); + expect(store.systemFindMany.mock.calls[0][0].select).toEqual({ + id: true, + tenantId: true, + userId: true, + dueAt: true, + }); + expect(forSystem).toHaveBeenCalledTimes(1); + }); + + it('eine fehlerhafte Zeile haelt die uebrigen nicht an', async () => { + const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]); + const { scheduler, mail } = makeScheduler(store.prisma); + // erste Zeile (b ist aelter, kommt zuerst): der Benutzer-Zugriff wirft + let calls = 0; + const original = store.prisma.__tenantClient; + store.prisma.__tenantClient = (tenantId: string) => { + const client = original(tenantId); + client.user.findFirst = vi.fn(async () => { + if (++calls === 1) throw new Error('DB weg'); + return { email: 'u1@example.invalid' }; + }); + return client; + }; + await scheduler.runTick(NOW); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); + }); + + it('nach einem Verschieben (Spuren zurueckgesetzt, neue Faelligkeit) gibt es genau eine weitere Mail (D-03)', async () => { + const store = makeStore([row({ id: 'a' })]); + const { scheduler, mail } = makeScheduler(store.prisma); + await scheduler.runTick(NOW); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1); + + // Wie RemindersService.snooze: neue Faelligkeit, Spuren zurueck + const later = new Date(NOW.getTime() + 10 * 60_000); + Object.assign(store.rows[0], { dueAt: later, emailSentAt: null, emailAttempts: 0 }); + await scheduler.runTick(new Date(later.getTime() + 5_000)); + await scheduler.runTick(new Date(later.getTime() + 35_000)); + expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2); + }); +}); + +describe('ReminderMailScheduler — Start', () => { + it('registriert genau ein Intervall "reminder-email" und ersetzt ein vorhandenes', () => { + vi.useFakeTimers(); + try { + const store = makeStore([]); + const { scheduler, registry } = makeScheduler(store.prisma); + scheduler.onApplicationBootstrap(); + expect(registry.deleteInterval).toHaveBeenCalledWith('reminder-email'); + expect(registry.addInterval).toHaveBeenCalledTimes(1); + expect(registry.addInterval.mock.calls[0][0]).toBe('reminder-email'); + clearInterval(registry.addInterval.mock.calls[0][1]); + } finally { + vi.useRealTimers(); + } + }); + + it('ein Fehler beim Registrieren wird nur protokolliert, nie geworfen', () => { + const store = makeStore([]); + const { scheduler, registry } = makeScheduler(store.prisma); + registry.addInterval.mockImplementation(() => { + throw new Error('doppelt'); + }); + expect(() => scheduler.onApplicationBootstrap()).not.toThrow(); + }); +}); diff --git a/apps/api/src/reminders/reminder-mail.scheduler.ts b/apps/api/src/reminders/reminder-mail.scheduler.ts new file mode 100644 index 0000000..387b339 --- /dev/null +++ b/apps/api/src/reminders/reminder-mail.scheduler.ts @@ -0,0 +1,169 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { MailService } from '../mail/mail.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; +import { SettingsService } from '../settings/settings.service'; + +/** E-08: Abstand der Pruefung. */ +const TICK_MS = 30_000; +/** E-03: so lange nach der Faelligkeit wird noch gemailt (Neustart, Ausfall). */ +const CATCH_UP_MS = 24 * 60 * 60 * 1000; +/** E-04: hoechstens so viele Versuche je Faelligkeit. */ +const MAX_ATTEMPTS = 3; +/** Kandidaten je Durchlauf. */ +const BATCH = 200; + +/** + * E-Mail-Planer der Erinnerungen (quick-260929-if2): verschickt hoechstens EINE + * Mail je faelliger Erinnerung, auch wenn Tessera nirgends geoeffnet ist. + * + * WARUM DER ANSPRUCH VOR DEM SENDEN STEHT (E-04, T-IF2-06): ein `updateMany` + * setzt `emailSentAt` und zaehlt `emailAttempts` hoch, und zwar NUR, wo + * `emailSentAt` noch leer ist und `dueAt` unveraendert. Nur wer die Zeile mit + * `count === 1` bekommt, sendet. Mehrere API-Instanzen, ein Neustart mitten im + * Durchlauf oder zwei ueberlappende Durchlaeufe verschicken so nie doppelt. + * Scheitert der Transport, gibt der Planer den Anspruch wieder frei + * (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht — + * hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse + * des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und + * wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben + * („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`). + * + * WARUM 24 STUNDEN (E-03): bei Neustart oder Ausfall werden Erinnerungen der + * letzten 24 Stunden nachgeholt; wer spaeter SMTP einrichtet, bekommt keine + * Mails ueber alte Erinnerungen. WARUM ALLE 30 SEKUNDEN (E-08): die Mail soll + * zur Faelligkeit ankommen, die Abfrage ist ein einziger Indexzugriff auf + * `dueAt`. + * + * SYSTEMKONTEXT: die Kandidatenabfrage liest ueber ALLE Mandanten + * (`forSystem()`, `system_read_policy ... FOR SELECT`, Migration 20260929140000); + * der Select ist bewusst nur skalar — eine Relation im Systemklienten machte + * `User` zum Systemlese-Modell (WINDOWS #27). Alles Weitere, jede Schreib- und + * Leseoperation je Zeile, laeuft an den Mandanten der Zeile gebunden. + * Lebenszyklus `onApplicationBootstrap` wie `TenderSchedulerService`. + */ +@Injectable() +export class ReminderMailScheduler implements OnApplicationBootstrap { + private readonly logger = new Logger(ReminderMailScheduler.name); + private readonly JOB_NAME = 'reminder-email'; + private running = false; + + constructor( + private readonly schedulerRegistry: SchedulerRegistry, + private readonly prisma: PrismaService, + private readonly settingsService: SettingsService, + private readonly mail: MailService, + ) {} + + /** Registriert das eine globale Intervall. Fehler werden nur protokolliert. */ + onApplicationBootstrap(): void { + try { + try { + this.schedulerRegistry.deleteInterval(this.JOB_NAME); + } catch { + /* noch nicht registriert — beim ersten Start erwartet */ + } + this.schedulerRegistry.addInterval( + this.JOB_NAME, + setInterval(() => { + this.runTick().catch((err) => + this.logger.error(`Reminder email tick failed: ${(err as Error).message}`), + ); + }, TICK_MS), + ); + this.logger.log( + `Reminder email scheduler registered: every ${TICK_MS / 1000} s (single global job — all tenants)`, + ); + } catch (err) { + this.logger.error(`Reminder email scheduler init failed: ${(err as Error).message}`); + } + } + + /** Ein Durchlauf. Ueberlappende Aufrufe im selben Prozess werden uebersprungen. */ + async runTick(now: Date = new Date()): Promise { + if (this.running) return; + this.running = true; + try { + const systemPrisma = forSystem(this.prisma); + const candidates: { id: string; tenantId: string; userId: string; dueAt: Date }[] = + await systemPrisma.reminder.findMany({ + where: { + emailEnabled: true, + emailSentAt: null, + emailAttempts: { lt: MAX_ATTEMPTS }, + dueAt: { lte: now, gte: new Date(now.getTime() - CATCH_UP_MS) }, + }, + select: { id: true, tenantId: true, userId: true, dueAt: true }, + orderBy: { dueAt: 'asc' }, + take: BATCH, + }); + + for (const candidate of candidates) { + try { + await this.processCandidate(candidate, now); + } catch (err) { + // Eine kaputte Zeile darf die uebrigen nicht anhalten. + this.logger.error( + `Reminder email for ${candidate.id} failed: ${(err as Error).message}`, + ); + } + } + } finally { + this.running = false; + } + } + + private async processCandidate( + c: { id: string; tenantId: string; userId: string; dueAt: Date }, + now: Date, + ): Promise { + const tenantPrisma = forTenant(this.prisma, c.tenantId); + + // Anspruch: nur wer count === 1 bekommt, sendet. + const claim = await tenantPrisma.reminder.updateMany({ + where: { + id: c.id, + tenantId: c.tenantId, + dueAt: c.dueAt, + emailEnabled: true, + emailSentAt: null, + emailAttempts: { lt: MAX_ATTEMPTS }, + }, + data: { emailSentAt: now, emailAttempts: { increment: 1 } }, + }); + if (claim.count !== 1) return; + + // Zeile nur mit UNVERAENDERTER Faelligkeit laden: wurde sie zwischen Anspruch + // und Laden verschoben, ist die Faelligkeit nicht mehr dieselbe. + const row = await tenantPrisma.reminder.findFirst({ + where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt }, + select: { title: true, description: true, dueAt: true }, + }); + const user = await tenantPrisma.user.findFirst({ + where: { id: c.userId, tenantId: c.tenantId }, + select: { email: true }, + }); + const smtp = await this.settingsService.getSmtpConfig(c.tenantId); + if (!row || !user?.email || smtp === null) { + this.logger.log( + `Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`, + ); + return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04) + } + + let sent = false; + try { + sent = await this.mail.sendReminderEmail(c.tenantId, user.email, row); + } catch (err) { + this.logger.error(`Reminder email for ${c.id} threw: ${(err as Error).message}`); + } + if (!sent) { + // Nur der eigene Anspruch (gleicher Zeitstempel) wird freigegeben. + await tenantPrisma.reminder.updateMany({ + where: { id: c.id, tenantId: c.tenantId, emailSentAt: now }, + data: { emailSentAt: null }, + }); + } + } +} diff --git a/apps/api/src/reminders/reminders.controller.spec.ts b/apps/api/src/reminders/reminders.controller.spec.ts index 435e321..e78ef2b 100644 --- a/apps/api/src/reminders/reminders.controller.spec.ts +++ b/apps/api/src/reminders/reminders.controller.spec.ts @@ -8,6 +8,10 @@ import { RemindersController } from './reminders.controller'; function makeService() { return { list: vi.fn(async (..._args: unknown[]) => []), + getEmailAvailability: vi.fn(async (..._args: unknown[]) => ({ + smtpConfigured: true, + hasEmail: true, + })), create: vi.fn(async (..._args: unknown[]) => ({})), update: vi.fn(async (..._args: unknown[]) => ({})), snooze: vi.fn(async (..._args: unknown[]) => ({})), @@ -20,7 +24,7 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any; const proto = RemindersController.prototype as any; describe('RemindersController — Rollen', () => { - it.each(['list', '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(); }); @@ -34,6 +38,8 @@ describe('RemindersController — Mandant', () => { const service = makeService(); const controller = new RemindersController(service as any); await controller.list(req('t1'), user); + await controller.emailStatus(req('t1'), user); + expect(service.getEmailAvailability).toHaveBeenCalledWith('t1', 'u1'); await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }); expect(service.list).toHaveBeenCalledWith('t1', 'u1'); expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', 'u1']); @@ -48,6 +54,7 @@ describe('RemindersController — Mandant', () => { it('wirft ForbiddenException ohne req.tenantId', async () => { const controller = new RemindersController(makeService() as any); await expect(controller.list(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.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), @@ -102,6 +109,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => { }); }); +describe('RemindersController — emailEnabled', () => { + it('die Pipe laesst emailEnabled beim Anlegen und Aendern durch und verlangt einen Wahrheitswert', async () => { + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + const created: any = await pipe.transform( + { title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: true }, + { type: 'body', metatype: CreateReminderDto }, + ); + expect(created.emailEnabled).toBe(true); + const updated: any = await pipe.transform( + { emailEnabled: false }, + { type: 'body', metatype: UpdateReminderDto }, + ); + expect(updated.emailEnabled).toBe(false); + await expect( + pipe.transform( + { title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: 'ja' }, + { type: 'body', metatype: CreateReminderDto }, + ), + ).rejects.toBeTruthy(); + }); +}); + describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => { it('deklariert list vor jeder :id-Route', () => { const methods = Object.getOwnPropertyNames(RemindersController.prototype); @@ -111,7 +140,11 @@ describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => const path = Reflect.getMetadata('path', proto[name]); if (typeof path === 'string' && path.startsWith(':id')) { expect(listIdx).toBeLessThan(methods.indexOf(name)); + // auch die statische Route email-status steht vor jeder :id-Route + expect(methods.indexOf('emailStatus')).toBeLessThan(methods.indexOf(name)); } } + expect(methods.indexOf('emailStatus')).toBeGreaterThanOrEqual(0); + expect(Reflect.getMetadata('path', proto.emailStatus)).toBe('email-status'); }); }); diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts index c1d988c..0c36109 100644 --- a/apps/api/src/reminders/reminders.controller.ts +++ b/apps/api/src/reminders/reminders.controller.ts @@ -22,7 +22,7 @@ import { RemindersService } from './reminders.service'; * `TenantGuard`), der Benutzer aus dem Token. * * ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab. - * Jede statische GET-Route (Aufgabe 3 ergaenzt `email-status`) MUSS ueber jeder + * Jede statische GET-Route (`email-status`) MUSS ueber jeder * `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der * Controller-Test haelt die Reihenfolge fest. */ @@ -43,6 +43,12 @@ export class RemindersController { return this.service.list(this.requireTenantId(req), user.id); } + // Statische Route: MUSS ueber jeder :id-Route stehen (siehe Kopfkommentar). + @Get('email-status') + async emailStatus(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) { + return this.service.getEmailAvailability(this.requireTenantId(req), user.id); + } + @Post() async create( @Req() req: AuthenticatedRequest, diff --git a/apps/api/src/reminders/reminders.module.ts b/apps/api/src/reminders/reminders.module.ts index e7c05ac..206c3f1 100644 --- a/apps/api/src/reminders/reminders.module.ts +++ b/apps/api/src/reminders/reminders.module.ts @@ -1,10 +1,18 @@ import { Module } from '@nestjs/common'; +import { MailModule } from '../mail/mail.module'; +import { SettingsModule } from '../settings/settings.module'; +import { ReminderMailScheduler } from './reminder-mail.scheduler'; import { RemindersController } from './reminders.controller'; import { RemindersService } from './reminders.service'; -/** Erinnerungen (quick-260929-if2). `PrismaModule` ist global. */ +/** + * Erinnerungen (quick-260929-if2). `PrismaModule` ist global. `SettingsModule` + * liefert die Regel „E-Mail-Versand eingerichtet“ (E-09), `MailModule` den + * Versand fuer den Planer. + */ @Module({ + imports: [SettingsModule, MailModule], controllers: [RemindersController], - providers: [RemindersService], + providers: [RemindersService, ReminderMailScheduler], }) export class RemindersModule {} diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts index fde2d08..27d80ca 100644 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -46,12 +46,18 @@ function makeFakePrisma() { ).length; }), }; - return { reminder, rows }; + const user = { + findFirst: vi.fn(async ({ where }: { where: any }) => ({ + email: where.id === 'u-ohne-mail' ? null : `${where.id}@example.invalid`, + })), + }; + return { reminder, user, rows }; } -function setup() { +function setup(smtp: unknown = { host: 'smtp.example.invalid' }) { const prisma = makeFakePrisma(); - return { prisma, service: new RemindersService(prisma as any) }; + const settings = { getSmtpConfig: vi.fn(async (_tenantId: string) => smtp) }; + return { prisma, settings, service: new RemindersService(prisma as any, settings as any) }; } const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString(); @@ -244,3 +250,67 @@ describe('RemindersService — loeschen (E-02)', () => { expect(prisma.rows.size).toBe(0); }); }); + +describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { + it('getEmailAvailability: SMTP eingerichtet und Adresse vorhanden', async () => { + const { service, settings } = setup(); + await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({ + smtpConfigured: true, + hasEmail: true, + }); + expect(settings.getSmtpConfig).toHaveBeenCalledWith('t1'); + }); + + it('getEmailAvailability: ohne SmtpConfig-Zeile ist der Versand nicht eingerichtet', async () => { + const { service } = setup(null); + await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({ + smtpConfigured: false, + hasEmail: true, + }); + }); + + it('getEmailAvailability: ohne Adresse im Konto hasEmail false, gelesen ueber den gebundenen Klienten', async () => { + const { prisma, service } = setup(); + await expect(service.getEmailAvailability('t1', 'u-ohne-mail')).resolves.toEqual({ + smtpConfigured: true, + hasEmail: false, + }); + 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' }); + }); + + it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => { + const { prisma, service } = setup(); + await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true }); + expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(true); + }); + + it('anlegen ohne emailEnabled speichert false', async () => { + const { prisma, service } = setup(); + await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }); + expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(false); + }); + + it('anlegen mit emailEnabled ergibt 400, wenn SMTP fehlt oder die Adresse fehlt', async () => { + const noSmtp = setup(null); + await expect( + noSmtp.service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true }), + ).rejects.toBeInstanceOf(BadRequestException); + const noMail = setup(); + await expect( + noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(noSmtp.prisma.rows.size).toBe(0); + expect(noMail.prisma.rows.size).toBe(0); + }); + + it('aendern mit emailEnabled true ergibt 400 ohne SMTP; emailEnabled false geht immer', async () => { + const { prisma, service } = setup(null); + seed(prisma, 'x', future()); + await expect(service.update('t1', 'u1', 'x', { emailEnabled: true })).rejects.toBeInstanceOf( + BadRequestException, + ); + await service.update('t1', 'u1', 'x', { emailEnabled: false }); + expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false }); + }); +}); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index 164a39e..274807f 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { SettingsService } from '../settings/settings.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; @@ -40,7 +41,35 @@ const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000; */ @Injectable() export class RemindersService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly settingsService: SettingsService, + ) {} + + /** + * Ist die E-Mail-Erinnerung fuer diesen Benutzer moeglich? (E-09: „E-Mail- + * Versand eingerichtet“ heisst, der Mandant hat eine `SmtpConfig`-Zeile — + * dieselbe Regel wie `TenderMailService`; der Umgebungs-Rueckfall des + * `MailService` zaehlt nicht.) Der Benutzer wird ueber den gebundenen Klienten + * gelesen. + */ + async getEmailAvailability(tenantId: string, userId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const user = await tenantPrisma.user.findFirst({ + where: { id: userId, tenantId }, + select: { email: true }, + }); + const smtp = await this.settingsService.getSmtpConfig(tenantId); + return { smtpConfigured: smtp !== null, hasEmail: Boolean(user?.email) }; + } + + /** 400, wenn `emailEnabled: true` verlangt wird, obwohl E-Mail nicht moeglich ist. */ + private async assertEmailAvailable(tenantId: string, userId: string) { + const { smtpConfigured, hasEmail } = await this.getEmailAvailability(tenantId, userId); + if (!smtpConfigured || !hasEmail) { + throw new BadRequestException('E-Mail-Erinnerungen sind nicht möglich'); + } + } /** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */ async list(tenantId: string, userId: string) { @@ -54,6 +83,7 @@ export class RemindersService { async create(tenantId: string, userId: string, dto: CreateReminderDto) { const dueAt = this.assertValidDueAt(dto.dueAt); + if (dto.emailEnabled === true) await this.assertEmailAvailable(tenantId, userId); const tenantPrisma = forTenant(this.prisma, tenantId, userId); const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } }); if (count >= MAX_REMINDERS_PER_USER) { @@ -68,6 +98,7 @@ export class RemindersService { title: dto.title, description: dto.description ?? '', dueAt, + emailEnabled: dto.emailEnabled === true, }, select: REMINDER_SELECT, }); @@ -83,10 +114,14 @@ export class RemindersService { if (row.dueAt.getTime() <= Date.now()) { throw new ConflictException('Die Erinnerung ist bereits fällig'); } - const data: { title?: string; description?: string; dueAt?: Date } = {}; + const data: { title?: string; description?: string; dueAt?: Date; emailEnabled?: boolean } = {}; if (dto.title !== undefined) data.title = dto.title; if (dto.description !== undefined) data.description = dto.description; if (dto.dueAt !== undefined) data.dueAt = this.assertValidDueAt(dto.dueAt); + if (dto.emailEnabled !== undefined) { + if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId); + data.emailEnabled = dto.emailEnabled; + } return tenantPrisma.reminder.update({ where: { id, tenantId, userId }, data, diff --git a/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx index 21f8deb..5e3048f 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx @@ -7,6 +7,7 @@ import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; import { type Reminder, + type ReminderEmailStatus, ReminderRequestError, createReminder, updateReminder, @@ -15,6 +16,8 @@ import { interface ReminderFormModalProps { /** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */ reminder: Reminder | null; + /** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */ + emailStatus: ReminderEmailStatus | null; onClose: () => void; /** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */ onSaved: () => void; @@ -42,7 +45,13 @@ const DESCRIPTION_MAX = 2000; * eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim * Laden der Seite. */ -export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) { +export function ReminderFormModal({ + reminder, + emailStatus, + onClose, + onSaved, + onStale, +}: ReminderFormModalProps) { const t = useTranslations('widgets'); const initial = useRef( reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()), @@ -51,6 +60,7 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin const [time, setTime] = useState(initial.time); const [title, setTitle] = useState(reminder?.title ?? ''); const [description, setDescription] = useState(reminder?.description ?? ''); + const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const titleRef = useRef(null); @@ -84,7 +94,16 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin setSaving(true); try { - const input = { title: trimmedTitle, description: description.trim(), dueAt }; + // `emailEnabled` nur senden, wenn es sich aendert oder beim Anlegen `true` + // ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400 + // ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen. + const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled; + const input = { + title: trimmedTitle, + description: description.trim(), + dueAt, + ...(emailChanged ? { emailEnabled } : {}), + }; if (reminder) { await updateReminder(reminder.id, input); } else { @@ -105,6 +124,17 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin } }; + // E-Mail moeglich: Versand eingerichtet UND Adresse im Konto. Unbekannt gilt + // als nicht moeglich. Ein bereits angehaktes Feld bleibt bedienbar, damit man + // es wieder abwaehlen kann. + const emailAvailable = emailStatus?.smtpConfigured === true && emailStatus.hasEmail; + const emailHint = + emailStatus === null || emailAvailable + ? null + : !emailStatus.smtpConfigured + ? t('reminder.emailNoSmtp') + : t('reminder.emailNoAddress'); + if (typeof document === 'undefined') return null; return createPortal( @@ -175,6 +205,24 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin /> +
+ + {emailHint && ( +

+ {emailHint} +

+ )} +
+ {error && (
{ updateReminder: vi.fn(), snoozeReminder: vi.fn(), deleteReminder: vi.fn(), + getReminderEmailStatus: vi.fn(), }; }); @@ -23,6 +24,7 @@ import { ReminderRequestError, createReminder, deleteReminder, + getReminderEmailStatus, listReminders, snoozeReminder, updateReminder, @@ -34,6 +36,7 @@ const mockCreate = createReminder as ReturnType; const mockUpdate = updateReminder as ReturnType; const mockSnooze = snoozeReminder as ReturnType; const mockDelete = deleteReminder as ReturnType; +const mockEmailStatus = getReminderEmailStatus as ReturnType; function reminder(id: string, title: string, dueAt: string, description = '') { return { @@ -58,6 +61,8 @@ beforeEach(() => { mockUpdate.mockReset(); mockSnooze.mockReset(); mockDelete.mockReset(); + mockEmailStatus.mockReset(); + mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true }); requestPermission = vi.fn(async () => 'granted'); const ctor = vi.fn(); Object.assign(ctor, { permission: 'default', requestPermission }); @@ -307,3 +312,87 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => { expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true'); }); }); + +describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => { + async function openFormAndWaitForStatus() { + await openForm(); + // Der Status wird beim Einhaengen der Kachel geladen + await waitFor(() => expect(mockEmailStatus).toHaveBeenCalledTimes(1)); + return screen.getByLabelText('reminder.emailLabel') as HTMLInputElement; + } + + it('ist bedienbar und wird als emailEnabled gesendet, wenn Versand und Adresse da sind', async () => { + mockList.mockResolvedValue([]); + mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z')); + render(); + const box = await openFormAndWaitForStatus(); + await waitFor(() => expect(box.disabled).toBe(false)); + fireEvent.click(box); + fillAndSubmit('Mit Mail'); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0].emailEnabled).toBe(true); + }); + + it('ohne E-Mail-Haken wird emailEnabled gar nicht gesendet', async () => { + mockList.mockResolvedValue([]); + mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z')); + render(); + await openFormAndWaitForStatus(); + fillAndSubmit('Ohne Mail'); + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('emailEnabled'); + }); + + it('ist ausgegraut mit Erklaerung, wenn kein E-Mail-Versand eingerichtet ist', async () => { + mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true }); + mockList.mockResolvedValue([]); + render(); + const box = await openFormAndWaitForStatus(); + expect(await screen.findByText('reminder.emailNoSmtp')).toBeTruthy(); + expect(box.disabled).toBe(true); + }); + + it('ist ausgegraut mit eigener Erklaerung, wenn das Konto keine Adresse hat', async () => { + mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: false }); + mockList.mockResolvedValue([]); + render(); + const box = await openFormAndWaitForStatus(); + expect(await screen.findByText('reminder.emailNoAddress')).toBeTruthy(); + expect(box.disabled).toBe(true); + }); + + it('gilt als nicht moeglich, solange der Status fehlt oder fehlschlaegt', async () => { + mockEmailStatus.mockRejectedValue(new Error('offline')); + mockList.mockResolvedValue([]); + render(); + const box = await openFormAndWaitForStatus(); + expect(box.disabled).toBe(true); + }); + + it('zeigt bei Zeilen mit emailEnabled ein Mail-Symbol, sonst nicht', async () => { + mockList.mockResolvedValue([ + { ...reminder('a', 'Mit', '2099-01-01T10:00:00.000Z'), emailEnabled: true }, + reminder('b', 'Ohne', '2099-01-02T10:00:00.000Z'), + ]); + render(); + const rows = await screen.findAllByTestId('reminder-row'); + expect(within(rows[0]).getByTestId('reminder-email-icon')).toBeTruthy(); + expect(within(rows[1]).queryByTestId('reminder-email-icon')).toBeNull(); + }); + + it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => { + mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true }); + mockList.mockResolvedValue([ + { ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true }, + ]); + mockUpdate.mockResolvedValue({}); + render(); + fireEvent.click(await screen.findByLabelText('reminder.edit')); + const box = screen.getByLabelText('reminder.emailLabel') as HTMLInputElement; + expect(box.checked).toBe(true); + expect(box.disabled).toBe(false); // angehakt bleibt bedienbar, zum Abwaehlen + fireEvent.click(screen.getByText('reminder.save')); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('emailEnabled'); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/reminder-widget.tsx b/apps/web/src/components/dashboard/widgets/reminder-widget.tsx index d125c3e..bbd6427 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-widget.tsx @@ -7,8 +7,10 @@ import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; import { REMINDERS_CHANGED_EVENT, type Reminder, + type ReminderEmailStatus, ReminderRequestError, deleteReminder, + getReminderEmailStatus, listReminders, snoozeReminder, } from '@/lib/reminders-api'; @@ -45,6 +47,8 @@ export function ReminderWidget(_props: WidgetProps) { const [snoozeOpenId, setSnoozeOpenId] = useState(null); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [notice, setNotice] = useState(null); + // Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich. + const [emailStatus, setEmailStatus] = useState(null); const refetch = useCallback(async () => { try { @@ -55,6 +59,18 @@ export function ReminderWidget(_props: WidgetProps) { } }, []); + useEffect(() => { + let cancelled = false; + getReminderEmailStatus() + .then((status) => { + if (!cancelled) setEmailStatus(status); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { void refetch(); const refresh = setInterval(() => void refetch(), REFRESH_MS); @@ -160,8 +176,33 @@ export function ReminderWidget(_props: WidgetProps) { : 'rounded-md border border-border bg-background/60 px-3 py-2' } > -
-

{r.title}

+
+

{r.title}

+ {r.emailEnabled && ( + + + + )} {due && ( {t('reminder.due')} @@ -308,6 +349,7 @@ export function ReminderWidget(_props: WidgetProps) { {modal && ( setModal(null)} onSaved={afterMutation} onStale={() => void refetch()} diff --git a/apps/web/src/lib/reminders-api.test.ts b/apps/web/src/lib/reminders-api.test.ts index 763eaff..d8367cb 100644 --- a/apps/web/src/lib/reminders-api.test.ts +++ b/apps/web/src/lib/reminders-api.test.ts @@ -3,6 +3,7 @@ import { ReminderRequestError, createReminder, deleteReminder, + getReminderEmailStatus, listReminders, snoozeReminder, updateReminder, @@ -68,6 +69,14 @@ describe('reminders-api', () => { expect(init.credentials).toBe('include'); }); + it('getReminderEmailStatus ruft GET /reminders/email-status', async () => { + fetchMock.mockResolvedValue(ok({ smtpConfigured: true, hasEmail: false })); + await expect(getReminderEmailStatus()).resolves.toEqual({ smtpConfigured: true, hasEmail: false }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toMatch(/\/reminders\/email-status$/); + expect(init.credentials).toBe('include'); + }); + it('bei 404 und 409 tragen die Fehler den Status', async () => { fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 }); diff --git a/apps/web/src/lib/reminders-api.ts b/apps/web/src/lib/reminders-api.ts index ffdce0e..d2852a4 100644 --- a/apps/web/src/lib/reminders-api.ts +++ b/apps/web/src/lib/reminders-api.ts @@ -55,6 +55,8 @@ export interface ReminderInput { description?: string; /** ISO-8601-Zeitpunkt. */ dueAt: string; + /** Zusaetzlich per E-Mail erinnern; nur senden, wenn es sich aendert oder `true` ist. */ + emailEnabled?: boolean; } export function createReminder(input: ReminderInput): Promise { @@ -79,3 +81,13 @@ export function snoozeReminder(id: string, dueAt: string): Promise { export function deleteReminder(id: string): Promise<{ deleted: true }> { return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' }); } + +/** Ob die E-Mail-Erinnerung fuer diesen Benutzer moeglich ist (Versand eingerichtet, Adresse vorhanden). */ +export interface ReminderEmailStatus { + smtpConfigured: boolean; + hasEmail: boolean; +} + +export function getReminderEmailStatus(): Promise { + return request('/reminders/email-status'); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 376cd93..0aeed8c 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -508,6 +508,10 @@ "alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.", "notDue": "Die Erinnerung ist noch nicht fällig.", "permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.", + "emailLabel": "Zusätzlich per E-Mail erinnern", + "emailNoSmtp": "E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator.", + "emailNoAddress": "In Ihrem Konto ist keine E-Mail-Adresse hinterlegt.", + "emailOn": "Zusätzlich per E-Mail", "notificationTitle": "Erinnerung: {title}" }, "editShort": "Bearbeiten", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 6c6f0eb..2fbbd9b 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -508,6 +508,10 @@ "alreadyDue": "The reminder is now due and can no longer be edited.", "notDue": "The reminder is not due yet.", "permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.", + "emailLabel": "Also remind me by e-mail", + "emailNoSmtp": "E-mail reminders are not possible because no e-mail delivery is set up. Please contact your administrator.", + "emailNoAddress": "Your account has no e-mail address on file.", + "emailOn": "Also by e-mail", "notificationTitle": "Reminder: {title}" }, "editShort": "Edit", diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md index 815992b..536f188 100644 --- a/docs/anleitung-anwender.md +++ b/docs/anleitung-anwender.md @@ -89,6 +89,7 @@ Ihre Änderungen werden über **„Fertig"** übernommen. Verlassen Sie den Bear | Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder | | XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. | | Proxmox | Zeigt den Zustand Ihrer Proxmox-Server: oben ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“, darunter die Server, auffällige zuerst, jeweils mit einer Kennzahl (laufende Gäste, Auslastung bei einer Warnung, letzte Sicherung oder eingehende E-Mails). Ein Klick auf einen Server öffnet die Proxmox-Seite. Die Kachel steht nur Benutzern zur Verfügung, die das Proxmox-Modul nutzen dürfen. Sie aktualisiert sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab. Einen Titel und die Auswahl der angezeigten Server (ohne Auswahl: alle) legen Sie im Bearbeitungsmodus direkt an der Kachel über das Titelfeld und „Server auswählen“ fest oder unter Einstellungen > Dashboard. In einer schmalen Kachel stehen nur Punkte und Namen, in einer sehr kleinen nur Balken und Zusammenfassung | +| Erinnerungen | Persönliche Erinnerungen mit Datum, Uhrzeit, Titel und Beschreibung. Über „Neue Erinnerung“ legen Sie eine an; die Liste zeigt Ihre Erinnerungen nach Zeit sortiert. Zur gewählten Zeit meldet sich Tessera mit einer Benachrichtigung – im Browser nach einer einmaligen Erlaubnis (der Browser fragt beim ersten Anlegen), in der Desktop-App als Windows-Benachrichtigung, auch wenn das Fenster im Infobereich liegt. Blockiert Ihr Browser Benachrichtigungen, sehen Sie die fällige Erinnerung nur hier in der Kachel; ein Hinweis in der Kachel sagt das. Wer möchte, setzt beim Anlegen den Haken „Zusätzlich per E-Mail erinnern“: Tessera schickt dann zur gewählten Zeit eine E-Mail an Ihre Adresse, auch wenn Tessera nirgends geöffnet ist. Der Haken ist ausgegraut, wenn Ihr Administrator noch keinen E-Mail-Versand eingerichtet hat oder in Ihrem Konto keine E-Mail-Adresse hinterlegt ist; die Kachel nennt den Grund. Eine fällige Erinnerung bleibt hervorgehoben mit „Fällig“ stehen. „Erledigt“ entfernt sie; „Später erinnern“ verschiebt sie auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit — dann melden sich Benachrichtigung und (falls gewählt) E-Mail noch einmal. Bearbeiten und Löschen sind nur möglich, solange die Erinnerung noch nicht fällig ist. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre | Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Proxmox gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame, Titel und angezeigte Server der Proxmox-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen). @@ -224,7 +225,7 @@ Schließen Sie das Fenster über das X, legt sich Tessera lediglich in den Infob - **Mit Windows starten** (unter Linux: **Beim Anmelden starten**) — mit Häkchen - **Beenden** -Nur „Beenden" beendet die App tatsächlich, auch im Infobereich. Fenstergröße und -position merkt sich die App bis zum nächsten Start. Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows). +Nur „Beenden" beendet die App tatsächlich, auch im Infobereich. Solange die App läuft, melden sich auch die Erinnerungen aus dem Widget „Erinnerungen“ als Windows-Benachrichtigung, wenn das Fenster im Infobereich liegt (bei ausgeblendetem Fenster kann die Meldung bis zu einer Minute nach der gewählten Zeit erscheinen). Fenstergröße und -position merkt sich die App bis zum nächsten Start. Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows). ### Server-Adresse ändern diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 69a3759..a832326 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -177,10 +177,10 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | | proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) | | custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. | -| reminders | 0 | 7 | 0 | **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | -| **Summe** | **61** | **230** | **6** | **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | +| **Summe** | **61** | **235** | **7** | **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | -## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 80 Paare) +## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 83 Paare) Stand 260909-jts (Aufgabe 3): 61 Paare aus dem vorherigen Durchlauf (260909-ipc) plus ein bisher vollstaendig unsichtbares Paar @@ -345,11 +345,11 @@ entnommen (30 Zusicherungen, darunter der Wachhund | Klasse | Anzahl Paare | |---|---| -| muss-mandantengebunden | 43 | +| muss-mandantengebunden | 44 | | keine-mandantengebundene-tabelle | 21 | -| beides | 14 | +| beides | 16 | | bewusst-uebergreifend | 2 | -| **Summe** | **80** | +| **Summe** | **83** | quick-260923-dhh (Aufgabe 1): +2 `muss-mandantengebunden` (`proxmox.service.ts`/`proxmoxServer` und `/proxmoxServerStatus`, beide `gebunden`) — nachgerechnet mit der Gate-Schleife, nicht @@ -371,6 +371,11 @@ quick-260929-if2 (Aufgabe 1): +1 `muss-mandantengebunden` (`reminders.service.ts 80 Paare, davon 43 `muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-260929-if2 (Aufgabe 3): +3 Paare (`reminders.service.ts`/`user` als `muss-mandantengebunden`, +`reminder-mail.scheduler.ts`/`reminder` und `/user` als `beides`): 83 Paare, davon 44 +`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 16 `beides`, +2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + ## Der Hintergrunddienst als Falle — sechs Fälle Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — @@ -639,6 +644,21 @@ geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform baut je Versand einen Transport aus `getDecryptedSmtpConfig(tenantId)` des Empfänger-Mandanten. Deshalb trägt SmtpConfig keine `system_read_policy`. +- **Nachtrag quick-260929-if2 — neuer Fall: `reminder-mail.scheduler.ts` + (E-Mail-Planer der Erinnerungen).** Gleiche Bauart wie der Digest + (`tender-digest.scheduler.ts`): EIN globales Intervall alle 30 Sekunden + liest die fälligen Erinnerungen über ALLE Mandanten und handelt danach je + Zeile gebunden. Die Kandidatenabfrage läuft über `forSystem()` + (`reminder.findMany`, `system_read_policy ... FOR SELECT` auf "Reminder", + Migration 20260929140000) und ist bewusst nur skalar: ein `include:` oder + `select:` auf `user` im Systemklienten machte `User` zum Systemlese-Modell + (WINDOWS #27). Alles Weitere — der Anspruch (`updateMany` mit + `emailSentAt: null`, atomar), das Laden von Zeile und Adresse, die + Freigabe bei Transportfehler — läuft über `forTenant(prisma, c.tenantId)` + ohne Benutzer. Eine leere Kandidatenliste ist Nichtstun. Ohne diese + Regel sähe der Planer nach dem Scharfschalten keine Erinnerung und + verstummte (zu-wenig-statt-zu-viel). `FORSYSTEM_ALLOWED_CALL_SITES` pinnt + genau einen Aufruf in dieser Datei (neue Summe: 6 Dateien, 7 Aufrufe). ## Bestandsaufnahme @@ -785,6 +805,9 @@ werden. | apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. | | apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). **quick-260929-dzu — persönliche Einträge:** neue Spalte `ownerUserId` (NULL = gemeinsam, gesetzt = persönlich, nur für den Besitzer sichtbar). Klasse und Stand unverändert (`muss-mandantengebunden`, `gebunden`); der Zeilenschutz bekommt die Benutzerdimension nach dem Muster `SearchProvider` (Migration 20260929130000): vier nach Befehl getrennte Regeln — Lesen: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId` NULL ODER eigene Zeile), Schreiben (INSERT/UPDATE/DELETE): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Persönliche Zugriffe binden mit Benutzer (`forTenant(prisma, tenantId, user.id)`); das Schreiben GEMEINSAMER Einträge bindet bewusst OHNE Benutzer, weil die Regel einem Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — davor prüft der Dienst die Rolle (nur Administrator, sonst 403). Fremde persönliche Einträge sind für jeden anderen Benutzer, auch Administratoren, ununterscheidbar 404. Sechs mandantengebundene Rohtreffer (siehe Bereichszeile). | | apps/api/src/reminders/reminders.service.ts | reminder | muss-mandantengebunden | gebunden | **quick-260929-if2:** neu — persönliche, einmalige Erinnerungen des Dashboard-Widgets „Erinnerungen“ (Titel, Beschreibung, Fälligkeit). `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20260929140000, Form aus `DashboardImage`): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Jede Methode bindet mit Mandant UND Benutzer (`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`), jedes `where` trägt zusätzlich `tenantId` und `userId` (Anwendungspruefung, solange der RLS-Schalter aus ist). Fremde oder unbekannte Kennungen sind ununterscheidbar 404, nie 403 (D-05). Sieben mandantengebundene Rohtreffer: `list` (`findMany`), `create` (`count` und `create`), `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst` mit `where: { id, tenantId, userId }`, für `update`/`snooze`/`remove`). Das Verschieben setzt `emailSentAt` und `emailAttempts` zurück (D-03). | +| apps/api/src/reminders/reminders.service.ts | user | muss-mandantengebunden | gebunden | quick-260929-if2 (Aufgabe 3): `getEmailAvailability` liest die eigene E-Mail-Adresse des Aufrufers (`user.findFirst` mit `where: { id: userId, tenantId }`), gebunden mit Mandant UND Benutzer über denselben Klienten wie die übrigen Zugriffe der Methode. Grundlage für den Schalter „zusätzlich per E-Mail“ und die 400-Antwort bei `emailEnabled` ohne Adresse. | +| apps/api/src/reminders/reminder-mail.scheduler.ts | reminder | beides | system-gebunden | quick-260929-if2 (Aufgabe 3): der E-Mail-Planer der Erinnerungen ist ein globales 30-Sekunden-Intervall über ALLE Mandanten (bewusst übergreifend, siehe Dateikopf). Die Kandidatenabfrage (`findMany`, nur skalarer Select, `take 200`) liest über `forSystem()` (`system_read_policy ... FOR SELECT`, Migration 20260929140000, nur lesend); Anspruch (`updateMany`), Laden (`findFirst`) und Freigabe (`updateMany`) laufen je Kandidatenzeile über `forTenant(prisma, c.tenantId)`. Der Anspruch ist atomar (`emailSentAt: null` und unveränderte `dueAt` in der Bedingung), damit mehrere Instanzen nie doppelt senden. | +| apps/api/src/reminders/reminder-mail.scheduler.ts | user | beides | gebunden | quick-260929-if2 (Aufgabe 3): die Empfängeradresse wird je Kandidatenzeile gelesen (`user.findFirst` mit `where: { id, tenantId }`), gebunden an den Mandanten dieser Zeile — bewusst NICHT als Relation im Systemklienten der Kandidatenabfrage (sonst würde `User` zum Systemlese-Modell). | ## Was diese Etappe NICHT entscheidet