feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)

- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen)
- Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal)
- Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse
- Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:54:07 +02:00
parent cd1f8f6cda
commit 325c5ddbf2
31 changed files with 1969 additions and 11 deletions
@@ -0,0 +1,85 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateReminderDto } from './dto/reminder.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
export const REMINDER_SELECT = {
id: true,
title: true,
description: true,
dueAt: true,
emailEnabled: true,
createdAt: true,
updatedAt: true,
};
/** E-06: hoechstens so viele Erinnerungen je Benutzer. */
export const MAX_REMINDERS_PER_USER = 100;
/** E-06: die Faelligkeit darf hoechstens so weit in der Zukunft liegen. */
const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/**
* Persoenliche Erinnerungen (quick-260929-if2). Eine Erinnerung gehoert genau
* einem Benutzer (D-05): fuer fremde oder unbekannte Kennungen antwortet der
* Dienst immer mit 404, nie mit 403 — kein Hinweis darauf, dass es sie gibt.
*
* `tenantId` und `userId` kommen ausschliesslich als Argumente (aus
* `req.tenantId` und dem Anmelde-Token), nie aus dem DTO.
*
* RLS-BINDUNG (Muster DashboardImage, Migration 20260929140000): jede Methode
* bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die
* Regel laesst dann nur eigene Zeilen zu. Zusaetzlich tragen alle `where`
* `tenantId` und `userId` als Anwendungspruefung, solange der RLS-Schalter aus
* ist.
*/
@Injectable()
export class RemindersService {
constructor(private readonly prisma: PrismaService) {}
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
async list(tenantId: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.reminder.findMany({
where: { tenantId, userId },
orderBy: { dueAt: 'asc' },
select: REMINDER_SELECT,
});
}
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
const dueAt = this.assertValidDueAt(dto.dueAt);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
if (count >= MAX_REMINDERS_PER_USER) {
throw new ConflictException(
`Es sind höchstens ${MAX_REMINDERS_PER_USER} Erinnerungen möglich`,
);
}
return tenantPrisma.reminder.create({
data: {
tenantId,
userId,
title: dto.title,
description: dto.description ?? '',
dueAt,
},
select: REMINDER_SELECT,
});
}
/** 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);
if (Number.isNaN(dueAt.getTime())) {
throw new BadRequestException('Ungültige Fälligkeit');
}
if (dueAt.getTime() <= now) {
throw new BadRequestException('Die Fälligkeit muss in der Zukunft liegen');
}
if (dueAt.getTime() > now + MAX_AHEAD_MS) {
throw new BadRequestException('Die Fälligkeit liegt zu weit in der Zukunft');
}
return dueAt;
}
}