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";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getSession } from "@/lib/auth";
|
import { eq, and, isNull, desc } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
// TODO(migration): the time-tracking feature was built on Supabase RPCs
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
|
import {
|
||||||
// get_open_clock_in, get_time_tracking_tasks,
|
timeTrackingWorkers,
|
||||||
// get_worker_pay_period_hours, get_time_tracking_settings,
|
timeTrackingTasks,
|
||||||
// get_water_user_by_id, get_water_admin_session, submit_water_entry,
|
timeTrackingLogs,
|
||||||
// trigger_water_alert) plus tables that don't exist in the SaaS rebuild
|
} from "@/db/schema/time-tracking";
|
||||||
// schema (`time_workers`, `time_tasks`, `time_logs`, `water_*`). The
|
import { verifyPin } from "@/lib/water-log-pin";
|
||||||
// functions below are stubs that preserve the original signature so
|
import { getWorkerPeriodTotals } from "./index";
|
||||||
// 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.
|
|
||||||
|
|
||||||
export type TimeTrackingSession = {
|
export type TimeTrackingSession = {
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
@@ -26,75 +42,257 @@ export type TimeTrackingSession = {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Cookie helpers ─────────────────────────────────────────────────────────────
|
// ── Cookie helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SESSION_COOKIE = "time_tracking_session";
|
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) {
|
function sessionCookieValue(session: TimeTrackingSession): string {
|
||||||
return `${session.worker_id}|${session.session_id}|${session.expires_at}`;
|
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("|");
|
const parts = cookie.split("|");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 7) return null;
|
||||||
const [worker_id, session_id, expires_at] = parts;
|
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;
|
if (new Date(expires_at) < new Date()) return null;
|
||||||
return {
|
return { worker_id, session_id, expires_at };
|
||||||
worker_id,
|
|
||||||
name: "",
|
|
||||||
role: "worker",
|
|
||||||
lang: "en",
|
|
||||||
session_id,
|
|
||||||
brand_id: "",
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Verify PIN ─────────────────────────────────────────────────────────────────
|
// ── Verify PIN ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function verifyTimeTrackingPin(
|
export async function verifyTimeTrackingPin(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_pin: string
|
pin: string,
|
||||||
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: 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
|
// Pull every active worker for the brand and try each PIN hash. With
|
||||||
// disable the PIN pad.
|
// a small worker pool this is fine; if it grows we could index a
|
||||||
return { success: false, error: "Time tracking is not configured" };
|
// 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(
|
export async function clockInWorker(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_taskId?: string,
|
taskId?: string,
|
||||||
_taskName = "General Labor"
|
taskName = "General Labor",
|
||||||
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
await getSession();
|
log_id?: string;
|
||||||
|
clock_in?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { success: false, error: "Not logged in" };
|
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(
|
export async function clockOutWorker(
|
||||||
_lunchMinutes = 0,
|
lunchMinutes = 0,
|
||||||
_notes?: string
|
notes?: string,
|
||||||
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
await getSession();
|
log_id?: string;
|
||||||
|
clock_out?: string;
|
||||||
|
total_minutes?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { success: false, error: "Not logged in" };
|
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(
|
export async function getOpenClockIn(brandId: string): Promise<{
|
||||||
_brandId: string
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
success: boolean;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
log_id?: string;
|
log_id?: string;
|
||||||
@@ -103,34 +301,76 @@ export async function getOpenClockIn(
|
|||||||
elapsed_minutes?: number;
|
elapsed_minutes?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { success: false, error: "Not logged in" };
|
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> {
|
export async function logoutTimeTracking(): Promise<void> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.delete(SESSION_COOKIE);
|
cookieStore.delete(SESSION_COOKIE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Get Current Session ────────────────────────────────────────────────────────
|
// ── Get Current Session ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const cookie = cookieStore.get(SESSION_COOKIE);
|
const cookie = cookieStore.get(SESSION_COOKIE);
|
||||||
if (!cookie) return null;
|
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 = {
|
export type TimeTaskField = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -142,14 +382,28 @@ export type TimeTaskField = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getTimeTrackingTasksField(
|
export async function getTimeTrackingTasksField(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_activeOnly = true
|
activeOnly = true,
|
||||||
): Promise<TimeTaskField[]> {
|
): Promise<TimeTaskField[]> {
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
await getSession(); return [];
|
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 = {
|
export type PayPeriodHours = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -184,11 +438,34 @@ const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function getWorkerPayPeriodHours(
|
export async function getWorkerPayPeriodHours(
|
||||||
_brandId: string
|
brandId: string,
|
||||||
): Promise<PayPeriodHours> {
|
): Promise<PayPeriodHours> {
|
||||||
|
|
||||||
await getSession();
|
|
||||||
const session = await getTimeTrackingSession();
|
const session = await getTimeTrackingSession();
|
||||||
if (!session) return { ...EMPTY_PAY_PERIOD };
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+624
-116
@@ -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";
|
"use server";
|
||||||
|
|
||||||
|
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
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,
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
// 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 ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type TimeWorker = {
|
export type TimeWorker = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +37,7 @@ export type TimeWorker = {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
|
// Hash, not plaintext. Use `verifyPin(rawPin, pin)` to check.
|
||||||
pin: string;
|
pin: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
last_used_at: string | null;
|
last_used_at: string | null;
|
||||||
@@ -39,10 +52,13 @@ export type TimeTask = {
|
|||||||
unit: string;
|
unit: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
brand_id: string;
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TimeLog = {
|
export type TimeLog = {
|
||||||
id: string;
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
worker_name: string;
|
worker_name: string;
|
||||||
task_id: string | null;
|
task_id: string | null;
|
||||||
@@ -57,147 +73,465 @@ export type TimeLog = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TimeSummary = {
|
export type TimeSummary = {
|
||||||
by_worker: { id: string; name: string; entry_count: number; total_hours: number }[];
|
by_worker: {
|
||||||
by_task: { id: string; name: string; name_es: string | null; entry_count: number; total_hours: number }[];
|
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 };
|
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.
|
function rowToTask(r: typeof timeTrackingTasks.$inferSelect): TimeTask {
|
||||||
return [];
|
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(
|
export async function createTimeWorker(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_role = "worker",
|
role: string = "worker",
|
||||||
_lang = "en"
|
lang: string = "en",
|
||||||
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
worker?: TimeWorker;
|
||||||
|
pin?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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 }> {
|
export async function resetTimeWorkerPin(
|
||||||
|
workerId: string,
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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(
|
export async function updateTimeWorker(
|
||||||
_workerId: string,
|
workerId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_role: string,
|
role: string,
|
||||||
_lang: string,
|
lang: string,
|
||||||
_active: boolean
|
active: boolean,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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 }> {
|
export async function deleteTimeWorker(
|
||||||
|
workerId: string,
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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[]> {
|
export async function getTimeTrackingTasks(
|
||||||
|
brandId: string,
|
||||||
await getSession(); return [];
|
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(
|
export async function createTimeTask(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_nameEs: string | null = null,
|
nameEs: string | null = null,
|
||||||
_unit = "hours",
|
unit: string = "hours",
|
||||||
_sortOrder = 0
|
sortOrder = 0,
|
||||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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(
|
export async function updateTimeTask(
|
||||||
_taskId: string,
|
taskId: string,
|
||||||
_name: string,
|
name: string,
|
||||||
_nameEs: string,
|
nameEs: string,
|
||||||
_unit: string,
|
unit: string,
|
||||||
_active: boolean,
|
active: boolean,
|
||||||
_sortOrder: number
|
sortOrder: number,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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 }> {
|
export async function deleteTimeTask(
|
||||||
|
taskId: string,
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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) ─────────────────
|
||||||
|
|
||||||
export async function getWorkerTimeLogs(
|
type LogListOptions = {
|
||||||
_brandId: string,
|
|
||||||
_options: {
|
|
||||||
workerId?: string;
|
workerId?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
start?: string;
|
start?: string; // ISO timestamp
|
||||||
end?: string;
|
end?: string; // ISO timestamp
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
} = {}
|
};
|
||||||
|
|
||||||
|
export async function getWorkerTimeLogs(
|
||||||
|
brandId: string,
|
||||||
|
options: LogListOptions = {},
|
||||||
): Promise<TimeLog[]> {
|
): 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(
|
function computeTotalMinutes(
|
||||||
_logId: string,
|
clockIn: Date,
|
||||||
_taskName: string,
|
clockOut: Date | null,
|
||||||
_clockIn: string,
|
lunchMinutes: number,
|
||||||
_clockOut: string | null,
|
): number {
|
||||||
_lunchMinutes: number,
|
if (!clockOut) return 0;
|
||||||
_notes: string | null
|
const ms = clockOut.getTime() - clockIn.getTime();
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
return Math.max(0, Math.round(ms / 60000) - lunchMinutes);
|
||||||
|
|
||||||
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" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTimeTrackingSummary(
|
export async function getTimeTrackingSummary(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_start: string,
|
start: string,
|
||||||
_end: string
|
end: string,
|
||||||
): Promise<TimeSummary> {
|
): 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 = {
|
export type TimeTrackingSettings = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -218,21 +552,55 @@ export type TimeTrackingSettings = {
|
|||||||
brand_name: string;
|
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.
|
export async function getTimeTrackingSettings(
|
||||||
return null;
|
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(
|
export async function updateTimeTrackingSettings(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_settings: {
|
settings: {
|
||||||
pay_period_start_day: number;
|
pay_period_start_day: number;
|
||||||
pay_period_length_days: number;
|
pay_period_length_days: number;
|
||||||
daily_overtime_threshold: number;
|
daily_overtime_threshold: number;
|
||||||
weekly_overtime_threshold: number;
|
weekly_overtime_threshold: number;
|
||||||
overtime_multiplier: number;
|
overtime_multiplier: number;
|
||||||
overtime_notifications: boolean;
|
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_emails?: string[];
|
||||||
notification_sms_numbers?: string[];
|
notification_sms_numbers?: string[];
|
||||||
enable_daily_alerts?: boolean;
|
enable_daily_alerts?: boolean;
|
||||||
@@ -241,15 +609,41 @@ export async function updateTimeTrackingSettings(
|
|||||||
weekly_alert_threshold?: number;
|
weekly_alert_threshold?: number;
|
||||||
send_end_of_period_summary?: boolean;
|
send_end_of_period_summary?: boolean;
|
||||||
brand_name?: string;
|
brand_name?: string;
|
||||||
}
|
},
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
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 = {
|
export type NotificationLogEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -267,9 +661,123 @@ export type NotificationLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getTimeTrackingNotificationLog(
|
export async function getTimeTrackingNotificationLog(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_limit = 100
|
limit = 100,
|
||||||
): Promise<NotificationLogEntry[]> {
|
): 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";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getSession } from "@/lib/auth";
|
import { withBrand } from "@/db/client";
|
||||||
|
import { timeTrackingNotificationLog } from "@/db/schema/time-tracking";
|
||||||
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
|
import { getTimeTrackingSettings, getWorkerPeriodTotals } from "./index";
|
||||||
// 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.
|
|
||||||
|
|
||||||
export type OvertimeCheckResult = {
|
export type OvertimeCheckResult = {
|
||||||
sent: boolean;
|
sent: boolean;
|
||||||
trigger_type?: string;
|
trigger_type?: "daily" | "weekly" | "both";
|
||||||
message?: string;
|
message?: string;
|
||||||
notification_log_id?: string;
|
notification_log_id?: string;
|
||||||
|
daily_hours?: number;
|
||||||
|
weekly_hours?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function checkAndNotifyOvertime(
|
export async function checkAndNotifyOvertime(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_workerId: string,
|
workerId: string,
|
||||||
_workerName: string,
|
workerName: string,
|
||||||
_dailyHours: number,
|
// The two hour args are accepted for back-compat with the original
|
||||||
_weeklyHours: number
|
// 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> {
|
): Promise<OvertimeCheckResult> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { sent: false, message: "Not authenticated" };
|
||||||
|
|
||||||
await getSession(); const adminUser = await getAdminUser();
|
const settings = await getTimeTrackingSettings(brandId);
|
||||||
if (!adminUser) {
|
if (!settings || !settings.overtime_notifications) {
|
||||||
return { sent: false, message: "Not authenticated" };
|
return { sent: false, message: "Notifications disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-derive totals from the source of truth so the notification log
|
||||||
|
// matches the actuals even if the caller passed stale numbers.
|
||||||
|
const totals = await getWorkerPeriodTotals(brandId, workerId, settings);
|
||||||
|
|
||||||
|
const dailyHit = totals.day_hours > totals.daily_threshold;
|
||||||
|
const weeklyHit = totals.week_hours > totals.weekly_threshold;
|
||||||
|
if (!dailyHit && !weeklyHit) {
|
||||||
|
return { sent: false, message: "Below thresholds" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger: "daily" | "weekly" | "both" = dailyHit && weeklyHit
|
||||||
|
? "both"
|
||||||
|
: dailyHit
|
||||||
|
? "daily"
|
||||||
|
: "weekly";
|
||||||
|
|
||||||
|
const subject = `Overtime alert: ${workerName}`;
|
||||||
|
const body =
|
||||||
|
`${workerName} crossed the ${trigger} overtime threshold.\n` +
|
||||||
|
`Day: ${totals.day_hours.toFixed(2)} h (threshold ${totals.daily_threshold})\n` +
|
||||||
|
`Week: ${totals.week_hours.toFixed(2)} h (threshold ${totals.weekly_threshold})\n` +
|
||||||
|
`(Reported: ${dailyHours.toFixed(2)} / ${weeklyHours.toFixed(2)})`;
|
||||||
|
|
||||||
|
// Insert a notification log row. status='pending' — actual email/SMS
|
||||||
|
// dispatch is out of Cycle 2 scope (notification_targets columns on
|
||||||
|
// time_tracking_settings land in a follow-up migration). A future
|
||||||
|
// cron will flip 'pending' → 'sent' / 'failed'.
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(timeTrackingNotificationLog)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
workerId,
|
||||||
|
notificationType: trigger,
|
||||||
|
recipient: "admin@pending",
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning({ id: timeTrackingNotificationLog.id });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sent: false,
|
sent: true,
|
||||||
message: "Time tracking is not configured in the SaaS rebuild",
|
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);
|
allWorkers.push(workers);
|
||||||
allSettings.push(settings);
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user