From 6652dc6b9601ebdb64b6b1742bbeb73860ff66fe Mon Sep 17 00:00:00 2001 From: v0 Date: Sat, 1 Aug 2026 10:00:13 +0000 Subject: [PATCH] feat: add escalation policy engine module with related models and logic Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com> --- prisma/schema.prisma | 143 ++++++++++++++++- src/lib/escalation-engine.ts | 287 +++++++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 src/lib/escalation-engine.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d729cad..76860b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,6 +136,10 @@ model School { dashboardWidgets DashboardWidget[] dashboardLayouts DashboardLayout[] widgetTemplates WidgetTemplate[] + // Module C escalation relations + escalationPolicies EscalationPolicy[] + escalations Escalation[] + escalationMessageTemplates EscalationMessageTemplate[] } model User { @@ -241,6 +245,9 @@ model User { // Module J widgets relations dashboardWidgets DashboardWidget[] dashboardLayouts DashboardLayout[] + // Module C escalation relations + escalationsInitiated Escalation[] @relation("EscalationInitiator") + escalationLogsPerformed EscalationLog[] @relation("EscalationLogPerformer") } model SchoolYear { @@ -383,7 +390,7 @@ model Enrollment { schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id]) } -// ─── Subject & Competency Structure ──────────────────────────────── +// ─── Subject & Competency Structure ─────────────────────────────��── model Subject { id String @id @default(cuid()) @@ -1240,7 +1247,7 @@ model Backup { @@map("backups") } -// ─── Behavior Interventions ───────────────────────────────────────── +// ──�� Behavior Interventions ───────────────────────────────────────── model BehaviorIntervention { id String @id @default(cuid()) @@ -3330,6 +3337,138 @@ model SchoolComplianceStatus { school School @relation(fields: [schoolId], references: [id]) } +// ─── Module C: Escalation Policy Engine ───────────────────────────── + +model EscalationPolicy { + id String @id @default(cuid()) + schoolId String + name String // "Verhaltensauffälligkeiten", "Noten-Bedenken", etc. + description String? + triggerType String // BEHAVIOR_INCIDENT, GRADE_CONCERN, ATTENDANCE, WELLNESS, CUSTOM + severity String // MINOR, MODERATE, MAJOR + isActive Boolean @default(true) + + // Business days calculation + businessDaysUntilEscalation Int @default(5) // e.g., 5 business days until parent can escalate + timezone String @default("Europe/Berlin") + + // Escalation chain configuration + escalationSteps EscalationStep[] + + // Audit + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + escalations Escalation[] + + @@unique([schoolId, name]) + @@index([schoolId]) +} + +model EscalationStep { + id String @id @default(cuid()) + policyId String + stepNumber Int // 1, 2, 3, etc. + recipientRole String // TEACHER, CLASS_LEADER, VICE_PRINCIPAL, PRINCIPAL, COUNSELOR, SCHOOL_ADMIN + requiresConfirmation Boolean @default(true) + autoNotify Boolean @default(true) + messageTemplate String? // Reason why escalation necessary + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + policy EscalationPolicy @relation(fields: [policyId], references: [id], onDelete: Cascade) + escalationLogs EscalationLog[] + + @@unique([policyId, stepNumber]) + @@index([policyId]) +} + +model Escalation { + id String @id @default(cuid()) + schoolId String + policyId String + initiatedByUserId String + initiatorRole String // student, parent, teacher + subjectUserId String // Student being escalated + subjectType String // STUDENT, BEHAVIOR, GRADE, ATTENDANCE, WELLNESS + subjectId String // ID of the specific issue (incidentId, gradeId, etc.) + + // Escalation state + status String @default("PENDING") // PENDING, IN_REVIEW, ESCALATED, RESOLVED, CLOSED + currentStep Int @default(1) + eligibleAtDateTime DateTime // Calculated by business-day logic + + // Context + reason String // Why escalation was initiated + attachmentUrls String? // JSON array of file URLs + + // Audit + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resolvedAt DateTime? + + school School @relation(fields: [schoolId], references: [id]) + policy EscalationPolicy @relation(fields: [policyId], references: [id]) + initiatedBy User @relation("EscalationInitiator", fields: [initiatedByUserId], references: [id]) + subjectUser Student @relation(fields: [subjectUserId], references: [id]) + logs EscalationLog[] + + @@index([schoolId]) + @@index([policyId]) + @@index([subjectUserId]) + @@index([status]) + @@index([createdAt]) +} + +model EscalationLog { + id String @id @default(cuid()) + escalationId String + stepId String + actionType String // INITIATED, NOTIFIED, CONFIRMED, ESCALATED_TO_NEXT_STEP, RESOLVED + performedByUserId String? + actionReason String? + confidentialNote String? // Only visible to authorized users + + createdAt DateTime @default(now()) + + escalation Escalation @relation(fields: [escalationId], references: [id], onDelete: Cascade) + step EscalationStep @relation(fields: [stepId], references: [id]) + performedBy User? @relation("EscalationLogPerformer", fields: [performedByUserId], references: [id]) + + @@index([escalationId]) + @@index([performedByUserId]) + @@index([createdAt]) +} + +model EscalationMessageTemplate { + id String @id @default(cuid()) + schoolId String + triggerType String // BEHAVIOR_INCIDENT, GRADE_CONCERN, ATTENDANCE, WELLNESS + severity String // MINOR, MODERATE, MAJOR + stepNumber Int // At which step this message is sent + + // Template content + titleDe String // German + titleEn String? // English (optional) + bodyDe String + bodyEn String? + + // Personalization placeholders: {{studentName}}, {{incidentDate}}, {{reason}}, {{nextStepRole}} + + isDefault Boolean @default(false) + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + school School @relation(fields: [schoolId], references: [id]) + + @@unique([schoolId, triggerType, severity, stepNumber]) + @@index([schoolId]) +} + model DeletionFlag { id String @id @default(cuid()) schoolId String diff --git a/src/lib/escalation-engine.ts b/src/lib/escalation-engine.ts new file mode 100644 index 0000000..ff1cde9 --- /dev/null +++ b/src/lib/escalation-engine.ts @@ -0,0 +1,287 @@ +/** + * Escalation Policy Engine + * Handles business-day calculations, escalation eligibility, and workflow management + * Module C: Communication & Escalation + */ + +import { + addDays, + isWeekend, + differenceInCalendarDays, + setHours, + setMinutes, + isBefore, + isAfter, + parseISO, +} from 'date-fns'; +import { de as deLocale } from 'date-fns/locale'; + +export interface SchoolDayConfig { + timezone: string; + publicHolidays: Date[]; // German public holidays for the year + schoolHolidays: { start: Date; end: Date }[]; // Specific school holidays + schoolDayStartTime: string; // "08:00" + schoolDayEndTime: string; // "16:00" + businessDaysPerWeek: number; // Usually 5 +} + +export interface EscalationPolicyRule { + triggerType: string; // BEHAVIOR_INCIDENT, GRADE_CONCERN, ATTENDANCE, WELLNESS + severity: string; // MINOR, MODERATE, MAJOR + businessDaysUntilEscalation: number; + escalationChain: { + stepNumber: number; + recipientRole: string; // TEACHER, CLASS_LEADER, VICE_PRINCIPAL, PRINCIPAL, COUNSELOR + requiresConfirmation: boolean; + autoNotify: boolean; + }[]; +} + +/** + * Calculate business days (excluding weekends, public holidays, school holidays) + */ +export function getNextBusinessDay( + fromDate: Date, + config: SchoolDayConfig, + daysToAdd: number = 1 +): Date { + let currentDate = new Date(fromDate); + let businessDaysAdded = 0; + + while (businessDaysAdded < daysToAdd) { + currentDate = addDays(currentDate, 1); + + // Skip weekends + if (isWeekend(currentDate, { locale: deLocale })) { + continue; + } + + // Skip public holidays (German holidays) + if (isGermanPublicHoliday(currentDate)) { + continue; + } + + // Skip school holidays + if (isSchoolHoliday(currentDate, config)) { + continue; + } + + businessDaysAdded++; + } + + // Set to end of school day (e.g., 16:00) + const [hours, minutes] = config.schoolDayEndTime.split(':').map(Number); + return setMinutes(setHours(currentDate, hours), minutes); +} + +/** + * Calculate business days between two dates + */ +export function calculateBusinessDaysBetween( + startDate: Date, + endDate: Date, + config: SchoolDayConfig +): number { + let businessDays = 0; + let currentDate = new Date(startDate); + + while (isBefore(currentDate, endDate)) { + // Skip weekends + if (!isWeekend(currentDate, { locale: deLocale })) { + // Skip public holidays + if (!isGermanPublicHoliday(currentDate)) { + // Skip school holidays + if (!isSchoolHoliday(currentDate, config)) { + businessDays++; + } + } + } + + currentDate = addDays(currentDate, 1); + } + + return businessDays; +} + +/** + * Check if a date is a German public holiday + */ +function isGermanPublicHoliday(date: Date): boolean { + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear(); + + // German public holidays (simplified, doesn't account for Easter-dependent dates) + const publicHolidays = [ + { month: 1, day: 1 }, // Neujahrstag + { month: 3, day: 8 }, // Internationaler Frauentag (varies by state) + { month: 5, day: 1 }, // Tag der Arbeit + { month: 5, day: 9 }, // Europatag (varies) + { month: 10, day: 3 }, // Tag der Deutschen Einheit + { month: 10, day: 31 }, // Reformationstag (varies by state) + { month: 11, day: 1 }, // Allerheiligen (varies by state) + { month: 12, day: 25 }, // Weihnachtstag + { month: 12, day: 26 }, // Zweiter Weihnachtstag + ]; + + return publicHolidays.some((h) => h.month === month && h.day === day); +} + +/** + * Check if a date falls within school holidays + */ +function isSchoolHoliday(date: Date, config: SchoolDayConfig): boolean { + return config.schoolHolidays.some((holiday) => { + return isBefore(date, holiday.end) && isAfter(date, holiday.start); + }); +} + +/** + * Determine if an escalation can be initiated + */ +export function canInitiateEscalation( + escalationCreatedAt: Date, + eligibleAtDateTime: Date +): boolean { + return isBefore(new Date(), eligibleAtDateTime); +} + +/** + * Check if user has permission to escalate at current step + */ +export function canEscalateToNextStep( + currentStep: number, + totalSteps: number, + userRole: string, + escalationPolicy: { escalationSteps: { recipientRole: string }[] } +): boolean { + if (currentStep >= totalSteps) { + return false; // Already at final step + } + + const nextStep = escalationPolicy.escalationSteps[currentStep]; + return userRole === nextStep.recipientRole || userRole === 'SCHOOL_ADMIN'; +} + +/** + * Format escalation eligibility message + */ +export function formatEscalationEligibility( + eligibleAtDateTime: Date, + now: Date = new Date(), + locale: 'de' | 'en' = 'de' +): string { + const businessDaysRemaining = calculateBusinessDaysBetween(now, eligibleAtDateTime, { + timezone: 'Europe/Berlin', + publicHolidays: [], + schoolHolidays: [], + schoolDayStartTime: '08:00', + schoolDayEndTime: '16:00', + businessDaysPerWeek: 5, + }); + + if (locale === 'de') { + if (businessDaysRemaining <= 0) { + return 'Sie können jetzt eskalieren.'; + } + return `Sie können in ${businessDaysRemaining} Geschäftstag(en) eskalieren.`; + } else { + if (businessDaysRemaining <= 0) { + return 'You can escalate now.'; + } + return `You can escalate in ${businessDaysRemaining} business day(s).`; + } +} + +/** + * Get escalation severity level + */ +export function getEscalationSeverity( + triggerType: string, + incident?: { severity?: string } +): string { + if (incident?.severity) { + return incident.severity; // MINOR, MODERATE, MAJOR + } + + // Default severity based on trigger type + const severityMap: Record = { + BEHAVIOR_INCIDENT: 'MODERATE', + GRADE_CONCERN: 'MINOR', + ATTENDANCE: 'MODERATE', + WELLNESS: 'MAJOR', + CUSTOM: 'MINOR', + }; + + return severityMap[triggerType] || 'MINOR'; +} + +/** + * Validate escalation request + */ +export function validateEscalationRequest( + initiatorRole: string, + escalationPolicy: any, + currentStep: number +): { valid: boolean; error?: string } { + // Students can only initiate escalations if policy allows + if (initiatorRole === 'STUDENT' && !escalationPolicy.allowStudentInitiation) { + return { + valid: false, + error: 'Students cannot initiate escalations for this policy.', + }; + } + + // Parents can only escalate after specific step + if (initiatorRole === 'PARENT' && currentStep < (escalationPolicy.parentCanEscalateAtStep || 1)) { + return { + valid: false, + error: 'Parents cannot escalate at this stage.', + }; + } + + return { valid: true }; +} + +/** + * Get next recipient in escalation chain + */ +export function getNextRecipientRole( + escalationPolicy: any, + currentStep: number +): string | null { + const nextStep = escalationPolicy.escalationSteps[currentStep]; + return nextStep?.recipientRole || null; +} + +/** + * Create audit trail entry for escalation action + */ +export function createEscalationAuditEntry( + escalationId: string, + actionType: string, + performedByUserId: string | null, + actionReason: string, + confidentialNote?: string +) { + return { + escalationId, + actionType, // INITIATED, NOTIFIED, CONFIRMED, ESCALATED_TO_NEXT_STEP, RESOLVED + performedByUserId, + actionReason, + confidentialNote, + createdAt: new Date(), + }; +} + +export default { + getNextBusinessDay, + calculateBusinessDaysBetween, + canInitiateEscalation, + canEscalateToNextStep, + formatEscalationEligibility, + getEscalationSeverity, + validateEscalationRequest, + getNextRecipientRole, + createEscalationAuditEntry, +};