diff --git a/backend/src/index.ts b/backend/src/index.ts index f256f80f..5ddde209 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import pdfTemplateRoutes from './routes/pdfTemplate.routes.js'; import birthdayRoutes from './routes/birthday.routes.js'; import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js'; import { startBirthdayScheduler } from './services/birthdayScheduler.service.js'; +import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; @@ -172,4 +173,5 @@ app.listen(PORT, () => { console.log(`Server läuft auf Port ${PORT}`); // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten startBirthdayScheduler(); + startContractStatusScheduler(); }); diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts index dcee33b6..2914eeb2 100644 --- a/backend/src/routes/upload.routes.ts +++ b/backend/src/routes/upload.routes.ts @@ -569,6 +569,25 @@ async function handleContractDocumentUpload( data: { [fieldName]: relativePath }, }); + // Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und + // der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log. + // "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen. + if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') { + await prisma.contract.update({ + where: { id: contractId }, + data: { status: 'CANCELLED' }, + }); + await logChange({ + req, + action: 'UPDATE', + resourceType: 'Contract', + resourceId: contractId.toString(), + label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`, + details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' }, + customerId: contract.customerId, + }); + } + res.json({ success: true, data: { diff --git a/backend/src/services/contractStatusScheduler.service.ts b/backend/src/services/contractStatusScheduler.service.ts new file mode 100644 index 00000000..1b8082ce --- /dev/null +++ b/backend/src/services/contractStatusScheduler.service.ts @@ -0,0 +1,85 @@ +/** + * Scheduler für automatische Vertrags-Status-Übergänge. + * + * Einmal täglich um 02:00: alle Verträge mit status=ACTIVE und + * endDate < heute werden auf EXPIRED umgestellt (+ Audit-Log). + * + * Läuft zusätzlich 60 Sekunden nach Server-Start als Catch-up falls + * der Prozess zum 02:00-Slot neu gestartet wurde. + */ +import cron from 'node-cron'; +import prisma from '../lib/prisma.js'; +import { createAuditLog } from './audit.service.js'; + +async function runExpireCheck(): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const expiring = await prisma.contract.findMany({ + where: { + status: 'ACTIVE', + endDate: { not: null, lt: today }, + }, + select: { + id: true, + contractNumber: true, + customerId: true, + endDate: true, + }, + }); + + if (expiring.length === 0) { + console.log('[ContractStatusScheduler] Keine abgelaufenen Verträge.'); + return; + } + + console.log(`[ContractStatusScheduler] ${expiring.length} Vertrag/Verträge auf EXPIRED setzen.`); + + for (const c of expiring) { + try { + await prisma.contract.update({ + where: { id: c.id }, + data: { status: 'EXPIRED' }, + }); + + await createAuditLog({ + userEmail: 'system', + userRole: 'System', + action: 'UPDATE', + resourceType: 'Contract', + resourceId: c.id.toString(), + resourceLabel: `Vertrag ${c.contractNumber} automatisch auf EXPIRED gesetzt (Laufzeit überschritten)`, + endpoint: 'scheduler:contract-status', + httpMethod: 'SYSTEM', + ipAddress: 'localhost', + dataSubjectId: c.customerId, + changesBefore: { status: 'ACTIVE' }, + changesAfter: { status: 'EXPIRED', endDate: c.endDate?.toISOString() }, + }); + } catch (err) { + console.error(`[ContractStatusScheduler] Fehler bei Vertrag #${c.id}:`, err); + } + } + + console.log('[ContractStatusScheduler] Fertig.'); +} + +export function startContractStatusScheduler(): void { + // Täglich um 02:00 Uhr (Server-Zeit) + cron.schedule('0 2 * * *', () => { + runExpireCheck().catch((err) => + console.error('[ContractStatusScheduler] Daily run failed:', err), + ); + }); + + // Catch-up 60 Sekunden nach Start + setTimeout(() => { + runExpireCheck().catch((err) => + console.error('[ContractStatusScheduler] Catch-up run failed:', err), + ); + }, 60_000); + + console.log('[ContractStatusScheduler] Gestartet – täglich um 02:00 + Catch-up nach 60s'); +} + +export { runExpireCheck }; diff --git a/backend/todo.md b/backend/todo.md index b0772cae..16c37a03 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -97,6 +97,17 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔄 Automatische Vertrags-Status-Übergänge** + - Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit + `status=ACTIVE` und `endDate < heute` → `EXPIRED` (mit Audit-Log). + - Beim Upload der Kündigungsbestätigung (`cancellationConfirmationPath`): + wenn Vertrag aktuell `ACTIVE` → auf `CANCELLED` setzen (Audit-Log). + Der "Optionen"-Upload löst den Wechsel bewusst NICHT aus, da er für + Vertragsänderungen (nicht echte Kündigungen) gedacht ist. + - Keine neuen Status eingeführt: `cancellationSentDate` vs. + `cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt" + abzubilden. `ACTIVE` bleibt bis zur Bestätigung. + - [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)** - Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)** - **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**