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).
This commit is contained in:
@@ -1,36 +1,98 @@
|
||||
/**
|
||||
* 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 { getSession } from "@/lib/auth";
|
||||
|
||||
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
|
||||
// RPC and the time-tracking notification tables are not part of the
|
||||
// SaaS rebuild schema. The function below is a stub that returns
|
||||
// `{ sent: false }` so the cron-style caller (`/api/time-tracking/notify`)
|
||||
// degrades gracefully. See `actions/route-trace/lots.ts` for the
|
||||
// same pattern.
|
||||
import { withBrand } from "@/db/client";
|
||||
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
|
||||
import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
|
||||
|
||||
export type OvertimeCheckResult = {
|
||||
sent: boolean;
|
||||
trigger_type?: string;
|
||||
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,
|
||||
_dailyHours: number,
|
||||
_weeklyHours: number
|
||||
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" };
|
||||
|
||||
await getSession(); 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" };
|
||||
}
|
||||
return {
|
||||
sent: false,
|
||||
message: "Time tracking is not configured in the SaaS rebuild",
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user