feat: add escalation policy engine module with related models and logic #4
No files matched your search
+141
-2
@@ -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
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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,
|
||||
};
|
||||
Reference in new issue
Block a user