import { VacationStatus } from "@planarchy/db"; import { createNotification } from "./create-notification.js"; type DbClient = { vacation: { findUnique: (args: { where: { id: string }; select: { id: true; resourceId: true; startDate: true; endDate: true; resource: { select: { chapter: true; displayName: true } }; }; }) => Promise<{ id: string; resourceId: string; startDate: Date; endDate: Date; resource: { chapter: string | null; displayName: string } | null; } | null>; findMany: (args: { where: { resource: { chapter: string }; resourceId: { not: string }; status: { in: string[] }; startDate: { lte: Date }; endDate: { gte: Date }; }; select: { id: true; resourceId: true; startDate: true; endDate: true; resource: { select: { displayName: true } }; }; }) => Promise< Array<{ id: string; resourceId: string; startDate: Date; endDate: Date; resource: { displayName: string } | null; }> >; }; resource: { count: (args: { where: { chapter: string; isActive: true }; }) => Promise; }; notification: { create: (args: { data: { userId: string; type: string; category: string; priority: string; title: string; body: string; entityId: string; entityType: string; link: string; channel: string; }; }) => Promise<{ id: string; userId: string }>; }; }; /** Threshold: warn when more than 50% of a chapter is absent on any single day */ const OVERLAP_THRESHOLD = 0.5; export interface VacationConflictResult { warnings: string[]; } /** * Check if approving a vacation would cause >50% of a chapter to be absent * on any single day within the vacation period. * * Returns a list of warning strings (empty if no conflicts). * Does NOT block the approval — warnings are advisory only. */ export async function checkVacationConflicts( db: DbClient, vacationId: string, approverUserId?: string, ): Promise { const warnings: string[] = []; const vacation = await db.vacation.findUnique({ where: { id: vacationId }, select: { id: true, resourceId: true, startDate: true, endDate: true, resource: { select: { chapter: true, displayName: true } }, }, }); if (!vacation?.resource?.chapter) { return { warnings }; } const chapter = vacation.resource.chapter; // Count active resources in the same chapter const totalInChapter = await db.resource.count({ where: { chapter, isActive: true }, }); if (totalInChapter <= 1) { return { warnings }; } // Find overlapping approved/pending vacations from other resources in the same chapter const overlapping = await db.vacation.findMany({ where: { resource: { chapter }, resourceId: { not: vacation.resourceId }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, startDate: { lte: vacation.endDate }, endDate: { gte: vacation.startDate }, }, select: { id: true, resourceId: true, startDate: true, endDate: true, resource: { select: { displayName: true } }, }, }); if (overlapping.length === 0) { return { warnings }; } // Check each day of the vacation to find the worst overlap const start = new Date(vacation.startDate); start.setUTCHours(0, 0, 0, 0); const end = new Date(vacation.endDate); end.setUTCHours(0, 0, 0, 0); let worstDay: string | null = null; let worstCount = 0; const cursor = new Date(start); while (cursor <= end) { // Skip weekends const dow = cursor.getUTCDay(); if (dow === 0 || dow === 6) { cursor.setUTCDate(cursor.getUTCDate() + 1); continue; } // Count unique resources absent on this day (excluding the current resource) const absentResourceIds = new Set(); for (const ov of overlapping) { const ovStart = new Date(ov.startDate); ovStart.setUTCHours(0, 0, 0, 0); const ovEnd = new Date(ov.endDate); ovEnd.setUTCHours(0, 0, 0, 0); if (cursor >= ovStart && cursor <= ovEnd) { absentResourceIds.add(ov.resourceId); } } // +1 because the resource being approved would also be absent const totalAbsent = absentResourceIds.size + 1; if (totalAbsent > worstCount) { worstCount = totalAbsent; worstDay = cursor.toISOString().slice(0, 10); } cursor.setUTCDate(cursor.getUTCDate() + 1); } if (worstCount > 0 && worstCount / totalInChapter > OVERLAP_THRESHOLD) { const pct = Math.round((worstCount / totalInChapter) * 100); const absentNames = overlapping .map((ov) => ov.resource?.displayName ?? "Unknown") .slice(0, 5); const nameList = absentNames.join(", "); const suffix = overlapping.length > 5 ? ` and ${overlapping.length - 5} more` : ""; const warning = `High absence in chapter "${chapter}" on ${worstDay}: ${worstCount}/${totalInChapter} resources (${pct}%) would be absent. Also off: ${nameList}${suffix}`; warnings.push(warning); // Create a notification for the approver if provided if (approverUserId) { await createNotification({ db, userId: approverUserId, type: "VACATION_CONFLICT_WARNING", category: "NOTIFICATION", priority: "HIGH", title: `Vacation conflict warning: ${vacation.resource.displayName}`, body: warning, entityId: vacationId, entityType: "vacation", link: "/vacations", channel: "in_app", }); } } return { warnings }; } /** * Check conflicts for multiple vacations at once (used by batchApprove). * Returns a map of vacationId -> warnings. */ export async function checkBatchVacationConflicts( db: DbClient, vacationIds: string[], approverUserId?: string, ): Promise> { const results = new Map(); for (const id of vacationIds) { const result = await checkVacationConflicts(db, id, approverUserId); if (result.warnings.length > 0) { results.set(id, result.warnings); } } return results; }