feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste
- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage - MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung - Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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, 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 },
|
||||
});
|
||||
const user = await tenantPrisma.user.findFirst({
|
||||
where: { id: c.userId, tenantId: c.tenantId },
|
||||
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?.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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user