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,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;
}
}