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:
parent
a129781035
commit
4e680a36e7
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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:**
|
||||
|
|
|
|||
Loading…
Reference in New Issue