import { listAssignmentBookings } from "@planarchy/application"; import { createNotificationsForUsers } from "./create-notification.js"; type DbClient = Parameters[0] & { project: { findUnique: (args: { where: { id: string }; select: { id: true; name: true; shortCode: true; budgetCents: true }; }) => Promise<{ id: string; name: string; shortCode: string; budgetCents: number; } | null>; }; notification: { findFirst: (args: { where: { entityId: string; entityType: string; type: string; }; select: { id: true }; }) => Promise<{ id: string } | null>; 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 }>; }; user: { findMany: (args: { where: { systemRole: { in: string[] } }; select: { id: true }; }) => Promise>; }; }; const THRESHOLDS = [ { percent: 100, type: "BUDGET_OVERRUN_100", label: "100%", priority: "URGENT" as const }, { percent: 80, type: "BUDGET_OVERRUN_80", label: "80%", priority: "HIGH" as const }, ] as const; /** * Check whether a project's current spend has crossed 80% or 100% of its budget. * Creates in-app notifications for all managers/admins when a threshold is * crossed for the first time. * * Safe to call repeatedly -- duplicate notifications are prevented by checking * whether a notification with the same entityId + type already exists. */ export async function checkBudgetThresholds( db: DbClient, projectId: string, ): Promise { const project = await db.project.findUnique({ where: { id: projectId }, select: { id: true, name: true, shortCode: true, budgetCents: true }, }); if (!project || project.budgetCents <= 0) return; // Compute total spend from assignment bookings (same logic as listWithCosts) const bookings = await listAssignmentBookings(db, { startDate: new Date("1900-01-01T00:00:00.000Z"), endDate: new Date("2100-12-31T23:59:59.999Z"), projectIds: [projectId], }); let totalCostCents = 0; for (const booking of bookings) { const days = (new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) / (1000 * 60 * 60 * 24) + 1; totalCostCents += booking.dailyCostCents * days; } totalCostCents = Math.round(totalCostCents); const spendPercent = (totalCostCents / project.budgetCents) * 100; for (const threshold of THRESHOLDS) { if (spendPercent < threshold.percent) continue; // Check if we already sent this alert const existing = await db.notification.findFirst({ where: { entityId: projectId, entityType: "project_budget", type: threshold.type, }, select: { id: true }, }); if (existing) continue; // Get all managers and admins const managers = await db.user.findMany({ where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, select: { id: true }, }); const formattedSpend = (totalCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); const formattedBudget = (project.budgetCents / 100).toLocaleString( "de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }, ); await createNotificationsForUsers({ db, userIds: managers.map((m) => m.id), type: threshold.type, category: "NOTIFICATION", priority: threshold.priority, title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`, body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`, entityId: projectId, entityType: "project_budget", link: `/projects/${projectId}`, channel: "in_app", }); } }