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 birthdayRoutes from './routes/birthday.routes.js';
|
||||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||||
|
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
|
|
||||||
|
|
@ -172,4 +173,5 @@ app.listen(PORT, () => {
|
||||||
console.log(`Server läuft auf Port ${PORT}`);
|
console.log(`Server läuft auf Port ${PORT}`);
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
startBirthdayScheduler();
|
startBirthdayScheduler();
|
||||||
|
startContractStatusScheduler();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,25 @@ async function handleContractDocumentUpload(
|
||||||
data: { [fieldName]: relativePath },
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
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
|
## ✅ 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)**
|
- [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)**
|
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
|
||||||
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue