Files
tessera-ctl/apps/api/src/reminders/reminders.service.ts
T
schalli c2e4467dd8 fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00

228 lines
8.6 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { SettingsService } from '../settings/settings.service';
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } 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,
private readonly settingsService: SettingsService,
) {}
/**
* Ist die E-Mail-Erinnerung fuer diesen Benutzer moeglich? (E-09: „E-Mail-
* Versand eingerichtet“ heisst, der Mandant hat eine `SmtpConfig`-Zeile —
* dieselbe Regel wie `TenderMailService`; der Umgebungs-Rueckfall des
* `MailService` zaehlt nicht.) Der Benutzer wird ueber den gebundenen Klienten
* gelesen.
*/
async getEmailAvailability(tenantId: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const user = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
select: { email: true },
});
const smtp = await this.settingsService.getSmtpConfig(tenantId);
return { smtpConfigured: smtp !== null, hasEmail: Boolean(user?.email) };
}
/** 400, wenn `emailEnabled: true` verlangt wird, obwohl E-Mail nicht moeglich ist. */
private async assertEmailAvailable(tenantId: string, userId: string) {
const { smtpConfigured, hasEmail } = await this.getEmailAvailability(tenantId, userId);
if (!smtpConfigured || !hasEmail) {
throw new BadRequestException('E-Mail-Erinnerungen sind nicht möglich');
}
}
/** 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);
if (dto.emailEnabled === true) await this.assertEmailAvailable(tenantId, userId);
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,
emailEnabled: dto.emailEnabled === true,
},
select: REMINDER_SELECT,
});
}
/**
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
*
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
*
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
*/
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);
const now = Date.now();
if (row.dueAt.getTime() <= now) {
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const data: {
title?: string;
description?: string;
dueAt?: Date;
emailEnabled?: boolean;
emailSentAt?: null;
emailAttempts?: number;
} = {};
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);
data.emailSentAt = null;
data.emailAttempts = 0;
}
if (dto.emailEnabled !== undefined) {
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
data.emailEnabled = dto.emailEnabled;
}
const result = await tenantPrisma.reminder.updateMany({
where: { id, tenantId, userId, dueAt: { gt: new Date() } },
data,
});
if (result.count !== 1) {
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
throw new ConflictException('Die Erinnerung ist bereits fällig');
}
const updated = await tenantPrisma.reminder.findFirst({
where: { id, tenantId, userId },
select: REMINDER_SELECT,
});
if (!updated) {
throw new NotFoundException('Erinnerung nicht gefunden');
}
return updated;
}
/**
* „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);
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;
}
}