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:
@@ -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.';
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user