From 709b41a007892fe94214463cc346350e374da5e9 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 29 Sep 2026 13:59:43 +0200 Subject: [PATCH] feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen - API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404 - Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen - Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/reminders/dto/reminder.dto.ts | 14 + .../reminders/reminders.controller.spec.ts | 41 ++- .../api/src/reminders/reminders.controller.ts | 43 ++- .../src/reminders/reminders.service.spec.ts | 142 +++++++++- apps/api/src/reminders/reminders.service.ts | 79 +++++- .../dashboard/widgets/reminder-form-modal.tsx | 53 ++-- .../widgets/reminder-widget.test.tsx | 166 ++++++++++- .../dashboard/widgets/reminder-widget.tsx | 266 ++++++++++++++++-- apps/web/src/lib/reminder-time.test.ts | 69 +++++ apps/web/src/lib/reminder-time.ts | 29 ++ apps/web/src/lib/reminders-api.test.ts | 44 ++- apps/web/src/lib/reminders-api.ts | 19 ++ apps/web/src/messages/de.json | 15 + apps/web/src/messages/en.json | 15 + apps/web/src/messages/umlaut-dictionary.ts | 2 + ...andantentrennung-zugriffsklassifikation.md | 6 +- 16 files changed, 946 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/lib/reminder-time.test.ts diff --git a/apps/api/src/reminders/dto/reminder.dto.ts b/apps/api/src/reminders/dto/reminder.dto.ts index a750af2..5b87cca 100644 --- a/apps/api/src/reminders/dto/reminder.dto.ts +++ b/apps/api/src/reminders/dto/reminder.dto.ts @@ -1,3 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; import { Transform } from 'class-transformer'; import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; @@ -26,3 +27,16 @@ export class CreateReminderDto { @IsISO8601({ strict: true }) dueAt!: string; } + +/** + * Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. + * Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer + * gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`). + */ +export class UpdateReminderDto extends PartialType(CreateReminderDto) {} + +/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */ +export class SnoozeReminderDto { + @IsISO8601({ strict: true }) + dueAt!: string; +} diff --git a/apps/api/src/reminders/reminders.controller.spec.ts b/apps/api/src/reminders/reminders.controller.spec.ts index 6917a55..435e321 100644 --- a/apps/api/src/reminders/reminders.controller.spec.ts +++ b/apps/api/src/reminders/reminders.controller.spec.ts @@ -2,13 +2,16 @@ import 'reflect-metadata'; import { ForbiddenException, ValidationPipe } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; -import { CreateReminderDto } from './dto/reminder.dto'; +import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import { RemindersController } from './reminders.controller'; function makeService() { return { list: vi.fn(async (..._args: unknown[]) => []), create: vi.fn(async (..._args: unknown[]) => ({})), + update: vi.fn(async (..._args: unknown[]) => ({})), + snooze: vi.fn(async (..._args: unknown[]) => ({})), + remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })), }; } @@ -17,7 +20,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'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { + it.each(['list', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); }); @@ -34,11 +37,22 @@ describe('RemindersController — Mandant', () => { 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']); + await controller.update(req('t1'), user, 'x', { title: 'b' }); + await controller.snooze(req('t1'), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }); + await controller.remove(req('t1'), user, 'x'); + expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']); + expect(service.snooze.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']); + expect(service.remove).toHaveBeenCalledWith('t1', 'u1', 'x'); }); 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.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException); + await expect( + controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }), + ).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException); await expect( controller.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }), ).rejects.toBeInstanceOf(ForbiddenException); @@ -65,6 +79,29 @@ describe('RemindersController — Mandant', () => { }); }); +describe('RemindersController — Pipe fuer Aendern und Verschieben', () => { + it('Aendern verwirft untergeschobene Felder, ein Teil-Update ist erlaubt', async () => { + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + const out: any = await pipe.transform( + { title: 'b', tenantId: 'evil', userId: 'evil', emailAttempts: 0 }, + { type: 'body', metatype: UpdateReminderDto }, + ); + expect(out).toEqual({ title: 'b' }); + }); + + it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => { + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + const out: any = await pipe.transform( + { dueAt: '2099-01-01T10:00:00.000Z', userId: 'evil' }, + { type: 'body', metatype: SnoozeReminderDto }, + ); + expect(out).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' }); + await expect( + pipe.transform({ dueAt: 'morgen' }, { type: 'body', metatype: SnoozeReminderDto }), + ).rejects.toBeTruthy(); + }); +}); + describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => { it('deklariert list vor jeder :id-Route', () => { const methods = Object.getOwnPropertyNames(RemindersController.prototype); diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts index 412842e..c1d988c 100644 --- a/apps/api/src/reminders/reminders.controller.ts +++ b/apps/api/src/reminders/reminders.controller.ts @@ -1,7 +1,17 @@ -import { Body, Controller, ForbiddenException, Get, Post, Req } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Patch, + Post, + Req, +} from '@nestjs/common'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user'; -import { CreateReminderDto } from './dto/reminder.dto'; +import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; import { RemindersService } from './reminders.service'; /** @@ -41,4 +51,33 @@ export class RemindersController { ) { return this.service.create(this.requireTenantId(req), user.id, dto); } + + @Patch(':id') + async update( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateReminderDto, + ) { + return this.service.update(this.requireTenantId(req), user.id, id, dto); + } + + @Post(':id/snooze') + async snooze( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: SnoozeReminderDto, + ) { + return this.service.snooze(this.requireTenantId(req), user.id, id, dto); + } + + @Delete(':id') + async remove( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Param('id') id: string, + ) { + return this.service.remove(this.requireTenantId(req), user.id, id); + } } diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts index e97f027..fde2d08 100644 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ b/apps/api/src/reminders/reminders.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; // `forTenant` reicht den Klienten durch — die Bindung selbst prueft @@ -27,6 +27,19 @@ function makeFakePrisma() { if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime()); return list; }), + findFirst: vi.fn(async ({ where }: { where: any }) => { + const r = rows.get(where.id); + if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return null; + return r; + }), + update: vi.fn(async ({ where, data }: { where: any; data: any }) => { + const row = { ...rows.get(where.id), ...data }; + rows.set(where.id, row); + return row; + }), + delete: vi.fn(async ({ where }: { where: any }) => { + rows.delete(where.id); + }), count: vi.fn(async ({ where }: { where?: any } = {}) => { return [...rows.values()].filter( (r) => r.tenantId === where?.tenantId && r.userId === where?.userId, @@ -104,3 +117,130 @@ describe('RemindersService — auflisten', () => { expect(where).toEqual({ tenantId: 't1', userId: 'u1' }); }); }); + +/** Zeile direkt in den Speicher legen (auch faellige, die `create` ablehnen wuerde). */ +function seed( + prisma: ReturnType, + id: string, + dueAt: Date, + owner: { tenantId: string; userId: string } = { tenantId: 't1', userId: 'u1' }, +) { + prisma.rows.set(id, { + id, + ...owner, + title: 'alt', + description: 'beschr', + dueAt, + emailEnabled: false, + emailSentAt: new Date(), + emailAttempts: 2, + }); +} + +const past = () => new Date(Date.now() - 3600_000); +const future = () => new Date(Date.now() + 3600_000); + +describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)', () => { + it.each([ + ['fremder Benutzer', { tenantId: 't1', userId: 'u2' }], + ['fremder Mandant', { tenantId: 't2', userId: 'u1' }], + ])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => { + const { prisma, service } = setup(); + seed(prisma, 'x', past(), owner); + await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException); + await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException); + await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.rows.has('x')).toBe(true); + }); + + it('eine unbekannte Kennung ergibt 404', async () => { + const { service } = setup(); + await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('das where jeder Abfrage traegt Mandant und Benutzer', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', future()); + await service.update('t1', 'u1', 'x', { title: 'n' }); + expect(prisma.reminder.findFirst.mock.calls[0]?.[0]?.where).toEqual({ + id: 'x', + tenantId: 't1', + userId: 'u1', + }); + expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({ + id: 'x', + tenantId: 't1', + userId: 'u1', + }); + }); +}); + +describe('RemindersService — aendern', () => { + it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', past()); + await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException); + }); + + it('eine vergangene neue Faelligkeit ergibt 400', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', future()); + await expect(service.update('t1', 'u1', 'x', { dueAt: inHours(-2) })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('aendert nur die gesetzten Felder', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', future()); + await service.update('t1', 'u1', 'x', { title: 'neu' }); + expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' }); + const newDue = inHours(9); + await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' }); + expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({ + description: '', + dueAt: new Date(newDue), + }); + }); +}); + +describe('RemindersService — spaeter erinnern (D-03)', () => { + it('eine noch nicht faellige Erinnerung laesst sich nicht verschieben (409)', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', future()); + await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(3) })).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('eine vergangene neue Faelligkeit ergibt 400', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', past()); + await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(-1) })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('schreibt dueAt UND setzt emailSentAt und emailAttempts zurueck', async () => { + const { prisma, service } = setup(); + seed(prisma, 'x', past()); + const newDue = inHours(1); + await service.snooze('t1', 'u1', 'x', { dueAt: newDue }); + expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ + dueAt: new Date(newDue), + emailSentAt: null, + emailAttempts: 0, + }); + }); +}); + +describe('RemindersService — loeschen (E-02)', () => { + it('loescht die eigene Zeile, vor und nach der Faelligkeit', async () => { + const { prisma, service } = setup(); + seed(prisma, 'a', future()); + seed(prisma, 'b', past()); + await expect(service.remove('t1', 'u1', 'a')).resolves.toEqual({ deleted: true }); + await expect(service.remove('t1', 'u1', 'b')).resolves.toEqual({ deleted: true }); + expect(prisma.rows.size).toBe(0); + }); +}); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index 00f1203..164a39e 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -1,7 +1,12 @@ -import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; -import type { CreateReminderDto } from './dto/reminder.dto'; +import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto'; /** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */ export const REMINDER_SELECT = { @@ -68,6 +73,76 @@ export class RemindersService { }); } + /** + * Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu + * (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03). + */ + async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const row = await this.loadOwn(tenantPrisma, tenantId, userId, id); + if (row.dueAt.getTime() <= Date.now()) { + throw new ConflictException('Die Erinnerung ist bereits fällig'); + } + const data: { title?: string; description?: string; dueAt?: Date } = {}; + 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); + return tenantPrisma.reminder.update({ + where: { id, tenantId, userId }, + data, + select: REMINDER_SELECT, + }); + } + + /** + * „Spaeter erinnern“ (D-03): nur bei einer faelligen Erinnerung (409 sonst). + * Setzt die neue Faelligkeit UND die Spuren des E-Mail-Planers zurueck, damit + * die Benachrichtigungen wieder ausloesen und die E-Mail erneut verschickt + * wird. + */ + async snooze(tenantId: string, userId: string, id: string, dto: SnoozeReminderDto) { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const row = await this.loadOwn(tenantPrisma, tenantId, userId, id); + if (row.dueAt.getTime() > Date.now()) { + throw new ConflictException('Die Erinnerung ist noch nicht fällig'); + } + const dueAt = this.assertValidDueAt(dto.dueAt); + return tenantPrisma.reminder.update({ + where: { id, tenantId, userId }, + data: { dueAt, emailSentAt: null, emailAttempts: 0 }, + select: REMINDER_SELECT, + }); + } + + /** Loescht die Zeile (E-02) — bedient „Loeschen“ vor und „Erledigt“ nach der Faelligkeit. */ + async remove(tenantId: string, userId: string, id: string) { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await this.loadOwn(tenantPrisma, tenantId, userId, id); + await tenantPrisma.reminder.delete({ where: { id, tenantId, userId } }); + return { deleted: true }; + } + + /** + * Eigene Zeile laden. Unbekannt, fremder Mandant und fremder Benutzer sind + * ununterscheidbar 404 (D-05, T-IF2-01) — nie ein Hinweis, dass es die + * Kennung gibt. + */ + private async loadOwn( + tenantPrisma: ReturnType, + tenantId: string, + userId: string, + id: string, + ) { + const row = await tenantPrisma.reminder.findFirst({ + where: { id, tenantId, userId }, + select: { id: true, dueAt: true }, + }); + if (!row) { + throw new NotFoundException('Erinnerung nicht gefunden'); + } + return row; + } + /** E-06: die Faelligkeit muss in der Zukunft und hoechstens 5 Jahre voraus liegen. */ private assertValidDueAt(iso: string, now: number = Date.now()): Date { const dueAt = new Date(iso); 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 ad58935..21f8deb 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx @@ -4,13 +4,22 @@ import { useTranslations } from 'next-intl'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; -import { defaultNewReminderInputs, localInputsToIso } from '@/lib/reminder-time'; -import { ReminderRequestError, createReminder } from '@/lib/reminders-api'; +import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; +import { + type Reminder, + ReminderRequestError, + createReminder, + updateReminder, +} from '@/lib/reminders-api'; interface ReminderFormModalProps { + /** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */ + reminder: Reminder | null; onClose: () => void; /** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */ onSaved: () => void; + /** Der Server meldet, dass die Erinnerung inzwischen faellig ist (409) — die Kachel laedt neu. */ + onStale?: () => void; } const TITLE_MAX = 200; @@ -25,18 +34,23 @@ const DESCRIPTION_MAX = 2000; * macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild * `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`. * - * ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft als ERSTES im - * Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf + * BEARBEITEN (`reminder` gesetzt): nur fuer eine noch nicht faellige Erinnerung; + * eine inzwischen faellige lehnt der Server mit 409 ab (`alreadyDue`). + * + * ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft beim ANLEGEN als + * ERSTES im Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf * eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim * Laden der Seite. */ -export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) { +export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) { const t = useTranslations('widgets'); - const initial = useRef(defaultNewReminderInputs(new Date())).current; + const initial = useRef( + reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()), + ).current; const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); + const [title, setTitle] = useState(reminder?.title ?? ''); + const [description, setDescription] = useState(reminder?.description ?? ''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const titleRef = useRef(null); @@ -53,7 +67,7 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04). - requestBrowserPermissionOnce(); + if (!reminder) requestBrowserPermissionOnce(); setError(null); const trimmedTitle = title.trim(); @@ -70,15 +84,22 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) setSaving(true); try { - await createReminder({ title: trimmedTitle, description: description.trim(), dueAt }); + const input = { title: trimmedTitle, description: description.trim(), dueAt }; + if (reminder) { + await updateReminder(reminder.id, input); + } else { + await createReminder(input); + } onSaved(); onClose(); } catch (err) { - setError( - err instanceof ReminderRequestError && err.status === 409 - ? t('reminder.limitReached') - : t('reminder.saveError'), - ); + if (err instanceof ReminderRequestError && err.status === 409) { + // Anlegen: Grenze erreicht; Bearbeiten: inzwischen faellig. + setError(reminder ? t('reminder.alreadyDue') : t('reminder.limitReached')); + if (reminder) onStale?.(); + } else { + setError(t('reminder.saveError')); + } } finally { setSaving(false); } @@ -95,7 +116,7 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border" >

- {t('reminder.formTitle')} + {reminder ? t('reminder.editTitle') : t('reminder.formTitle')}

diff --git a/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx b/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx index 521887a..d772a6c 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('next-intl', () => ({ @@ -13,14 +13,27 @@ vi.mock('@/lib/reminders-api', async () => { ReminderRequestError: actual.ReminderRequestError, listReminders: vi.fn(), createReminder: vi.fn(), + updateReminder: vi.fn(), + snoozeReminder: vi.fn(), + deleteReminder: vi.fn(), }; }); -import { createReminder, listReminders } from '@/lib/reminders-api'; +import { + ReminderRequestError, + createReminder, + deleteReminder, + listReminders, + snoozeReminder, + updateReminder, +} from '@/lib/reminders-api'; import { ReminderWidget } from './reminder-widget'; const mockList = listReminders as ReturnType; const mockCreate = createReminder as ReturnType; +const mockUpdate = updateReminder as ReturnType; +const mockSnooze = snoozeReminder as ReturnType; +const mockDelete = deleteReminder as ReturnType; function reminder(id: string, title: string, dueAt: string, description = '') { return { @@ -42,6 +55,9 @@ beforeEach(() => { window.localStorage.clear(); mockList.mockReset(); mockCreate.mockReset(); + mockUpdate.mockReset(); + mockSnooze.mockReset(); + mockDelete.mockReset(); requestPermission = vi.fn(async () => 'granted'); const ctor = vi.fn(); Object.assign(ctor, { permission: 'default', requestPermission }); @@ -145,3 +161,149 @@ describe('ReminderWidget', () => { window.removeEventListener('tessera:reminders-changed', handler); }); }); + +const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString(); + +describe('ReminderWidget — faellig und Aktionen (D-03)', () => { + it('eine faellige Zeile ist hervorgehoben mit Faellig, Erledigt und Spaeter erinnern, ohne Bearbeiten/Loeschen', async () => { + mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); + render(); + const row = await screen.findByTestId('reminder-row'); + expect(row.getAttribute('data-due')).toBe('true'); + expect(row.className).toContain('border-status-warn'); + expect(within(row).getByText('reminder.due')).toBeTruthy(); + expect(within(row).getByText('reminder.done')).toBeTruthy(); + expect(within(row).getByText('reminder.snooze')).toBeTruthy(); + expect(within(row).queryByLabelText('reminder.edit')).toBeNull(); + expect(within(row).queryByLabelText('reminder.delete')).toBeNull(); + }); + + it('eine kuenftige Zeile bietet Bearbeiten und Loeschen, aber nicht Erledigt/Spaeter', async () => { + mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]); + render(); + const row = await screen.findByTestId('reminder-row'); + expect(row.getAttribute('data-due')).toBe('false'); + expect(within(row).getByLabelText('reminder.edit')).toBeTruthy(); + expect(within(row).getByLabelText('reminder.delete')).toBeTruthy(); + expect(within(row).queryByText('reminder.done')).toBeNull(); + expect(within(row).queryByText('reminder.snooze')).toBeNull(); + }); + + it('Erledigt ruft deleteReminder und die Zeile verschwindet nach dem Neuladen', async () => { + mockList.mockResolvedValueOnce([reminder('a', 'Faellig', iso(-60_000))]).mockResolvedValue([]); + mockDelete.mockResolvedValue({ deleted: true }); + render(); + fireEvent.click(await screen.findByText('reminder.done')); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a')); + await waitFor(() => expect(screen.queryByTestId('reminder-row')).toBeNull()); + }); + + it('jede Spaeter-Option ruft snoozeReminder mit dem Zeitpunkt aus snoozeTarget', async () => { + const original = new Date(Date.now() - 5 * 60_000); + mockList.mockResolvedValue([reminder('a', 'Faellig', original.toISOString())]); + mockSnooze.mockResolvedValue({}); + render(); + + for (const [label, expected] of [ + ['reminder.snooze10m', () => Date.now() + 10 * 60_000], + ['reminder.snooze1h', () => Date.now() + 60 * 60_000], + ] as const) { + mockSnooze.mockClear(); + fireEvent.click(await screen.findByText('reminder.snooze')); + fireEvent.click(await screen.findByText(label)); + await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1)); + const [id, dueAt] = mockSnooze.mock.calls[0]; + expect(id).toBe('a'); + expect(Math.abs(new Date(dueAt).getTime() - expected())).toBeLessThan(5_000); + } + + mockSnooze.mockClear(); + fireEvent.click(await screen.findByText('reminder.snooze')); + fireEvent.click(await screen.findByText('reminder.snoozeTomorrow')); + await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1)); + const tomorrow = new Date(original); + tomorrow.setDate(tomorrow.getDate() + 1); + expect(mockSnooze.mock.calls[0][1]).toBe(tomorrow.toISOString()); + }); + + it('Loeschen fragt zweistufig nach und ruft dann deleteReminder', async () => { + mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]); + mockDelete.mockResolvedValue({ deleted: true }); + render(); + fireEvent.click(await screen.findByLabelText('reminder.delete')); + expect(mockDelete).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('reminder.yes')); + await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a')); + }); + + it('Bearbeiten oeffnet das Formular mit den Werten und speichert per updateReminder', async () => { + const due = new Date(Date.now() + 3600_000); + mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString(), 'Text')]); + mockUpdate.mockResolvedValue({}); + render(); + fireEvent.click(await screen.findByLabelText('reminder.edit')); + const titleInput = screen.getByLabelText('reminder.titleLabel') as HTMLInputElement; + expect(titleInput.value).toBe('Alt'); + fireEvent.change(titleInput, { target: { value: 'Neu' } }); + fireEvent.click(screen.getByText('reminder.save')); + await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1)); + const [id, patch] = mockUpdate.mock.calls[0]; + expect(id).toBe('a'); + expect(patch.title).toBe('Neu'); + expect(patch.description).toBe('Text'); + // Bearbeiten fragt nie die Benachrichtigungs-Erlaubnis + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => { + mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]); + mockSnooze.mockRejectedValue(new ReminderRequestError(409)); + render(); + fireEvent.click(await screen.findByText('reminder.snooze')); + fireEvent.click(await screen.findByText('reminder.snooze10m')); + expect(await screen.findByText('reminder.notDue')).toBeTruthy(); + }); + + it('zeigt bei blockierten Browser-Benachrichtigungen einen Hinweis, sonst nicht', async () => { + mockList.mockResolvedValue([]); + (Notification as unknown as { permission: string }).permission = 'denied'; + const { unmount } = render(); + expect(await screen.findByTestId('reminder-permission-hint')).toBeTruthy(); + unmount(); + (Notification as unknown as { permission: string }).permission = 'default'; + render(); + await screen.findByText('reminder.empty'); + expect(screen.queryByTestId('reminder-permission-hint')).toBeNull(); + }); + + it('zeigt in der Desktop-App keinen Hinweis', async () => { + (window as unknown as Record).__TAURI_INTERNALS__ = { invoke: vi.fn() }; + (Notification as unknown as { permission: string }).permission = 'denied'; + mockList.mockResolvedValue([]); + render(); + await screen.findByText('reminder.empty'); + expect(screen.queryByTestId('reminder-permission-hint')).toBeNull(); + delete (window as unknown as Record).__TAURI_INTERNALS__; + }); +}); + +describe('ReminderWidget — Faelligkeit ohne Neuladen', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('eine Zeile wird durch den lokalen 10-s-Takt faellig', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z')); + mockList.mockResolvedValue([reminder('a', 'Bald', '2026-09-29T12:00:30.000Z')]); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('false'); + await act(async () => { + await vi.advanceTimersByTimeAsync(40_000); + }); + expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true'); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/reminder-widget.tsx b/apps/web/src/components/dashboard/widgets/reminder-widget.tsx index 3643b15..d125c3e 100644 --- a/apps/web/src/components/dashboard/widgets/reminder-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/reminder-widget.tsx @@ -2,31 +2,49 @@ import { useLocale, useTranslations } from 'next-intl'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { browserPermissionState } from '@/lib/reminder-notify'; +import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time'; import { REMINDERS_CHANGED_EVENT, type Reminder, + ReminderRequestError, + deleteReminder, listReminders, + snoozeReminder, } from '@/lib/reminders-api'; import type { WidgetProps } from '../widget-registry'; import { ReminderFormModal } from './reminder-form-modal'; /** Abstand der Auffrischung beim Server. */ const REFRESH_MS = 60_000; +/** Abstand, in dem die Kachel neu entscheidet, welche Erinnerung faellig ist. */ +const NOW_TICK_MS = 10_000; + +const SNOOZE_PRESETS: SnoozePreset[] = ['10m', '1h', 'tomorrow']; /** * Kachel „Erinnerungen“ (quick-260929-if2): die persoenlichen Erinnerungen des - * Benutzers, die naechste Faelligkeit zuerst, dazu „Neue Erinnerung“. Die - * Benachrichtigung selbst macht der globale `ReminderNotifier` im Portal- - * Rahmen — die Kachel muss dafuer nicht sichtbar sein. Nach jeder Aenderung - * laedt sie neu und meldet `REMINDERS_CHANGED_EVENT`, damit der Melder eine - * neue Faelligkeit sofort kennt. + * Benutzers, die naechste Faelligkeit zuerst. Die Benachrichtigung selbst macht + * der globale `ReminderNotifier` im Portal-Rahmen — die Kachel muss dafuer + * nicht sichtbar sein. Nach jeder Aenderung laedt sie neu und meldet + * `REMINDERS_CHANGED_EVENT`, damit der Melder eine neue Faelligkeit sofort kennt. + * + * Faellige Zeilen (D-03) bleiben hervorgehoben stehen und bieten „Erledigt“ + * (loescht die Zeile, E-02) und „Spaeter erinnern“ (+10 Minuten, +1 Stunde, + * morgen zur gleichen Uhrzeit). Noch nicht faellige lassen sich bearbeiten und + * loeschen. Ob eine Zeile faellig ist, entscheidet ein 10-s-Takt lokal, ohne + * Neuladen. */ export function ReminderWidget(_props: WidgetProps) { const t = useTranslations('widgets'); const locale = useLocale(); const [reminders, setReminders] = useState(null); const [loadError, setLoadError] = useState(false); - const [modalOpen, setModalOpen] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [modal, setModal] = useState<{ reminder: Reminder | null } | null>(null); + const [snoozeOpenId, setSnoozeOpenId] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [notice, setNotice] = useState(null); const refetch = useCallback(async () => { try { @@ -39,11 +57,13 @@ export function ReminderWidget(_props: WidgetProps) { useEffect(() => { void refetch(); - const timer = setInterval(() => void refetch(), REFRESH_MS); + const refresh = setInterval(() => void refetch(), REFRESH_MS); + const tick = setInterval(() => setNow(Date.now()), NOW_TICK_MS); const onChanged = () => void refetch(); window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged); return () => { - clearInterval(timer); + clearInterval(refresh); + clearInterval(tick); window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged); }; }, [refetch]); @@ -56,16 +76,61 @@ export function ReminderWidget(_props: WidgetProps) { [reminders], ); - const formatter = useMemo( + const dateTime = useMemo( () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }), [locale], ); + const timeOnly = useMemo(() => new Intl.DateTimeFormat(locale, { timeStyle: 'short' }), [locale]); - const handleSaved = () => { + /** Nach jeder erfolgreichen Aenderung: neu laden und den Melder informieren. */ + const afterMutation = () => { void refetch(); window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT)); }; + /** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */ + const handleFailure = (err: unknown, conflictKey: string) => { + setNotice( + err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'), + ); + void refetch(); + }; + + const markDone = async (id: string) => { + setNotice(null); + try { + await deleteReminder(id); + afterMutation(); + } catch (err) { + handleFailure(err, 'reminder.saveError'); + } + }; + + const snooze = async (r: Reminder, preset: SnoozePreset) => { + setNotice(null); + setSnoozeOpenId(null); + try { + const target = snoozeTarget(preset, new Date(r.dueAt), new Date()); + await snoozeReminder(r.id, target.toISOString()); + afterMutation(); + } catch (err) { + handleFailure(err, 'reminder.notDue'); + } + }; + + const remove = async (id: string) => { + setNotice(null); + setConfirmDeleteId(null); + try { + await deleteReminder(id); + afterMutation(); + } catch (err) { + handleFailure(err, 'reminder.saveError'); + } + }; + + const blocked = browserPermissionState() === 'denied'; + return (
@@ -74,34 +139,179 @@ export function ReminderWidget(_props: WidgetProps) { {t('reminder.loadError')}

)} + {notice && ( +

+ {notice} +

+ )} {!loadError && reminders !== null && sorted.length === 0 && (

{t('reminder.empty')}

)} - {sorted.map((r) => ( -
-

{r.title}

-

{formatter.format(new Date(r.dueAt))}

- {r.description !== '' && ( -

- {r.description} -

- )} -
- ))} + {sorted.map((r) => { + const due = new Date(r.dueAt).getTime() <= now; + return ( +
+
+

{r.title}

+ {due && ( + + {t('reminder.due')} + + )} +
+

{dateTime.format(new Date(r.dueAt))}

+ {r.description !== '' && ( +

+ {r.description} +

+ )} + + {due ? ( +
+ + + {snoozeOpenId === r.id && ( +
+ {SNOOZE_PRESETS.map((preset) => ( + + ))} +
+ )} +
+ ) : ( +
+ {confirmDeleteId === r.id ? ( + <> + {t('reminder.deleteConfirm')} + + + + ) : ( + <> + + + + )} +
+ )} +
+ ); + })}
+ {blocked && ( +

+ {t('reminder.permissionDenied')} +

+ )} - {modalOpen && ( - setModalOpen(false)} onSaved={handleSaved} /> + {modal && ( + setModal(null)} + onSaved={afterMutation} + onStale={() => void refetch()} + /> )}
); diff --git a/apps/web/src/lib/reminder-time.test.ts b/apps/web/src/lib/reminder-time.test.ts new file mode 100644 index 0000000..172992e --- /dev/null +++ b/apps/web/src/lib/reminder-time.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + defaultNewReminderInputs, + isoToLocalInputs, + localInputsToIso, + snoozeTarget, +} from './reminder-time'; + +// Alle Zeitpunkte in Ortszeit gebildet (`new Date(y, m, d, h, min)`), damit die +// Tests in jeder Zeitzone dasselbe pruefen. +const local = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min); + +describe('localInputsToIso / isoToLocalInputs', () => { + it('laufen im Kreis', () => { + const iso = localInputsToIso('2026-10-05', '14:30'); + expect(iso).toBe(local(2026, 10, 5, 14, 30).toISOString()); + expect(isoToLocalInputs(iso as string)).toEqual({ date: '2026-10-05', time: '14:30' }); + }); + + it('liefert null bei unvollstaendiger oder ungueltiger Eingabe', () => { + expect(localInputsToIso('', '14:30')).toBeNull(); + expect(localInputsToIso('2026-10-05', '')).toBeNull(); + expect(localInputsToIso('2026-13-45', '14:30')).toBeNull(); + }); +}); + +describe('defaultNewReminderInputs', () => { + it('ist die naechste volle Stunde', () => { + expect(defaultNewReminderInputs(local(2026, 9, 29, 13, 20))).toEqual({ + date: '2026-09-29', + time: '14:00', + }); + }); + + it('rollt ueber Mitternacht und Monatsende', () => { + expect(defaultNewReminderInputs(local(2026, 9, 30, 23, 30))).toEqual({ + date: '2026-10-01', + time: '00:00', + }); + }); +}); + +describe('snoozeTarget (E-05)', () => { + const now = local(2026, 9, 29, 14, 5); + const original = local(2026, 9, 29, 14, 0); + + it('10m und 1h zaehlen ab jetzt, nicht ab der alten Faelligkeit', () => { + expect(snoozeTarget('10m', original, now).getTime()).toBe(now.getTime() + 10 * 60_000); + expect(snoozeTarget('1h', original, now).getTime()).toBe(now.getTime() + 3600_000); + }); + + it('morgen = urspruengliche Ortszeit am naechsten Tag', () => { + expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime()); + }); + + it('morgen ueber das Monatsende', () => { + const n = local(2026, 9, 30, 15, 0); + expect(snoozeTarget('tomorrow', local(2026, 9, 30, 14, 0), n).getTime()).toBe( + local(2026, 10, 1, 14, 0).getTime(), + ); + }); + + it('eine tagelang ueberfaellige Erinnerung landet beim naechsten kuenftigen Vorkommen', () => { + const old = local(2026, 9, 25, 8, 15); + const target = snoozeTarget('tomorrow', old, now); + expect(target.getTime()).toBeGreaterThan(now.getTime()); + expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime()); + }); +}); diff --git a/apps/web/src/lib/reminder-time.ts b/apps/web/src/lib/reminder-time.ts index b98b63f..70f1ca8 100644 --- a/apps/web/src/lib/reminder-time.ts +++ b/apps/web/src/lib/reminder-time.ts @@ -27,3 +27,32 @@ export function defaultNewReminderInputs(now: Date): { date: string; time: strin time: `${pad(next.getHours())}:${pad(next.getMinutes())}`, }; } + +/** Zeitpunkt aus ISO-Zeichenkette als Datums- und Zeitfeld (Ortszeit) fuer das Formular. */ +export function isoToLocalInputs(iso: string): { date: string; time: string } { + const d = new Date(iso); + return { + date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}`, + }; +} + +export type SnoozePreset = '10m' | '1h' | 'tomorrow'; + +/** + * Neuer Zeitpunkt fuer „Spaeter erinnern“ (D-03, E-05). `10m` und `1h` zaehlen + * ab JETZT, nicht ab der alten Faelligkeit. `tomorrow` nimmt die urspruengliche + * Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit- + * 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, + * landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. + */ +export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date { + if (preset === '10m') return new Date(now.getTime() + 10 * 60_000); + if (preset === '1h') return new Date(now.getTime() + 60 * 60_000); + const d = new Date(originalDueAt); + do { + d.setDate(d.getDate() + 1); + } while (d.getTime() <= now.getTime()); + return d; +} diff --git a/apps/web/src/lib/reminders-api.test.ts b/apps/web/src/lib/reminders-api.test.ts index fbae341..763eaff 100644 --- a/apps/web/src/lib/reminders-api.test.ts +++ b/apps/web/src/lib/reminders-api.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ReminderRequestError, createReminder, listReminders } from './reminders-api'; +import { + ReminderRequestError, + createReminder, + deleteReminder, + listReminders, + snoozeReminder, + updateReminder, +} from './reminders-api'; const fetchMock = vi.fn(); @@ -33,6 +40,41 @@ describe('reminders-api', () => { expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }); }); + it('updateReminder schickt PATCH an /reminders/:id', async () => { + fetchMock.mockResolvedValue(ok({ id: 'r 1' })); + await updateReminder('r 1', { title: 'neu' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toMatch(/\/reminders\/r%201$/); + expect(init.method).toBe('PATCH'); + expect(init.credentials).toBe('include'); + expect(JSON.parse(init.body)).toEqual({ title: 'neu' }); + }); + + it('snoozeReminder schickt POST /reminders/:id/snooze mit dueAt', async () => { + fetchMock.mockResolvedValue(ok({ id: 'r1' })); + await snoozeReminder('r1', '2099-01-01T10:00:00.000Z'); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toMatch(/\/reminders\/r1\/snooze$/); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' }); + }); + + it('deleteReminder schickt DELETE an /reminders/:id', async () => { + fetchMock.mockResolvedValue(ok({ deleted: true })); + await expect(deleteReminder('r1')).resolves.toEqual({ deleted: true }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toMatch(/\/reminders\/r1$/); + expect(init.method).toBe('DELETE'); + 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 }); + fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) }); + await expect(snoozeReminder('x', 'y')).rejects.toMatchObject({ status: 409 }); + }); + it('ein Status ausserhalb 2xx wirft ReminderRequestError mit diesem Status', async () => { fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }); await expect(listReminders()).rejects.toMatchObject({ status: 401 }); diff --git a/apps/web/src/lib/reminders-api.ts b/apps/web/src/lib/reminders-api.ts index 4e0c770..ffdce0e 100644 --- a/apps/web/src/lib/reminders-api.ts +++ b/apps/web/src/lib/reminders-api.ts @@ -60,3 +60,22 @@ export interface ReminderInput { export function createReminder(input: ReminderInput): Promise { return request('/reminders', jsonInit('POST', input)); } + +export type ReminderPatch = Partial; + +export function updateReminder(id: string, patch: ReminderPatch): Promise { + return request(`/reminders/${encodeURIComponent(id)}`, jsonInit('PATCH', patch)); +} + +/** „Spaeter erinnern“: neue Faelligkeit (ISO), nur bei einer faelligen Erinnerung. */ +export function snoozeReminder(id: string, dueAt: string): Promise { + return request( + `/reminders/${encodeURIComponent(id)}/snooze`, + jsonInit('POST', { dueAt }), + ); +} + +/** Loescht die Erinnerung — bedient „Loeschen“ (vorher) und „Erledigt“ (nachher). */ +export function deleteReminder(id: string): Promise<{ deleted: true }> { + return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index d2d57a0..376cd93 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -493,6 +493,21 @@ "saveError": "Die Erinnerung konnte nicht gespeichert werden.", "loadError": "Erinnerungen konnten nicht geladen werden.", "limitReached": "Es sind höchstens 100 Erinnerungen möglich.", + "due": "Fällig", + "done": "Erledigt", + "snooze": "Später erinnern", + "snooze10m": "In 10 Minuten", + "snooze1h": "In 1 Stunde", + "snoozeTomorrow": "Morgen um {time}", + "edit": "Erinnerung bearbeiten", + "editTitle": "Erinnerung bearbeiten", + "delete": "Erinnerung löschen", + "deleteConfirm": "Löschen?", + "yes": "Ja", + "no": "Nein", + "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.", "notificationTitle": "Erinnerung: {title}" }, "editShort": "Bearbeiten", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 8af6c73..6c6f0eb 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -493,6 +493,21 @@ "saveError": "The reminder could not be saved.", "loadError": "Reminders could not be loaded.", "limitReached": "You can have at most 100 reminders.", + "due": "Due", + "done": "Done", + "snooze": "Remind me later", + "snooze10m": "In 10 minutes", + "snooze1h": "In 1 hour", + "snoozeTomorrow": "Tomorrow at {time}", + "edit": "Edit reminder", + "editTitle": "Edit reminder", + "delete": "Delete reminder", + "deleteConfirm": "Delete?", + "yes": "Yes", + "no": "No", + "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.", "notificationTitle": "Reminder: {title}" }, "editShort": "Edit", diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index de8a699..753a430 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record = { * and must never be touched by the replacement or flagged by the guard. */ export const UMLAUT_ALLOWLIST: readonly string[] = [ + // quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“) + 'lässt', 'manuell', 'Manuell', 'Quelle', diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 24c210b..69a3759 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -177,8 +177,8 @@ 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 | 3 | 0 | **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** | **226** | **6** | **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 | 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 | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 80 Paare) @@ -784,7 +784,7 @@ werden. | apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). | | 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). Drei mandantengebundene Rohtreffer: `list` (`findMany`), `create` (`count` und `create`). | +| 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). | ## Was diese Etappe NICHT entscheidet