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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof makeFakePrisma>,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof forTenant>,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user