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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user