93bcbc29a0
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).
98 lines
3.4 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
} |