From 93bcbc29a02206a1eadf9298c42be793c920f24d Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 18:11:26 -0600 Subject: [PATCH] =?UTF-8?q?refactor(time-tracking):=20cycle=202=20?= =?UTF-8?q?=E2=80=94=20re-implement=20field=20actions=20against=20Drizzle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../0094_time_tracking_one_open_clock_in.sql | 23 + src/actions/time-tracking/field.ts | 427 ++++++++-- src/actions/time-tracking/index.ts | 748 +++++++++++++++--- src/actions/time-tracking/notifications.ts | 106 ++- src/app/api/time-tracking/export/route.ts | 2 +- 5 files changed, 1088 insertions(+), 218 deletions(-) create mode 100644 db/migrations/0094_time_tracking_one_open_clock_in.sql diff --git a/db/migrations/0094_time_tracking_one_open_clock_in.sql b/db/migrations/0094_time_tracking_one_open_clock_in.sql new file mode 100644 index 0000000..c486cc0 --- /dev/null +++ b/db/migrations/0094_time_tracking_one_open_clock_in.sql @@ -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.'; diff --git a/src/actions/time-tracking/field.ts b/src/actions/time-tracking/field.ts index 6c9ca37..37d326e 100644 --- a/src/actions/time-tracking/field.ts +++ b/src/actions/time-tracking/field.ts @@ -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 { - -await getSession(); const cookieStore = await cookies(); cookieStore.delete(SESSION_COOKIE); } -// ── Get Current Session ──────────────────────────────────────────────────────── +// ── Get Current Session ──────────────────────────────────────────────────── export async function getTimeTrackingSession(): Promise { - -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 { - -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 = Object.freeze({ }); export async function getWorkerPayPeriodHours( - _brandId: string + brandId: string, ): Promise { - -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, + }; } diff --git a/src/actions/time-tracking/index.ts b/src/actions/time-tracking/index.ts index 14c32a4..e62e179 100644 --- a/src/actions/time-tracking/index.ts +++ b/src/actions/time-tracking/index.ts @@ -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 { +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 { + 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 { - -await getSession(); return []; +export async function getTimeTrackingTasks( + brandId: string, + activeOnly = false, +): Promise { + 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 { + 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 { + 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 { +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 { + 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 { + 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); + + 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, + }; + }); +} -await getSession(); return []; -} \ No newline at end of file diff --git a/src/actions/time-tracking/notifications.ts b/src/actions/time-tracking/notifications.ts index d52580e..083ba10 100644 --- a/src/actions/time-tracking/notifications.ts +++ b/src/actions/time-tracking/notifications.ts @@ -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 { + 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, + }; + }); +} \ No newline at end of file diff --git a/src/app/api/time-tracking/export/route.ts b/src/app/api/time-tracking/export/route.ts index a0517ac..e709edc 100644 --- a/src/app/api/time-tracking/export/route.ts +++ b/src/app/api/time-tracking/export/route.ts @@ -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); }