feat: add escalation policy engine module with related models and logic
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
2 files changed
+428
-2
No files matched your search
+141
-2
@@ -136,6 +136,10 @@ model School {
|
|||||||
dashboardWidgets DashboardWidget[]
|
dashboardWidgets DashboardWidget[]
|
||||||
dashboardLayouts DashboardLayout[]
|
dashboardLayouts DashboardLayout[]
|
||||||
widgetTemplates WidgetTemplate[]
|
widgetTemplates WidgetTemplate[]
|
||||||
|
// Module C escalation relations
|
||||||
|
escalationPolicies EscalationPolicy[]
|
||||||
|
escalations Escalation[]
|
||||||
|
escalationMessageTemplates EscalationMessageTemplate[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -241,6 +245,9 @@ model User {
|
|||||||
// Module J widgets relations
|
// Module J widgets relations
|
||||||
dashboardWidgets DashboardWidget[]
|
dashboardWidgets DashboardWidget[]
|
||||||
dashboardLayouts DashboardLayout[]
|
dashboardLayouts DashboardLayout[]
|
||||||
|
// Module C escalation relations
|
||||||
|
escalationsInitiated Escalation[] @relation("EscalationInitiator")
|
||||||
|
escalationLogsPerformed EscalationLog[] @relation("EscalationLogPerformer")
|
||||||
}
|
}
|
||||||
|
|
||||||
model SchoolYear {
|
model SchoolYear {
|
||||||
@@ -383,7 +390,7 @@ model Enrollment {
|
|||||||
schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id])
|
schoolYear SchoolYear @relation(fields: [schoolYearId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Subject & Competency Structure ────────────────────────────────
|
// ─── Subject & Competency Structure ─────────────────────────────��──
|
||||||
|
|
||||||
model Subject {
|
model Subject {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -1240,7 +1247,7 @@ model Backup {
|
|||||||
@@map("backups")
|
@@map("backups")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Behavior Interventions ─────────────────────────────────────────
|
// ──�� Behavior Interventions ─────────────────────────────────────────
|
||||||
|
|
||||||
model BehaviorIntervention {
|
model BehaviorIntervention {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -3330,6 +3337,138 @@ model SchoolComplianceStatus {
|
|||||||
school School @relation(fields: [schoolId], references: [id])
|
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 {
|
model DeletionFlag {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
schoolId String
|
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