Files
opencrm/backend/src/services/birthdayScheduler.service.ts
T
duffyduck 0764bc6ddf Version 1.0.0: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße
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>
2026-04-23 17:14:27 +02:00

170 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };