Files
tessera-ctl/apps/api/src/reminders/reminder-mail.scheduler.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

169 lines
6.9 KiB
TypeScript

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<void> {
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<void> {
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 },
});
}
}
}