0764bc6ddf
Die drei letzten wichtigen Features für ein produktionsreifes 1.0.0: ## 1. Passwort vergessen-Flow Der klassische Selfservice-Reset per Email – sowohl für Mitarbeiter als auch für Portal-Kunden. User können sich nicht mehr aussperren, Admin muss nicht mehr manuell eingreifen. - Neues Link "Passwort vergessen?" auf Login-Seite - PasswordResetRequest: Email + Typ-Auswahl (Mitarbeiter / Portal) - PasswordResetConfirm: Token-basierte Bestätigung + neues Passwort (min 6 Zeichen) - Token ist 2 Stunden gültig, dann muss neu angefordert werden - Token ist kryptografisch sicher (crypto.randomBytes(32)) - User-Enumeration-Schutz: Backend gibt immer 200 zurück, egal ob Email existiert - Nach erfolgreichem Reset werden ALLE bestehenden Sessions gekickt (tokenInvalidatedAt gesetzt) – falls jemand parallel eingeloggt war DB: - User.passwordResetToken + passwordResetExpiresAt - Customer.portalPasswordResetToken + portalPasswordResetExpiresAt ## 2. Rate-Limiting gegen Brute-Force Mit express-rate-limit: - Login: 10 Versuche pro 15 Minuten pro IP. Erfolgreiche zählen nicht mit. - Passwort-Reset-Request: 5 Versuche pro Stunde pro IP (Mail-Flut verhindern) Sowohl Mitarbeiter-Login als auch Portal-Login geschützt. ## 3. Auto-Geburtstagsgrüße per Cron Das autoBirthdayGreeting-Flag hatten wir schon, aber kein Scheduler der ihn wirklich abschickt. Jetzt: - Läuft täglich um 08:00 Uhr - Findet Kunden mit heutigem Geburtstag + autoBirthdayGreeting=true - Nur Email-Kanal (Messenger brauchen Browser-Klick) - Catch-up 30s nach Server-Start: wenn Server am Geburtstag down war, wird beim nächsten Boot nachgeholt - lastBirthdayGreetingYear verhindert Doppelversand Dependencies: node-cron, @types/node-cron, express-rate-limit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
170 lines
5.3 KiB
TypeScript
170 lines
5.3 KiB
TypeScript
/**
|
||
* Scheduler für automatische Geburtstagsgrüße.
|
||
*
|
||
* Läuft täglich um 08:00 Uhr und sendet Grüße an alle Kunden mit:
|
||
* - Geburtstag = heute
|
||
* - autoBirthdayGreeting = true
|
||
* - autoBirthdayChannel ist gesetzt (aktuell nur 'email' automatisiert)
|
||
* - lastBirthdayGreetingYear != aktuelles Jahr (verhindert Doppel-Versand)
|
||
*/
|
||
import cron from 'node-cron';
|
||
import prisma from '../lib/prisma.js';
|
||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||
import * as birthdayService from './birthday.service.js';
|
||
|
||
async function runDailyBirthdayGreetings(): Promise<void> {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const thisYear = today.getFullYear();
|
||
const month = today.getMonth() + 1; // Prisma-Raw-SQL ist 1-indexed
|
||
const day = today.getDate();
|
||
|
||
console.log(
|
||
`[BirthdayScheduler] Suche Kunden mit Geburtstag ${day}.${month}., Auto-Versand aktiv …`,
|
||
);
|
||
|
||
// Kunden mit heutigem Geburtstag + Auto-Versand + dieses Jahr noch nicht gesendet
|
||
const candidates = await prisma.$queryRaw<
|
||
Array<{
|
||
id: number;
|
||
firstName: string;
|
||
lastName: string;
|
||
email: string | null;
|
||
salutation: string | null;
|
||
useInformalAddress: boolean;
|
||
birthDate: Date;
|
||
autoBirthdayChannel: string | null;
|
||
}>
|
||
>`
|
||
SELECT id, firstName, lastName, email, salutation, useInformalAddress, birthDate, autoBirthdayChannel
|
||
FROM Customer
|
||
WHERE autoBirthdayGreeting = 1
|
||
AND birthDate IS NOT NULL
|
||
AND MONTH(birthDate) = ${month}
|
||
AND DAY(birthDate) = ${day}
|
||
AND (lastBirthdayGreetingYear IS NULL OR lastBirthdayGreetingYear != ${thisYear})
|
||
`;
|
||
|
||
if (candidates.length === 0) {
|
||
console.log('[BirthdayScheduler] Keine passenden Kunden heute.');
|
||
return;
|
||
}
|
||
|
||
console.log(`[BirthdayScheduler] ${candidates.length} Kunde(n) gefunden – sende Grüße.`);
|
||
|
||
// System-E-Mail-Credentials einmal laden
|
||
const systemEmail = await getSystemEmailCredentials();
|
||
if (!systemEmail) {
|
||
console.error(
|
||
'[BirthdayScheduler] Keine System-E-Mail konfiguriert – kann keine Grüße versenden.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
const smtpCreds: SmtpCredentials = {
|
||
host: systemEmail.smtpServer,
|
||
port: systemEmail.smtpPort,
|
||
user: systemEmail.emailAddress,
|
||
password: systemEmail.password,
|
||
encryption: systemEmail.smtpEncryption,
|
||
allowSelfSignedCerts: systemEmail.allowSelfSignedCerts,
|
||
};
|
||
|
||
let sent = 0;
|
||
let skipped = 0;
|
||
|
||
for (const c of candidates) {
|
||
const channel = c.autoBirthdayChannel || 'email';
|
||
|
||
// Aktuell nur Email automatisch – Messenger brauchen Browser-Klick
|
||
if (channel !== 'email') {
|
||
console.log(
|
||
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): Kanal "${channel}" nicht automatisierbar, übersprungen.`,
|
||
);
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
if (!c.email) {
|
||
console.log(
|
||
`[BirthdayScheduler] Kunde #${c.id} (${c.firstName} ${c.lastName}): keine E-Mail hinterlegt, übersprungen.`,
|
||
);
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
const age = thisYear - new Date(c.birthDate).getFullYear();
|
||
const { subject, html } = birthdayService.buildBirthdayGreetingText(
|
||
{
|
||
firstName: c.firstName,
|
||
lastName: c.lastName,
|
||
salutation: c.salutation,
|
||
useInformalAddress: c.useInformalAddress,
|
||
},
|
||
age,
|
||
);
|
||
|
||
try {
|
||
const result = await sendEmail(
|
||
smtpCreds,
|
||
systemEmail.emailAddress,
|
||
{ to: c.email, subject, html },
|
||
{ context: 'birthday-greeting-auto', customerId: c.id, triggeredBy: 'cron' },
|
||
);
|
||
|
||
if (result.success) {
|
||
// Marker setzen damit nächstes Jahr wieder läuft, dieses Jahr aber nicht nochmal
|
||
await prisma.customer.update({
|
||
where: { id: c.id },
|
||
data: { lastBirthdayGreetingYear: thisYear },
|
||
});
|
||
sent++;
|
||
console.log(
|
||
`[BirthdayScheduler] ✓ Kunde #${c.id} (${c.firstName} ${c.lastName}): Gruß gesendet.`,
|
||
);
|
||
} else {
|
||
console.error(
|
||
`[BirthdayScheduler] ✗ Kunde #${c.id}: Sendfehler: ${result.error}`,
|
||
);
|
||
skipped++;
|
||
}
|
||
} catch (err) {
|
||
console.error(`[BirthdayScheduler] ✗ Kunde #${c.id}: Exception:`, err);
|
||
skipped++;
|
||
}
|
||
}
|
||
|
||
console.log(
|
||
`[BirthdayScheduler] Fertig: ${sent} versendet, ${skipped} übersprungen von ${candidates.length} Kandidaten.`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Scheduler starten. Läuft täglich um 08:00 in lokaler Server-Zeit.
|
||
* Zusätzlich: ein Test-Lauf 30 Sekunden nach Server-Start, aber nur wenn heute schon jemand Geburtstag hat
|
||
* (sonst passiert eh nichts). So können wir bei Ausfall am Tag X direkt beim nächsten Boot nachholen.
|
||
*/
|
||
export function startBirthdayScheduler(): void {
|
||
// Täglich um 08:00
|
||
cron.schedule('0 8 * * *', () => {
|
||
runDailyBirthdayGreetings().catch((err) =>
|
||
console.error('[BirthdayScheduler] Daily run failed:', err),
|
||
);
|
||
});
|
||
|
||
// Einmal 30 Sekunden nach Start (Catch-up bei Ausfall)
|
||
setTimeout(() => {
|
||
runDailyBirthdayGreetings().catch((err) =>
|
||
console.error('[BirthdayScheduler] Catch-up run failed:', err),
|
||
);
|
||
}, 30_000);
|
||
|
||
console.log('[BirthdayScheduler] Gestartet – täglich um 08:00 + Catch-up nach 30s');
|
||
}
|
||
|
||
/**
|
||
* Für manuelles Triggern (z.B. aus Debug-Endpoint).
|
||
*/
|
||
export { runDailyBirthdayGreetings };
|