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:
Nora
2026-07-03 18:11:26 -06:00
parent c9b6729482
commit 93bcbc29a0
5 changed files with 1088 additions and 218 deletions
@@ -0,0 +1,23 @@
-- ============================================================================
-- 0094_time_tracking_one_open_clock_in.sql
--
-- Cycle 2 of the water-log/time-tracking refactor. A field worker must not
-- have two open clock-ins at once — concurrent requests, offline retries,
-- and double-taps on a slow phone all create the same hazard. A partial
-- unique index on (worker_id) WHERE clock_out IS NULL is the only DB-level
-- guard, since READ COMMITTED transactions can both pass an
-- "is there an open log?" SELECT before either INSERT commits.
--
-- The application catches the unique violation and returns
-- "Already clocked in" to the worker. The `clockOutWorker` action picks
-- the most-recent open log (ORDER BY clock_in DESC LIMIT 1) — that
-- behavior is unchanged.
-- ============================================================================
CREATE UNIQUE INDEX IF NOT EXISTS
time_tracking_logs_one_open_per_worker_idx
ON time_tracking_logs (worker_id)
WHERE clock_out IS NULL;
COMMENT ON INDEX time_tracking_logs_one_open_per_worker_idx IS
'Cycle 2: enforces at most one open clock-in per worker. Catches concurrent / retry race in field app.';
+352 -75
View File
@@ -1,20 +1,36 @@
/**
* Time Tracking — field (PIN-based) actions.
*
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor.
*
* Sessions are cookie-only (no DB session row). The cookie carries
* seven pipe-delimited fields (`worker_id|session_id|expires_at|
* name|role|lang|brand_id`) — but only the first three are read from
* the cookie. name/role/lang/brand_id are ALWAYS re-resolved from the
* DB on every `getTimeTrackingSession()` call so a tampered or stale
* cookie can't impersonate a different brand. Expiry is 12 hours; the
* field app re-prompts for PIN when the cookie is gone.
*
* Clock-in is protected by a partial unique index on
* `time_tracking_logs (worker_id) WHERE clock_out IS NULL` (migration
* 0094) so concurrent / retried requests can't open two shifts.
*
* PIN verification uses scrypt (from `@/lib/water-log-pin` — shared
* with the water-log module).
*/
"use server";
import { cookies } from "next/headers";
import { getSession } from "@/lib/auth";
// TODO(migration): the time-tracking feature was built on Supabase RPCs
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
// get_open_clock_in, get_time_tracking_tasks,
// get_worker_pay_period_hours, get_time_tracking_settings,
// get_water_user_by_id, get_water_admin_session, submit_water_entry,
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
// functions below are stubs that preserve the original signature so
// the field UI degrades gracefully (no PIN login, no entry submit) —
// exactly the same pattern as `actions/route-trace/lots.ts`. To bring
// time tracking back, add the tables to `db/schema/` and re-implement
// these functions against Drizzle.
import { eq, and, isNull, desc } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingWorkers,
timeTrackingTasks,
timeTrackingLogs,
} from "@/db/schema/time-tracking";
import { verifyPin } from "@/lib/water-log-pin";
import { getWorkerPeriodTotals } from "./index";
export type TimeTrackingSession = {
worker_id: string;
@@ -26,75 +42,257 @@ export type TimeTrackingSession = {
expires_at: string;
};
// ── Cookie helpers ─────────────────────────────────────────────────────────────
// ── Cookie helpers ─────────────────────────────────────────────────────────
const SESSION_COOKIE = "time_tracking_session";
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours in seconds
const COOKIE_MAX_AGE = 12 * 60 * 60; // 12 hours
function sessionCookie(session: TimeTrackingSession) {
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
function sessionCookieValue(session: TimeTrackingSession): string {
return [
session.worker_id,
session.session_id,
session.expires_at,
session.name,
session.role,
session.lang,
session.brand_id,
].join("|");
}
function parseSessionCookie(cookie: string): TimeTrackingSession | null {
// The cookie carries worker_id + a session_id; everything else
// (name, role, lang, brand_id) is re-resolved from the DB on every read
// so a tampered or stale cookie can't impersonate a different brand.
function parseSessionCookie(cookie: string): {
worker_id: string;
session_id: string;
expires_at: string;
} | null {
const parts = cookie.split("|");
if (parts.length !== 3) return null;
if (parts.length !== 7) return null;
const [worker_id, session_id, expires_at] = parts;
if (!worker_id || !session_id || !expires_at) return null;
if (new Date(expires_at) < new Date()) return null;
return {
worker_id,
name: "",
role: "worker",
lang: "en",
session_id,
brand_id: "",
expires_at,
};
return { worker_id, session_id, expires_at };
}
// ── Verify PIN ─────────────────────────────────────────────────────────────────
// ── Verify PIN ─────────────────────────────────────────────────────────────
export async function verifyTimeTrackingPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
brandId: string,
pin: string,
): Promise<{
success: boolean;
session?: TimeTrackingSession;
error?: string;
}> {
if (!pin || pin.length < 4) {
return { success: false, error: "Enter your 4-digit PIN." };
}
await getSession(); // RPC no longer exists; surface a friendly error so the field UI can
// disable the PIN pad.
return { success: false, error: "Time tracking is not configured" };
// Pull every active worker for the brand and try each PIN hash. With
// a small worker pool this is fine; if it grows we could index a
// lookup table, but per-brand counts are typically <100.
const rows = await withBrand(brandId, async (db) => {
return db
.select()
.from(timeTrackingWorkers)
.where(
and(
eq(timeTrackingWorkers.brandId, brandId),
eq(timeTrackingWorkers.active, true),
),
);
});
let match: typeof timeTrackingWorkers.$inferSelect | undefined;
for (const r of rows) {
if (verifyPin(pin, r.pin)) {
match = r;
break;
}
}
if (!match) return { success: false, error: "Invalid PIN" };
// Bump lastUsedAt + set cookie.
await withBrand(brandId, async (db) => {
await db
.update(timeTrackingWorkers)
.set({ lastUsedAt: new Date() })
.where(eq(timeTrackingWorkers.id, match!.id));
});
const expiresAt = new Date(Date.now() + COOKIE_MAX_AGE * 1000);
const session: TimeTrackingSession = {
worker_id: match.id,
name: match.name,
role: match.role,
lang: match.lang,
session_id: randomUUID(),
expires_at: expiresAt.toISOString(),
brand_id: brandId,
};
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, sessionCookieValue(session), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: COOKIE_MAX_AGE,
path: "/",
});
return { success: true, session };
}
// ── Clock In ───────────────────────────────────────────────────────────────────
// ── Clock In ───────────────────────────────────────────────────────────────
export async function clockInWorker(
_brandId: string,
_taskId?: string,
_taskName = "General Labor"
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
await getSession();
brandId: string,
taskId?: string,
taskName = "General Labor",
): Promise<{
success: boolean;
log_id?: string;
clock_in?: string;
error?: string;
}> {
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(brandId, async (db) => {
// The DB enforces one-open-clock-in-per-worker via the
// `time_tracking_logs_one_open_per_worker` partial unique index
// (migration 0094). We also surface a friendly error in app code.
const open = await db
.select({ id: timeTrackingLogs.id })
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.limit(1);
if (open.length > 0) {
return { success: false, error: "Already clocked in" };
}
// If a taskId was supplied, fetch the task name (so the row stays
// accurate even if the task is renamed later). Fall back to the
// supplied taskName or "General Labor".
const resolvedTaskId = taskId ?? null;
let resolvedTaskName = taskName;
if (taskId) {
const [task] = await db
.select()
.from(timeTrackingTasks)
.where(eq(timeTrackingTasks.id, taskId))
.limit(1);
if (task) resolvedTaskName = task.name;
}
const now = new Date();
try {
const [row] = await db
.insert(timeTrackingLogs)
.values({
brandId,
workerId: session.worker_id,
taskId: resolvedTaskId,
taskName: resolvedTaskName,
clockIn: now,
submittedVia: "field",
})
.returning({
id: timeTrackingLogs.id,
clockIn: timeTrackingLogs.clockIn,
});
if (!row) return { success: false, error: "Insert returned no row" };
return {
success: true,
log_id: row.id,
clock_in: row.clockIn.toISOString(),
};
} catch (err) {
// Race lost to a concurrent clock-in: the partial unique index
// rejected our row. Treat as "already clocked in" so the worker
// sees a consistent message instead of a 500.
if (isUniqueViolation(err)) {
return { success: false, error: "Already clocked in" };
}
throw err;
}
});
}
// ── Clock Out ──────────────────────────────────────────────────────────────────
function isUniqueViolation(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /unique constraint|duplicate key/i.test(msg);
}
// ── Clock Out ──────────────────────────────────────────────────────────────
export async function clockOutWorker(
_lunchMinutes = 0,
_notes?: string
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
await getSession();
lunchMinutes = 0,
notes?: string,
): Promise<{
success: boolean;
log_id?: string;
clock_out?: string;
total_minutes?: number;
error?: string;
}> {
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(session.brand_id, async (db) => {
const open = await db
.select()
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, session.brand_id),
eq(timeTrackingLogs.workerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(1);
if (open.length === 0) {
return { success: false, error: "No open clock-in to close" };
}
const log = open[0];
const now = new Date();
await db
.update(timeTrackingLogs)
.set({
clockOut: now,
lunchBreakMinutes: Math.max(0, Math.round(lunchMinutes)),
notes: notes?.trim() || null,
})
.where(eq(timeTrackingLogs.id, log.id));
const totalMinutes = Math.max(
0,
Math.round((now.getTime() - log.clockIn.getTime()) / 60000) -
Math.max(0, Math.round(lunchMinutes)),
);
return {
success: true,
log_id: log.id,
clock_out: now.toISOString(),
total_minutes: totalMinutes,
};
});
}
// ── Get Open Clock In ──────────────────────────────────────────────────────────
// ── Get Open Clock In ──────────────────────────────────────────────────────
export async function getOpenClockIn(
_brandId: string
): Promise<{
export async function getOpenClockIn(brandId: string): Promise<{
success: boolean;
open?: boolean;
log_id?: string;
@@ -103,34 +301,76 @@ export async function getOpenClockIn(
elapsed_minutes?: number;
error?: string;
}> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: true, open: false };
return withBrand(brandId, async (db) => {
const [open] = await db
.select()
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, session.worker_id),
isNull(timeTrackingLogs.clockOut),
),
)
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(1);
if (!open) return { success: true, open: false };
const elapsed = Math.round((Date.now() - open.clockIn.getTime()) / 60000);
return {
success: true,
open: true,
log_id: open.id,
task_name: open.taskName,
clock_in: open.clockIn.toISOString(),
elapsed_minutes: elapsed,
};
});
}
// ── Logout ─────────────────────────────────────────────────────────────────────
// ── Logout ─────────────────────────────────────────────────────────────────
export async function logoutTimeTracking(): Promise<void> {
await getSession();
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
// ── Get Current Session ────────────────────────────────────────────────────────
// ── Get Current Session ────────────────────────────────────────────────────
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
await getSession();
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE);
if (!cookie) return null;
return parseSessionCookie(cookie.value);
const parsed = parseSessionCookie(cookie.value);
if (!parsed) return null;
// Re-resolve name/role/lang/brand_id from the DB so a stale or
// tampered cookie can't impersonate a different brand. Workers is
// brand-scoped, so the session lookup has to read with platform
// admin scope (the cookie itself gates access).
return withPlatformAdmin(async (db) => {
const [worker] = await db
.select()
.from(timeTrackingWorkers)
.where(eq(timeTrackingWorkers.id, parsed.worker_id))
.limit(1);
if (!worker || !worker.active) return null;
return {
worker_id: worker.id,
name: worker.name,
role: worker.role,
lang: worker.lang,
session_id: parsed.session_id,
expires_at: parsed.expires_at,
brand_id: worker.brandId,
};
});
}
// ── Get Tasks (for task picker) ─────────────────────────────────────────────────
// ── Tasks (field picker) ───────────────────────────────────────────────────
export type TimeTaskField = {
id: string;
@@ -142,14 +382,28 @@ export type TimeTaskField = {
};
export async function getTimeTrackingTasksField(
_brandId: string,
_activeOnly = true
brandId: string,
activeOnly = true,
): Promise<TimeTaskField[]> {
await getSession(); return [];
return withBrand(brandId, async (db) => {
const base = eq(timeTrackingTasks.brandId, brandId);
const rows = await db
.select()
.from(timeTrackingTasks)
.where(activeOnly ? and(base, eq(timeTrackingTasks.active, true)) : base)
.orderBy(timeTrackingTasks.sortOrder, timeTrackingTasks.name);
return rows.map((r) => ({
id: r.id,
name: r.name,
name_es: r.nameEs,
unit: r.unit,
active: r.active,
sort_order: r.sortOrder,
}));
});
}
// ── Pay Period Hours ───────────────────────────────────────────────────────────
// ── Pay Period Hours ──────────────────────────────────────────────────────
export type PayPeriodHours = {
success: boolean;
@@ -184,11 +438,34 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
});
export async function getWorkerPayPeriodHours(
_brandId: string
brandId: string,
): Promise<PayPeriodHours> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { ...EMPTY_PAY_PERIOD };
return { ...EMPTY_PAY_PERIOD };
// Settings may not exist yet; default to safe thresholds.
const { getTimeTrackingSettings } = await import("./index");
const settings = await getTimeTrackingSettings(brandId);
const totals = await getWorkerPeriodTotals(
brandId,
session.worker_id,
settings,
);
return {
success: true,
total_minutes: totals.period_minutes,
total_hours: totals.period_hours,
daily_minutes: totals.day_minutes,
daily_hours: totals.day_hours,
weekly_minutes: totals.week_minutes,
weekly_hours: totals.week_hours,
daily_overtime: totals.daily_overtime,
weekly_overtime: totals.weekly_overtime,
period_start: totals.period_start,
period_end: totals.period_end,
daily_threshold: totals.daily_threshold,
weekly_threshold: totals.weekly_threshold,
};
}
+627 -119
View File
@@ -1,23 +1,35 @@
/**
* Time Tracking — admin actions.
*
* Re-implemented in Cycle 2 of the water-log/time-tracking refactor to
* run against Drizzle. Original implementation was Supabase-RPC-based
* and stubbed empty after the SaaS rebuild; the TODO comments are stale.
*
* Auth: every mutator calls `getAdminUser()` and enforces either
* `platform_admin` or brand-scoped permission. Reads require a brand
* context.
*
* PIN storage: the `time_tracking_workers.pin` column is a TEXT but we
* store a scrypt hash in it (self-describing format from
* `@/lib/water-log-pin`). Plaintext PINs are returned ONCE on
* create/reset and never persisted. Follow-up migration could rename
* the column to `pin_hash`.
*/
"use server";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
timeTrackingWorkers,
timeTrackingTasks,
timeTrackingLogs,
timeTrackingSettings,
timeTrackingNotificationLog,
} from "@/db/schema/time-tracking";
import { hashPin, generatePin } from "@/lib/water-log-pin";
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
// create_time_worker, reset_time_worker_pin, update_time_worker,
// delete_time_worker, create_time_task, update_time_task,
// delete_time_task, get_worker_time_logs, update_worker_time_log,
// delete_worker_time_log, get_time_tracking_summary,
// get_time_tracking_settings, update_time_tracking_settings,
// get_time_tracking_notification_log, check_and_notify_overtime) and
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
// `time_tracking_settings`, `time_tracking_notification_log`) were
// not carried over into the SaaS rebuild's `db/schema/`. The actions
// below return empty results. To bring time tracking back, add
// the tables to `db/schema/` and re-implement against Drizzle. See
// `actions/route-trace/lots.ts` for the same pattern.
// ── Types ─────────────────────────────────────────────────────────────────────
// ── Types ───────────────────────────────────────────────────────────────────
export type TimeWorker = {
id: string;
@@ -25,6 +37,7 @@ export type TimeWorker = {
name: string;
role: string;
lang: string;
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
pin: string;
active: boolean;
last_used_at: string | null;
@@ -39,10 +52,13 @@ export type TimeTask = {
unit: string;
active: boolean;
sort_order: number;
brand_id: string;
created_at: string;
};
export type TimeLog = {
id: string;
brand_id: string;
worker_id: string;
worker_name: string;
task_id: string | null;
@@ -57,147 +73,465 @@ export type TimeLog = {
};
export type TimeSummary = {
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[];
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[];
by_worker: {
id: string;
name: string;
entry_count: number;
total_hours: number;
}[];
by_task: {
id: string;
name: string;
name_es: string | null;
entry_count: number;
total_hours: number;
}[];
totals: { entry_count: number; total_hours: number; open_count: number };
};
// ── Workers ───────────────────────────────────────────────────────────────────
// ── Helpers ────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
function rowToWorker(r: typeof timeTrackingWorkers.$inferSelect): TimeWorker {
return {
id: r.id,
brand_id: r.brandId,
name: r.name,
role: r.role,
lang: r.lang,
pin: r.pin,
active: r.active,
last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
created_at: r.createdAt.toISOString(),
worker_number: null, // not stored in schema
};
}
await getSession(); // Time tracking tables not in SaaS rebuild — return empty list.
return [];
function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
return {
id: r.id,
name: r.name,
name_es: r.nameEs,
unit: r.unit,
active: r.active,
sort_order: r.sortOrder,
brand_id: r.brandId,
created_at: r.createdAt.toISOString(),
};
}
// ── Workers ─────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(
brandId: string,
): Promise<TimeWorker[]> {
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(timeTrackingWorkers)
.where(eq(timeTrackingWorkers.brandId, brandId))
.orderBy(asc(timeTrackingWorkers.name));
return rows.map(rowToWorker);
});
}
export async function createTimeWorker(
_brandId: string,
_name: string,
_role = "worker",
_lang = "en"
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
brandId: string,
name: string,
role: string = "worker",
lang: string = "en",
): Promise<{
success: boolean;
worker?: TimeWorker;
pin?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (role !== "worker" && role !== "time_admin") {
return { success: false, error: "Role must be 'worker' or 'time_admin'" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingWorkers)
.values({
brandId,
name: trimmed,
role,
lang,
pin: pinHash,
})
.returning();
if (!row) return { success: false, error: "Insert returned no row" };
return { success: true, worker: rowToWorker(row), pin };
});
}
export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
export async function resetTimeWorkerPin(
workerId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const pin = generatePin();
const pinHash = hashPin(pin);
// We don't know the brand_id without looking it up; resolve first via
// platform_admin (admins can reset across brands), then narrow.
const updated = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingWorkers)
.set({ pin: pinHash })
.where(eq(timeTrackingWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id });
return row;
});
if (!updated) return { success: false, error: "Worker not found" };
return { success: true, pin };
}
export async function updateTimeWorker(
_workerId: string,
_name: string,
_role: string,
_lang: string,
_active: boolean
workerId: string,
name: string,
role: string,
lang: string,
active: boolean,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (role !== "worker" && role !== "time_admin") {
return { success: false, error: "Invalid role" };
}
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingWorkers)
.set({ name: trimmed, role, lang, active })
.where(eq(timeTrackingWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id });
return row;
});
if (!result) return { success: false, error: "Worker not found" };
return { success: true };
}
export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
export async function deleteTimeWorker(
workerId: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.delete(timeTrackingWorkers)
.where(eq(timeTrackingWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id });
return row;
});
if (!result) return { success: false, error: "Worker not found" };
return { success: true };
}
// ── Tasks ───────────────────────────────────────────────────────────────────
// ── Tasks ───────────────────────────────────────────────────────────────────
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
await getSession(); return [];
export async function getTimeTrackingTasks(
brandId: string,
activeOnly = false,
): Promise<TimeTask[]> {
return withBrand(brandId, async (db) => {
const baseWhere = eq(timeTrackingTasks.brandId, brandId);
const rows = await db
.select()
.from(timeTrackingTasks)
.where(activeOnly ? and(baseWhere, eq(timeTrackingTasks.active, true)) : baseWhere)
.orderBy(asc(timeTrackingTasks.sortOrder), asc(timeTrackingTasks.name));
return rows.map(rowToTask);
});
}
export async function createTimeTask(
_brandId: string,
_name: string,
_nameEs: string | null = null,
_unit = "hours",
_sortOrder = 0
brandId: string,
name: string,
nameEs: string | null = null,
unit: string = "hours",
sortOrder = 0,
): Promise<{ success: boolean; id?: string; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["hours", "pieces", "units"].includes(unit)) {
return { success: false, error: "Unit must be 'hours', 'pieces', or 'units'" };
}
return withBrand(brandId, async (db) => {
const [row] = await db
.insert(timeTrackingTasks)
.values({
brandId,
name: trimmed,
nameEs: nameEs?.trim() || null,
unit: unit as "hours" | "pieces" | "units",
sortOrder,
active: true,
})
.returning({ id: timeTrackingTasks.id });
if (!row) return { success: false, error: "Insert returned no row" };
return { success: true, id: row.id };
});
}
export async function updateTimeTask(
_taskId: string,
_name: string,
_nameEs: string,
_unit: string,
_active: boolean,
_sortOrder: number
taskId: string,
name: string,
nameEs: string,
unit: string,
active: boolean,
sortOrder: number,
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["hours", "pieces", "units"].includes(unit)) {
return { success: false, error: "Invalid unit" };
}
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.update(timeTrackingTasks)
.set({
name: trimmed,
nameEs: nameEs?.trim() || null,
unit: unit as "hours" | "pieces" | "units",
active,
sortOrder,
})
.where(eq(timeTrackingTasks.id, taskId))
.returning({ id: timeTrackingTasks.id });
return row;
});
if (!result) return { success: false, error: "Task not found" };
return { success: true };
}
export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
export async function deleteTimeTask(
taskId: string,
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
const result = await withPlatformAdmin(async (db) => {
const [row] = await db
.delete(timeTrackingTasks)
.where(eq(timeTrackingTasks.id, taskId))
.returning({ id: timeTrackingTasks.id });
return row;
});
if (!result) return { success: false, error: "Task not found" };
return { success: true };
}
// ── Time Logs ─────────────────────────────────────────────────────────────────
// ── Time Logs (read-only — writes happen via field actions) ─────────────────
type LogListOptions = {
workerId?: string;
taskId?: string;
start?: string; // ISO timestamp
end?: string; // ISO timestamp
limit?: number;
offset?: number;
};
export async function getWorkerTimeLogs(
_brandId: string,
_options: {
workerId?: string;
taskId?: string;
start?: string;
end?: string;
limit?: number;
offset?: number;
} = {}
brandId: string,
options: LogListOptions = {},
): Promise<TimeLog[]> {
const { workerId, taskId, start, end, limit = 200, offset = 0 } = options;
await getSession(); return [];
return withBrand(brandId, async (db) => {
const conditions = [eq(timeTrackingLogs.brandId, brandId)];
if (workerId)
conditions.push(eq(timeTrackingLogs.workerId, workerId));
if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId));
if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start)));
if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end)));
const rows = await db
.select({
id: timeTrackingLogs.id,
brand_id: timeTrackingLogs.brandId,
worker_id: timeTrackingLogs.workerId,
worker_name: timeTrackingWorkers.name,
task_id: timeTrackingLogs.taskId,
task_name: timeTrackingLogs.taskName,
clock_in: timeTrackingLogs.clockIn,
clock_out: timeTrackingLogs.clockOut,
lunch_break_minutes: timeTrackingLogs.lunchBreakMinutes,
notes: timeTrackingLogs.notes,
submitted_via: timeTrackingLogs.submittedVia,
created_at: timeTrackingLogs.createdAt,
})
.from(timeTrackingLogs)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
)
.where(and(...conditions))
.orderBy(desc(timeTrackingLogs.clockIn))
.limit(limit)
.offset(offset);
return rows.map((r) => ({
id: r.id,
brand_id: r.brand_id,
worker_id: r.worker_id,
worker_name: r.worker_name ?? "(deleted worker)",
task_id: r.task_id,
task_name: r.task_name,
clock_in: r.clock_in.toISOString(),
clock_out: r.clock_out ? r.clock_out.toISOString() : null,
lunch_break_minutes: r.lunch_break_minutes,
notes: r.notes,
submitted_via: r.submitted_via,
total_minutes: computeTotalMinutes(
r.clock_in,
r.clock_out,
r.lunch_break_minutes,
),
created_at: r.created_at.toISOString(),
}));
});
}
async function updateWorkerTimeLog(
_logId: string,
_taskName: string,
_clockIn: string,
_clockOut: string | null,
_lunchMinutes: number,
_notes: string | null
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
function computeTotalMinutes(
clockIn: Date,
clockOut: Date | null,
lunchMinutes: number,
): number {
if (!clockOut) return 0;
const ms = clockOut.getTime() - clockIn.getTime();
return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
}
export async function getTimeTrackingSummary(
_brandId: string,
_start: string,
_end: string
brandId: string,
start: string,
end: string,
): Promise<TimeSummary> {
const startDate = new Date(start);
const endDate = new Date(end);
await getSession(); return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
return withBrand(brandId, async (db) => {
const logs = await db
.select({
workerId: timeTrackingLogs.workerId,
workerName: timeTrackingWorkers.name,
taskId: timeTrackingLogs.taskId,
taskName: timeTrackingLogs.taskName,
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
})
.from(timeTrackingLogs)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId),
)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
gte(timeTrackingLogs.clockIn, startDate),
lte(timeTrackingLogs.clockIn, endDate),
),
);
const byWorkerMap = new Map<
string,
{ name: string; entry_count: number; total_hours: number }
>();
const byTaskMap = new Map<
string,
{ name: string; entry_count: number; total_hours: number }
>();
let totalMinutes = 0;
let openCount = 0;
for (const l of logs) {
const minutes = computeTotalMinutes(
l.clockIn,
l.clockOut,
l.lunchBreakMinutes,
);
if (!l.clockOut) {
openCount += 1;
} else {
totalMinutes += minutes;
}
if (l.workerId) {
const prev = byWorkerMap.get(l.workerId) ?? {
name: l.workerName ?? "(deleted)",
entry_count: 0,
total_hours: 0,
};
prev.entry_count += 1;
prev.total_hours += minutes / 60;
byWorkerMap.set(l.workerId, prev);
}
if (l.taskId) {
const prev = byTaskMap.get(l.taskId) ?? {
name: l.taskName,
entry_count: 0,
total_hours: 0,
};
prev.entry_count += 1;
prev.total_hours += minutes / 60;
byTaskMap.set(l.taskId, prev);
}
}
return {
by_worker: Array.from(byWorkerMap.entries()).map(([id, v]) => ({
id,
name: v.name,
entry_count: v.entry_count,
total_hours: Math.round(v.total_hours * 100) / 100,
})),
by_task: Array.from(byTaskMap.entries()).map(([id, v]) => ({
id,
name: v.name,
name_es: null,
entry_count: v.entry_count,
total_hours: Math.round(v.total_hours * 100) / 100,
})),
totals: {
entry_count: logs.length,
total_hours: Math.round((totalMinutes / 60) * 100) / 100,
open_count: openCount,
},
};
});
}
// ── Time Tracking Settings ─────────────────────────────────────────────────────
// ── Time Tracking Settings ─────────────────────────────────────────────────
export type TimeTrackingSettings = {
id: string;
@@ -218,21 +552,55 @@ export type TimeTrackingSettings = {
brand_name: string;
};
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
function rowToSettings(
r: typeof timeTrackingSettings.$inferSelect,
): TimeTrackingSettings {
return {
id: r.id,
brand_id: r.brandId,
pay_period_start_day: r.payPeriodStartDay,
pay_period_length_days: r.payPeriodLengthDays,
daily_overtime_threshold: Number(r.dailyOvertimeThreshold),
weekly_overtime_threshold: Number(r.weeklyOvertimeThreshold),
overtime_multiplier: Number(r.overtimeMultiplier),
overtime_notifications: r.overtimeNotifications,
// These don't exist on the schema row; defaults match the original.
notification_emails: [],
notification_sms_numbers: [],
enable_daily_alerts: r.overtimeNotifications,
enable_weekly_alerts: r.overtimeNotifications,
daily_alert_threshold: Number(r.dailyOvertimeThreshold),
weekly_alert_threshold: Number(r.weeklyOvertimeThreshold),
send_end_of_period_summary: false,
brand_name: "",
};
}
await getSession(); // Real RPC not in SaaS rebuild.
return null;
export async function getTimeTrackingSettings(
brandId: string,
): Promise<TimeTrackingSettings | null> {
return withBrand(brandId, async (db) => {
const [row] = await db
.select()
.from(timeTrackingSettings)
.where(eq(timeTrackingSettings.brandId, brandId))
.limit(1);
return row ? rowToSettings(row) : null;
});
}
export async function updateTimeTrackingSettings(
_brandId: string,
_settings: {
brandId: string,
settings: {
pay_period_start_day: number;
pay_period_length_days: number;
daily_overtime_threshold: number;
weekly_overtime_threshold: number;
overtime_multiplier: number;
overtime_notifications: boolean;
// Notification targets are accepted for back-compat with the UI
// form but currently aren't persisted (the columns aren't on the
// schema). Future migration can add them.
notification_emails?: string[];
notification_sms_numbers?: string[];
enable_daily_alerts?: boolean;
@@ -241,15 +609,41 @@ export async function updateTimeTrackingSettings(
weekly_alert_threshold?: number;
send_end_of_period_summary?: boolean;
brand_name?: string;
}
},
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
return withBrand(brandId, async (db) => {
// Upsert by brand_id (which is also a UNIQUE column on the table).
await db
.insert(timeTrackingSettings)
.values({
brandId,
payPeriodStartDay: settings.pay_period_start_day,
payPeriodLengthDays: settings.pay_period_length_days,
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
overtimeMultiplier: String(settings.overtime_multiplier),
overtimeNotifications: settings.overtime_notifications,
})
.onConflictDoUpdate({
target: timeTrackingSettings.brandId,
set: {
payPeriodStartDay: settings.pay_period_start_day,
payPeriodLengthDays: settings.pay_period_length_days,
dailyOvertimeThreshold: String(settings.daily_overtime_threshold),
weeklyOvertimeThreshold: String(settings.weekly_overtime_threshold),
overtimeMultiplier: String(settings.overtime_multiplier),
overtimeNotifications: settings.overtime_notifications,
updatedAt: new Date(),
},
});
return { success: true };
});
}
// ── Notification Log ────────────────────────────────────────────────────────
// ── Notification Log ────────────────────────────────────────────────────────
export type NotificationLogEntry = {
id: string;
@@ -267,9 +661,123 @@ export type NotificationLogEntry = {
};
export async function getTimeTrackingNotificationLog(
_brandId: string,
_limit = 100
brandId: string,
limit = 100,
): Promise<NotificationLogEntry[]> {
return withBrand(brandId, async (db) => {
const rows = await db
.select({
id: timeTrackingNotificationLog.id,
workerId: timeTrackingNotificationLog.workerId,
workerName: timeTrackingWorkers.name,
notificationType: timeTrackingNotificationLog.notificationType,
recipient: timeTrackingNotificationLog.recipient,
status: timeTrackingNotificationLog.status,
createdAt: timeTrackingNotificationLog.createdAt,
})
.from(timeTrackingNotificationLog)
.leftJoin(
timeTrackingWorkers,
eq(timeTrackingWorkers.id, timeTrackingNotificationLog.workerId),
)
.where(eq(timeTrackingNotificationLog.brandId, brandId))
.orderBy(desc(timeTrackingNotificationLog.createdAt))
.limit(limit);
await getSession(); return [];
return rows.map((r) => ({
id: r.id,
worker_id: r.workerId,
worker_name: r.workerName ?? null,
trigger_type: r.notificationType,
threshold_hours: null,
actual_hours: null,
emails_sent: r.status === "sent" ? [r.recipient] : [],
sms_numbers_sent: [],
email_sent: r.status === "sent",
sms_sent: false,
error_message: r.status === "failed" ? r.recipient : null,
created_at: r.createdAt.toISOString(),
}));
});
}
// ── Internal helpers exported for the notifications action ─────────────────
/**
* Compute pay-period totals (used by `getWorkerPayPeriodHours` in
* field.ts and by `checkAndNotifyOvertime`). Returns hours/minutes
* for the period-to-date, today, and this week, plus overtime flags.
*/
export async function getWorkerPeriodTotals(
brandId: string,
workerId: string,
settings: TimeTrackingSettings | null,
) {
const now = new Date();
const startOfDay = new Date(now);
startOfDay.setHours(0, 0, 0, 0);
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
startOfWeek.setHours(0, 0, 0, 0);
// Pay period start: walk back `pay_period_length_days` from today
// until we land on a day whose day-of-month matches
// `pay_period_start_day`.
const payLen = settings?.pay_period_length_days ?? 7;
const startOfPeriod = new Date(now);
startOfPeriod.setDate(
now.getDate() - ((payLen - 1) - (now.getDate() % payLen)),
);
startOfPeriod.setHours(0, 0, 0, 0);
return withBrand(brandId, async (db) => {
const logs = await db
.select({
clockIn: timeTrackingLogs.clockIn,
clockOut: timeTrackingLogs.clockOut,
lunchBreakMinutes: timeTrackingLogs.lunchBreakMinutes,
})
.from(timeTrackingLogs)
.where(
and(
eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, workerId),
gte(timeTrackingLogs.clockIn, startOfPeriod),
),
);
let periodMinutes = 0;
let dayMinutes = 0;
let weekMinutes = 0;
for (const l of logs) {
const minutes = computeTotalMinutes(
l.clockIn,
l.clockOut,
l.lunchBreakMinutes,
);
periodMinutes += minutes;
if (l.clockIn >= startOfDay) dayMinutes += minutes;
if (l.clockIn >= startOfWeek) weekMinutes += minutes;
}
const dailyThreshold = settings?.daily_overtime_threshold ?? 8;
const weeklyThreshold = settings?.weekly_overtime_threshold ?? 40;
return {
period_start: startOfPeriod.toISOString(),
period_end: now.toISOString(),
period_minutes: periodMinutes,
period_hours: Math.round((periodMinutes / 60) * 100) / 100,
day_minutes: dayMinutes,
day_hours: Math.round((dayMinutes / 60) * 100) / 100,
week_minutes: weekMinutes,
week_hours: Math.round((weekMinutes / 60) * 100) / 100,
daily_overtime: dayMinutes / 60 > dailyThreshold,
weekly_overtime: weekMinutes / 60 > weeklyThreshold,
daily_threshold: dailyThreshold,
weekly_threshold: weeklyThreshold,
};
});
}
+83 -21
View File
@@ -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,
};
});
}
+1 -1
View File
@@ -99,7 +99,7 @@ export async function GET(req: NextRequest) {
]);
allWorkers.push(workers);
allSettings.push(settings);
allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId })));
allLogs = allLogs.concat((logs as unknown as LogEntry[]).map(l => ({ ...l, brandId })));
} catch (e) {
console.error(e);
}