125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.23';
|
|
|
|
/**
|
|
* Fill Escalation Monitor
|
|
* Runs every 15 minutes. Detects events at risk of not being filled
|
|
* and creates escalation alerts in ActivityLog.
|
|
*
|
|
* Fill probability formula:
|
|
* score = (assigned / requested) * 60
|
|
* + timeBuffer * 20 (more time = more chance to fill)
|
|
* + dayOfWeek * 20 (based on historical fill rate for that day)
|
|
*
|
|
* If fillProbability < 60% → escalation alert
|
|
*/
|
|
|
|
Deno.serve(async (req) => {
|
|
const base44 = createClientFromRequest(req);
|
|
|
|
// Allow scheduled/service calls without user auth
|
|
const now = new Date();
|
|
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
const in4h = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
|
|
|
// Fetch all upcoming active events
|
|
const allEvents = await base44.asServiceRole.entities.Event.list();
|
|
const upcoming = allEvents.filter(e => {
|
|
if (!e.date) return false;
|
|
const eventDate = new Date(e.date);
|
|
return eventDate > now && eventDate <= in24h &&
|
|
["Active", "Pending", "Confirmed", "Draft"].includes(e.status);
|
|
});
|
|
|
|
// Historical fill rate by day of week
|
|
const completed = allEvents.filter(e => e.status === "Completed" && e.date);
|
|
const dayFillRate = Array(7).fill(null).map((_, dow) => {
|
|
const dayEvents = completed.filter(e => new Date(e.date).getDay() === dow);
|
|
if (dayEvents.length === 0) return 0.75; // default 75%
|
|
const filled = dayEvents.filter(e => (e.assigned_staff?.length || 0) >= (e.requested || 1));
|
|
return filled.length / dayEvents.length;
|
|
});
|
|
|
|
const escalations = [];
|
|
const alerts = [];
|
|
|
|
for (const event of upcoming) {
|
|
const assigned = event.assigned_staff?.length || 0;
|
|
const requested = event.requested || 0;
|
|
if (requested === 0) continue;
|
|
|
|
const fillRatio = assigned / requested;
|
|
const eventDate = new Date(event.date);
|
|
const hoursUntil = (eventDate - now) / (1000 * 60 * 60);
|
|
const dow = eventDate.getDay();
|
|
|
|
// Time buffer score: 0 = within 2h (critical), 1 = 24h out (comfortable)
|
|
const timeBuffer = Math.min(hoursUntil / 24, 1);
|
|
|
|
// Fill probability 0-100
|
|
const fillProbability = Math.round(
|
|
fillRatio * 60 +
|
|
timeBuffer * 20 +
|
|
dayFillRate[dow] * 20
|
|
);
|
|
|
|
const isCritical = hoursUntil <= 4 && fillRatio < 1;
|
|
const isAtRisk = fillProbability < 60;
|
|
|
|
if (isCritical || isAtRisk) {
|
|
escalations.push({
|
|
event_id: event.id,
|
|
event_name: event.event_name,
|
|
hours_until: Math.round(hoursUntil),
|
|
fill_ratio: fillRatio,
|
|
fill_probability: fillProbability,
|
|
assigned,
|
|
requested,
|
|
is_critical: isCritical,
|
|
});
|
|
|
|
// Create ActivityLog alert (avoid duplicates — check if one exists in last 2h)
|
|
const existingAlerts = await base44.asServiceRole.entities.ActivityLog.filter({
|
|
related_entity_id: event.id,
|
|
activity_type: "event_updated",
|
|
});
|
|
|
|
const recentAlert = existingAlerts.find(a => {
|
|
const created = new Date(a.created_date);
|
|
return (now - created) < 2 * 60 * 60 * 1000; // within 2h
|
|
});
|
|
|
|
if (!recentAlert) {
|
|
const severity = isCritical ? "🚨 CRITICAL" : "⚠️ AT RISK";
|
|
const gapCount = requested - assigned;
|
|
const description = isCritical
|
|
? `${event.event_name} starts in ${Math.round(hoursUntil)}h — ${gapCount} position${gapCount > 1 ? "s" : ""} still open. Fill immediately.`
|
|
: `${event.event_name} has a ${fillProbability}% fill probability. ${gapCount} position${gapCount > 1 ? "s" : ""} unfilled with ${Math.round(hoursUntil)}h remaining.`;
|
|
|
|
alerts.push(
|
|
base44.asServiceRole.entities.ActivityLog.create({
|
|
title: `${severity}: ${event.event_name} needs staffing`,
|
|
description,
|
|
activity_type: "event_updated",
|
|
related_entity_type: "event",
|
|
related_entity_id: event.id,
|
|
action_link: `EventDetail?id=${event.id}`,
|
|
action_label: "Assign Staff Now",
|
|
user_id: event.created_by || "system",
|
|
is_read: false,
|
|
icon_type: "alert",
|
|
icon_color: isCritical ? "red" : "yellow",
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(alerts);
|
|
|
|
return Response.json({
|
|
checked: upcoming.length,
|
|
escalations: escalations.length,
|
|
details: escalations,
|
|
timestamp: now.toISOString(),
|
|
});
|
|
}); |