feat(demo): Mail-Benachrichtigungen bei Demo-Ablauf

Neue Mail-Templates (lib/mail.js):
- sendDemoExpiryReminderMail: 7 Tage vor Ablauf
- sendDemoLastDayMail: 1 Tag vor Ablauf (Letzter-Tag-Reminder)
- sendDemoExpiredMail: am Ablauftag (setzt Tenant-Status abgelaufen)

Cron-Job (lib/expiryJob.js):
- node-cron taeglich 08:00 UTC (anpassbar via CRON_EXPIRY_AT)
- 3 Windows pruefen, idempotent ueber notified_*_at Timestamp-Spalten
- BETWEEN-Fenster [n-1, n] Tage fuer Robustheit

DB-Migration (bootstrap.js):
- Beim Backend-Start automatisches ALTER TABLE fuer
  notified_7d_at, notified_1d_at, notified_expired_at (idempotent)

Manueller Test: docker exec pfandsystem-demo-backend node -e "
  import(\"./lib/expiryJob.js\").then(m => m.runExpiryCheck())"
This commit is contained in:
christian
2026-05-26 15:59:25 +00:00
parent d9c10211d8
commit 3228fe4ab3
5 changed files with 218 additions and 33 deletions

93
backend/lib/expiryJob.js Normal file
View File

@@ -0,0 +1,93 @@
import cron from 'node-cron';
import pool from '../config/database.js';
import {
sendDemoExpiryReminderMail,
sendDemoLastDayMail,
sendDemoExpiredMail,
} from './mail.js';
// Findet Demo-Tenants, deren Ablauf in [from, to] liegt UND fuer die noch
// keine entsprechende Benachrichtigung versandt wurde, schickt die Mail
// und markiert die Spalte mit NOW().
async function processWindow({ flagColumn, daysFrom, daysTo, sender }) {
const [rows] = await pool.query(
`SELECT id, name, kontakt_email, slug, demo_expires_at
FROM tenants
WHERE typ = 'demo'
AND kontakt_email IS NOT NULL
AND ${flagColumn} IS NULL
AND demo_expires_at IS NOT NULL
AND demo_expires_at BETWEEN DATE_ADD(NOW(), INTERVAL ? DAY)
AND DATE_ADD(NOW(), INTERVAL ? DAY)`,
[daysFrom, daysTo]
);
for (const t of rows) {
try {
await sender(t);
await pool.query(`UPDATE tenants SET ${flagColumn} = NOW() WHERE id = ?`, [t.id]);
console.log(`[expiry] ${flagColumn} -> ${t.kontakt_email} (slug=${t.slug})`);
} catch (e) {
console.error(`[expiry] Mail-Fehler ${flagColumn} fuer ${t.kontakt_email}:`, e.message);
}
}
}
async function processExpiredToday() {
const [rows] = await pool.query(
`SELECT id, name, kontakt_email, slug, demo_expires_at
FROM tenants
WHERE typ = 'demo'
AND kontakt_email IS NOT NULL
AND notified_expired_at IS NULL
AND demo_expires_at IS NOT NULL
AND demo_expires_at <= NOW()`
);
for (const t of rows) {
try {
await sendDemoExpiredMail({ to: t.kontakt_email, name: t.name });
await pool.query(
`UPDATE tenants SET notified_expired_at = NOW(),
status = 'abgelaufen'
WHERE id = ?`,
[t.id]
);
console.log(`[expiry] expired-mail -> ${t.kontakt_email} (slug=${t.slug})`);
} catch (e) {
console.error(`[expiry] expired-Mail-Fehler ${t.kontakt_email}:`, e.message);
}
}
}
export async function runExpiryCheck() {
console.log('[expiry] check start', new Date().toISOString());
// 7-Tage-Erinnerung: Ablauf in [6, 7] Tagen
await processWindow({
flagColumn: 'notified_7d_at',
daysFrom: 6, daysTo: 7,
sender: (t) => sendDemoExpiryReminderMail({
to: t.kontakt_email, name: t.name, slug: t.slug,
daysLeft: 7, expiresAt: t.demo_expires_at,
}),
});
// 1-Tag-Erinnerung: Ablauf in [0, 1] Tagen (aber noch nicht abgelaufen)
await processWindow({
flagColumn: 'notified_1d_at',
daysFrom: 0, daysTo: 1,
sender: (t) => sendDemoLastDayMail({
to: t.kontakt_email, name: t.name, slug: t.slug,
expiresAt: t.demo_expires_at,
}),
});
// Heute (oder bereits) abgelaufen
await processExpiredToday();
console.log('[expiry] check done');
}
export function startExpiryCron() {
// Taeglich um 08:00 Uhr (Server-Zeit = UTC). Anpassbar via env CRON_EXPIRY_AT
const expr = process.env.CRON_EXPIRY_AT || '0 8 * * *';
cron.schedule(expr, () => {
runExpiryCheck().catch(e => console.error('[expiry] cron error:', e));
});
console.log('[expiry] cron registriert:', expr);
}