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:
2026-09-29 13:59:43 +02:00
parent 325c5ddbf2
commit 709b41a007
16 changed files with 946 additions and 57 deletions
+77 -2
View File
@@ -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);