import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { SettingsService } from '../settings/settings.service'; /** E-08: Abstand der Pruefung. */ const TICK_MS = 30_000; /** E-03: so lange nach der Faelligkeit wird noch gemailt (Neustart, Ausfall). */ const CATCH_UP_MS = 24 * 60 * 60 * 1000; /** E-04: hoechstens so viele Versuche je Faelligkeit. */ const MAX_ATTEMPTS = 3; /** Kandidaten je Durchlauf. */ const BATCH = 200; /** * E-Mail-Planer der Erinnerungen (quick-260929-if2): verschickt hoechstens EINE * Mail je faelliger Erinnerung, auch wenn Tessera nirgends geoeffnet ist. * * WARUM DER ANSPRUCH VOR DEM SENDEN STEHT (E-04, T-IF2-06): ein `updateMany` * setzt `emailSentAt` und zaehlt `emailAttempts` hoch, und zwar NUR, wo * `emailSentAt` noch leer ist und `dueAt` unveraendert. Nur wer die Zeile mit * `count === 1` bekommt, sendet. Mehrere API-Instanzen, ein Neustart mitten im * Durchlauf oder zwei ueberlappende Durchlaeufe verschicken so nie doppelt. * Scheitert der Transport, gibt der Planer den Anspruch wieder frei * (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht — * hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse * des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und * wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben * („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`). * * WARUM 24 STUNDEN (E-03): bei Neustart oder Ausfall werden Erinnerungen der * letzten 24 Stunden nachgeholt; wer spaeter SMTP einrichtet, bekommt keine * Mails ueber alte Erinnerungen. WARUM ALLE 30 SEKUNDEN (E-08): die Mail soll * zur Faelligkeit ankommen, die Abfrage ist ein einziger Indexzugriff auf * `dueAt`. * * SYSTEMKONTEXT: die Kandidatenabfrage liest ueber ALLE Mandanten * (`forSystem()`, `system_read_policy ... FOR SELECT`, Migration 20260929140000); * der Select ist bewusst nur skalar — eine Relation im Systemklienten machte * `User` zum Systemlese-Modell (WINDOWS #27). Alles Weitere, jede Schreib- und * Leseoperation je Zeile, laeuft an den Mandanten der Zeile gebunden. * Lebenszyklus `onApplicationBootstrap` wie `TenderSchedulerService`. */ @Injectable() export class ReminderMailScheduler implements OnApplicationBootstrap { private readonly logger = new Logger(ReminderMailScheduler.name); private readonly JOB_NAME = 'reminder-email'; private running = false; constructor( private readonly schedulerRegistry: SchedulerRegistry, private readonly prisma: PrismaService, private readonly settingsService: SettingsService, private readonly mail: MailService, ) {} /** Registriert das eine globale Intervall. Fehler werden nur protokolliert. */ onApplicationBootstrap(): void { try { try { this.schedulerRegistry.deleteInterval(this.JOB_NAME); } catch { /* noch nicht registriert — beim ersten Start erwartet */ } this.schedulerRegistry.addInterval( this.JOB_NAME, setInterval(() => { this.runTick().catch((err) => this.logger.error(`Reminder email tick failed: ${(err as Error).message}`), ); }, TICK_MS), ); this.logger.log( `Reminder email scheduler registered: every ${TICK_MS / 1000} s (single global job — all tenants)`, ); } catch (err) { this.logger.error(`Reminder email scheduler init failed: ${(err as Error).message}`); } } /** Ein Durchlauf. Ueberlappende Aufrufe im selben Prozess werden uebersprungen. */ async runTick(now: Date = new Date()): Promise { if (this.running) return; this.running = true; try { const systemPrisma = forSystem(this.prisma); const candidates: { id: string; tenantId: string; userId: string; dueAt: Date }[] = await systemPrisma.reminder.findMany({ where: { emailEnabled: true, emailSentAt: null, emailAttempts: { lt: MAX_ATTEMPTS }, dueAt: { lte: now, gte: new Date(now.getTime() - CATCH_UP_MS) }, }, select: { id: true, tenantId: true, userId: true, dueAt: true }, orderBy: { dueAt: 'asc' }, take: BATCH, }); for (const candidate of candidates) { try { await this.processCandidate(candidate, now); } catch (err) { // Eine kaputte Zeile darf die uebrigen nicht anhalten. this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`); } } } finally { this.running = false; } } private async processCandidate( c: { id: string; tenantId: string; userId: string; dueAt: Date }, now: Date, ): Promise { const tenantPrisma = forTenant(this.prisma, c.tenantId); // Anspruch: nur wer count === 1 bekommt, sendet. const claim = await tenantPrisma.reminder.updateMany({ where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt, emailEnabled: true, emailSentAt: null, emailAttempts: { lt: MAX_ATTEMPTS }, }, data: { emailSentAt: now, emailAttempts: { increment: 1 } }, }); if (claim.count !== 1) return; // Zeile nur mit UNVERAENDERTER Faelligkeit laden: wurde sie zwischen Anspruch // und Laden verschoben, ist die Faelligkeit nicht mehr dieselbe. const row = await tenantPrisma.reminder.findFirst({ where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt }, select: { title: true, description: true, dueAt: true }, }); // Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse. const user = await tenantPrisma.user.findFirst({ where: { id: c.userId, tenantId: c.tenantId, isActive: true }, select: { email: true }, }); const smtp = await this.settingsService.getSmtpConfig(c.tenantId); if (!row || !user?.email || smtp === null) { this.logger.log( `Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`, ); return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04) } let sent = false; try { sent = await this.mail.sendReminderEmail(c.tenantId, user.email, row); } catch (err) { this.logger.error(`Reminder email for ${c.id} threw: ${(err as Error).message}`); } if (!sent) { // Nur der eigene Anspruch (gleicher Zeitstempel) wird freigegeben. await tenantPrisma.reminder.updateMany({ where: { id: c.id, tenantId: c.tenantId, emailSentAt: now }, data: { emailSentAt: null }, }); } } }