c2e4467dd8
- 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>
228 lines
8.6 KiB
TypeScript
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;
|
|
}
|
|
}
|