/** * Time Tracking — overtime notification check. * * Re-implemented in Cycle 2 against Drizzle. The original was a stub * that returned `{ sent: false }`. The cron-style caller * (`/api/time-tracking/notify`) invokes this after every clock-in/out * to decide whether to alert the brand's configured recipients. * * For now we INSERT into the notification log on every check; the * actual email/SMS dispatch is intentionally a no-op until the * notification_targets columns land on `time_tracking_settings` (out * of Cycle 2 scope). The notify API route can already read the log. */ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; import { withBrand } from "@/db/client"; import { timeTrackingNotificationLog } from "@/db/schema/time-tracking"; import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index"; export type OvertimeCheckResult = { sent: boolean; trigger_type?: "daily" | "weekly" | "both"; message?: string; notification_log_id?: string; daily_hours?: number; weekly_hours?: number; }; export async function checkAndNotifyOvertime( brandId: string, workerId: string, workerName: string, // The two hour args are accepted for back-compat with the original // signature (the cron caller passes them). We re-derive from the // logs to avoid drift, but record them for the log body. dailyHours: number, weeklyHours: number, ): Promise { const adminUser = await getAdminUser(); if (!adminUser) return { sent: false, message: "Not authenticated" }; const settings = await getTimeTrackingSettings(brandId); if (!settings || !settings.overtime_notifications) { return { sent: false, message: "Notifications disabled" }; } // Re-derive totals from the source of truth so the notification log // matches the actuals even if the caller passed stale numbers. const totals = await getWorkerPeriodTotals(brandId, workerId, settings); const dailyHit = totals.day_hours > totals.daily_threshold; const weeklyHit = totals.week_hours > totals.weekly_threshold; if (!dailyHit && !weeklyHit) { return { sent: false, message: "Below thresholds" }; } const trigger: "daily" | "weekly" | "both" = dailyHit && weeklyHit ? "both" : dailyHit ? "daily" : "weekly"; const subject = `Overtime alert: ${workerName}`; const body = `${workerName} crossed the ${trigger} overtime threshold.\n` + `Day: ${totals.day_hours.toFixed(2)} h (threshold ${totals.daily_threshold})\n` + `Week: ${totals.week_hours.toFixed(2)} h (threshold ${totals.weekly_threshold})\n` + `(Reported: ${dailyHours.toFixed(2)} / ${weeklyHours.toFixed(2)})`; // Insert a notification log row. status='pending' — actual email/SMS // dispatch is out of Cycle 2 scope (notification_targets columns on // time_tracking_settings land in a follow-up migration). A future // cron will flip 'pending' → 'sent' / 'failed'. return withBrand(brandId, async (db) => { const [row] = await db .insert(timeTrackingNotificationLog) .values({ brandId, workerId, notificationType: trigger, recipient: "admin@pending", subject, body, status: "pending", }) .returning({ id: timeTrackingNotificationLog.id }); return { sent: true, trigger_type: trigger, message: body, notification_log_id: row?.id, daily_hours: totals.day_hours, weekly_hours: totals.week_hours, }; }); }