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>
169 lines
6.9 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|
|
}
|