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,73 @@
-- 260929-if2 — Erinnerungen: persoenliche, einmalige Erinnerungen je Benutzer.
--
-- Zweck: die Tabelle "Reminder" traegt die Erinnerungen des Dashboard-Widgets
-- „Erinnerungen“ (Titel, Beschreibung, Faelligkeit, optional E-Mail). Es gibt
-- keine Wiederholung (D-01) und keine Historie: „Erledigt“ loescht die Zeile.
--
-- Besitz: eine Erinnerung gehoert genau einem Benutzer (gleicher Mandant UND
-- gleicher Benutzer, D-05). Faellt der Benutzer weg, fallen seine Erinnerungen
-- mit (ON DELETE CASCADE). Die Anwendung antwortet fuer fremde Kennungen mit
-- 404 (nie 403).
--
-- Spuren des E-Mail-Planers: "emailSentAt" ist der ANSPRUCH auf den Versand
-- (wird vor dem Senden gesetzt, damit mehrere API-Instanzen nicht doppelt
-- senden), "emailAttempts" zaehlt die Versuche (hoechstens 3). Ein Verschieben
-- der Faelligkeit setzt beide zurueck.
--
-- Zeilenschutz, zwei Regeln:
-- tenant_isolation_policy — Mandant UND Benutzer (Form aus DashboardImage,
-- 20260921120000_dashboard_image): ohne gesetzten Benutzer (Hintergrund-
-- dienst, der je Mandant gebunden schreibt) gilt nur der Mandant, mit
-- Benutzer zusaetzlich "userId".
-- system_read_policy — NUR FOR SELECT, Form aus 20260914120000_rls_system_
-- context_read. Sie bedient allein die Kandidatenabfrage des E-Mail-
-- Planers (reminder-mail.scheduler.ts), der einmal ueber ALLE Mandanten
-- liest und dann je Zeile gebunden anspricht. Schreiben bleibt der
-- Mandantenregel vorbehalten.
--
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
-- CreateTable
CREATE TABLE "Reminder" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"dueAt" TIMESTAMP(3) NOT NULL,
"emailEnabled" BOOLEAN NOT NULL DEFAULT false,
"emailSentAt" TIMESTAMP(3),
"emailAttempts" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Reminder_tenantId_userId_dueAt_idx" ON "Reminder"("tenantId", "userId", "dueAt");
-- CreateIndex
CREATE INDEX "Reminder_dueAt_idx" ON "Reminder"("dueAt");
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260921120000)
ALTER TABLE "Reminder" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Reminder" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "Reminder"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- Systemkontext: nur Lesen, fuer die Kandidatenabfrage des E-Mail-Planers
CREATE POLICY system_read_policy ON "Reminder"
FOR SELECT USING (is_system_context());
+25
View File
@@ -54,6 +54,7 @@ model User {
groupMemberships GroupMembership[]
moduleGrants ModuleGrant[]
customModules CustomModule[]
reminders Reminder[]
@@index([tenantId])
@@index([username])
@@ -739,3 +740,27 @@ model CustomModule {
@@index([tenantId])
@@index([tenantId, ownerUserId])
}
// quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01). Eine Zeile
// gehoert genau einem Benutzer (D-05); fuer fremde Kennungen antwortet die API
// mit 404. "Erledigt" loescht die Zeile (E-02), es gibt keine Historie.
// emailSentAt/emailAttempts sind die Rechenspur des E-Mail-Planers (Anspruch
// vor dem Senden, hoechstens 3 Versuche); ein Verschieben (dueAt) setzt beide
// zurueck, damit die E-Mail erneut verschickt wird (D-03).
model Reminder {
id String @id @default(uuid())
tenantId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
description String @default("")
dueAt DateTime
emailEnabled Boolean @default(false)
emailSentAt DateTime?
emailAttempts Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, userId, dueAt])
@@index([dueAt])
}
+2
View File
@@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { RemindersModule } from './reminders/reminders.module';
@Module({
imports: [
@@ -55,6 +56,7 @@ import { CustomModulesModule } from './custom-modules/custom-modules.module';
BugReportsModule,
ProxmoxModule,
CustomModulesModule,
RemindersModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
@@ -0,0 +1,28 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/**
* DTO fuer das Anlegen einer Erinnerung (quick-260929-if2). `tenantId`,
* `userId` und die Spuren des E-Mail-Planers gibt es hier nicht: die globale
* ValidationPipe (`whitelist: true`) verwirft sie, der Dienst setzt Mandant und
* Benutzer ausschliesslich aus dem Anmelde-Token (T-IF2-02).
*/
export class CreateReminderDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(200)
title!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
/** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */
@IsISO8601({ strict: true })
dueAt!: string;
}
@@ -0,0 +1,80 @@
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 { RemindersController } from './reminders.controller';
function makeService() {
return {
list: vi.fn(async (..._args: unknown[]) => []),
create: vi.fn(async (..._args: unknown[]) => ({})),
};
}
const req = (tenantId?: string) => ({ tenantId }) as any;
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) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
it('haengt an Pfad reminders', () => {
expect(Reflect.getMetadata('path', RemindersController)).toBe('reminders');
});
});
describe('RemindersController — Mandant', () => {
it('reicht req.tenantId und die Benutzerkennung an den Dienst weiter', async () => {
const service = makeService();
const controller = new RemindersController(service as any);
await controller.list(req('t1'), user);
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']);
});
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.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('die globale Pipe verwirft untergeschobene Felder (T-IF2-02)', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{
title: ' a ',
dueAt: '2099-01-01T10:00:00.000Z',
tenantId: 'evil',
userId: 'evil',
emailSentAt: '2020-01-01T00:00:00.000Z',
emailAttempts: 9,
},
{ type: 'body', metatype: CreateReminderDto },
);
expect(out).not.toHaveProperty('tenantId');
expect(out).not.toHaveProperty('userId');
expect(out).not.toHaveProperty('emailSentAt');
expect(out).not.toHaveProperty('emailAttempts');
expect(out.title).toBe('a');
});
});
describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert list vor jeder :id-Route', () => {
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
const listIdx = methods.indexOf('list');
expect(listIdx).toBeGreaterThanOrEqual(0);
for (const name of methods) {
const path = Reflect.getMetadata('path', proto[name]);
if (typeof path === 'string' && path.startsWith(':id')) {
expect(listIdx).toBeLessThan(methods.indexOf(name));
}
}
});
});
@@ -0,0 +1,44 @@
import { Body, Controller, ForbiddenException, Get, 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 { RemindersService } from './reminders.service';
/**
* Persoenliche Erinnerungen (quick-260929-if2). Jeder angemeldete Benutzer
* verwaltet seine eigenen; fremde Kennungen sind 404 (D-05). Kein `@Roles`,
* kein `@UseModule`: das Widget haengt an keiner Modul-Aktivierung.
* `tenantId` kommt ausschliesslich aus `req.tenantId` (gesetzt vom
* `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
* `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der
* Controller-Test haelt die Reihenfolge fest.
*/
@Controller('reminders')
export class RemindersController {
constructor(private readonly service: RemindersService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get()
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.list(this.requireTenantId(req), user.id);
}
@Post()
async create(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Body() dto: CreateReminderDto,
) {
return this.service.create(this.requireTenantId(req), user.id, dto);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RemindersController } from './reminders.controller';
import { RemindersService } from './reminders.service';
/** Erinnerungen (quick-260929-if2). `PrismaModule` ist global. */
@Module({
controllers: [RemindersController],
providers: [RemindersService],
})
export class RemindersModule {}
@@ -0,0 +1,106 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant` reicht den Klienten durch — die Bindung selbst prueft
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten gebunden wird.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { RemindersService } from './reminders.service';
function makeFakePrisma() {
const rows = new Map<string, any>();
let seq = 0;
const reminder = {
create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data };
rows.set(id, row);
return row;
}),
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
let list = [...rows.values()];
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
if (where?.userId) list = list.filter((r) => r.userId === where.userId);
if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list;
}),
count: vi.fn(async ({ where }: { where?: any } = {}) => {
return [...rows.values()].filter(
(r) => r.tenantId === where?.tenantId && r.userId === where?.userId,
).length;
}),
};
return { reminder, rows };
}
function setup() {
const prisma = makeFakePrisma();
return { prisma, service: new RemindersService(prisma as any) };
}
const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString();
describe('RemindersService — anlegen', () => {
it('speichert tenantId und userId aus den Argumenten, nie aus dem DTO', async () => {
const { prisma, service } = setup();
await service.create('t1', 'u1', {
title: 'Zahnarzt',
dueAt: inHours(2),
tenantId: 'evil',
userId: 'evil',
} as any);
const data = prisma.reminder.create.mock.calls[0][0].data;
expect(data.tenantId).toBe('t1');
expect(data.userId).toBe('u1');
expect(data.description).toBe('');
});
it('bindet mit Mandant UND Benutzer', async () => {
const { service } = setup();
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u1');
});
it('lehnt eine vergangene Faelligkeit mit 400 ab', async () => {
const { service } = setup();
await expect(
service.create('t1', 'u1', { title: 'a', dueAt: inHours(-1) }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('lehnt eine Faelligkeit ueber 5 Jahre voraus mit 400 ab', async () => {
const { service } = setup();
await expect(
service.create('t1', 'u1', { title: 'a', dueAt: inHours(24 * 365 * 5 + 48) }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('die 101. Erinnerung eines Benutzers ergibt 409', async () => {
const { prisma, service } = setup();
for (let i = 0; i < 100; i++) {
prisma.rows.set(`x${i}`, { id: `x${i}`, tenantId: 't1', userId: 'u1', dueAt: new Date() });
}
await expect(
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy();
});
});
describe('RemindersService — auflisten', () => {
it('liefert nur Zeilen von Mandant und Benutzer, nach Faelligkeit aufsteigend', async () => {
const { prisma, service } = setup();
await service.create('t1', 'u1', { title: 'spaet', dueAt: inHours(5) });
await service.create('t1', 'u1', { title: 'frueh', dueAt: inHours(1) });
await service.create('t1', 'u2', { title: 'fremd', dueAt: inHours(2) });
await service.create('t2', 'u1', { title: 'anderer Mandant', dueAt: inHours(2) });
const list = await service.list('t1', 'u1');
expect(list.map((r: any) => r.title)).toEqual(['frueh', 'spaet']);
const where = prisma.reminder.findMany.mock.calls[0]?.[0]?.where;
expect(where).toEqual({ tenantId: 't1', userId: 'u1' });
});
});
@@ -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;
}
}