Auto-Vertragsstatus: nightly EXPIRED + Kündigungsbestätigung → CANCELLED

- Neuer Scheduler (02:00 + Catch-up 60s nach Start): alle ACTIVE-Verträge mit
  endDate < heute werden auf EXPIRED umgestellt. Audit-Log pro Vertrag.
- Upload cancellationConfirmationPath: Vertrag wechselt von ACTIVE → CANCELLED
  (mit Audit-Log). "Options"-Upload triggert bewusst nicht, da für
  Vertragsänderungen gedacht, nicht für echte Kündigungen.
- Keine neuen Statuswerte. "Kündigung gesendet vs. bestätigt" bleibt über die
  getrennten Felder cancellationSentDate / cancellationConfirmationDate lesbar,
  Status bleibt bis zur Bestätigung auf ACTIVE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck 2026-04-24 10:08:58 +02:00
parent a129781035
commit 4e680a36e7
4 changed files with 117 additions and 0 deletions

View File

@ -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();
});

View File

@ -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: {

View File

@ -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<void> {
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 };

View File

@ -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:**