Files
route-commerce/src/actions/time-tracking/notifications.ts
T
Nora 93bcbc29a0 refactor(time-tracking): cycle 2 — re-implement field actions against Drizzle
Replaces the in-progress stub (RPC + brandId params) with Drizzle + the
shared SECURITY DEFINER wrappers in db/client.

- field.ts: cookie session, scrypt PIN, clock-in/out, getOpenClockIn,
  getWorkerPayPeriodHours. Cookie payload now 7 fields; parseSessionCookie
  re-resolves name/role/lang/brand_id from DB on every read so a stale or
  tampered cookie cannot impersonate a different brand.
- index.ts: admin CRUD for workers / tasks / settings / logs / notifications
  + getWorkerPeriodTotals. All admin actions drop the brandId param; the
  helpers pull it from the admin session via getAdminUser().
- notifications.ts: overtime check inserts with status='pending' (not
  optimistic 'sent') since email/SMS dispatch is out of scope until the
  notification_targets columns land in a follow-up migration.
- export/route: cast fix for the changed TimeLog shape.

DB:
- 0094 partial unique index on time_tracking_logs (worker_id)
  WHERE clock_out IS NULL — enforces one open clock-in per worker
  against concurrent / retried requests (READ COMMITTED cannot).
2026-07-03 18:11:26 -06:00

98 lines
3.4 KiB
TypeScript

/**
* 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<OvertimeCheckResult> {
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,
};
});
}