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, 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; } }