MEGAINFRA-WEBAPP/src/app/api/clock/scan/route.ts
DEls 708e0b2742 feat(clock): a scan at another site moves workers and vehicles there automatically
A destination scan now ends the worker's previous site session at that moment
(transfer OUT, hours booked at the origin) and opens the new one, so nothing is
left for the office to review. Vehicle scans take a scanning site, move the unit
there and log the reading against it. Adds a script to close already-flagged moves.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 06:34:05 +02:00

123 lines
11 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { dateLogged, workDate } from "@/lib/clock-day";
import { clockDayPaidMinutes } from "@/lib/clock-session-hours";
import type { ClockEventRecord, ClockMissedOutCorrectionRecord, EmployeeRecord, TimesheetEntryRecord } from "@/lib/pb-types";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { isWorkerActive } from "@/lib/worker-status";
const escapePb = (value: string) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const idFor = (value: string) => createHash("sha256").update(value).digest("hex").slice(0,15);
const missing = (error: unknown) => (error as {status?:number})?.status === 404;
const fail = (error: string, status: number) => NextResponse.json({ error }, { status });
export async function POST(request: Request) {
const user = await requireApiUser("clock"); if (user instanceof Response) return user;
let body: Record<string, unknown>;
try {
const parsed = await request.json();
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return fail("Body must be a JSON object",400);
body = parsed;
} catch { return fail("Body must be JSON",400); }
const employeeNo = typeof body.employee_no === "string" ? body.employee_no.trim() : "";
if (!employeeNo) return fail("employee_no is required",400);
if (body.site === undefined || !Object.hasOwn(body,"expected_event_id") || body.request_id === undefined || body.expected_employee_id === undefined) return fail("Refresh MEGA Clock before scanning. The updated app requires a scanning site and a fresh confirmation.",409);
if (body.site !== undefined && (typeof body.site !== "string" || !/^[a-z0-9]{15}$/.test(body.site))) return fail("Choose a valid scanning site",400);
if (body.expected_event_id !== undefined && body.expected_event_id !== null && (typeof body.expected_event_id !== "string" || !/^[a-z0-9]{15}$/.test(body.expected_event_id))) return fail("Invalid scan confirmation. Scan the badge again.",400);
if (body.request_id !== undefined && (typeof body.request_id !== "string" || !/^[a-zA-Z0-9-]{16,64}$/.test(body.request_id))) return fail("Invalid scan request",400);
if (typeof body.expected_employee_id !== "string" || !/^[a-z0-9]{15}$/.test(body.expected_employee_id)) return fail("Invalid worker confirmation. Scan the badge again.",400);
if (body.expected_direction !== undefined && !["in","out"].includes(String(body.expected_direction))) return fail("Invalid scan direction",400);
if (body.expected_work_date !== undefined && (typeof body.expected_work_date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(body.expected_work_date))) return fail("Invalid scan date",400);
const hasLat = body.latitude !== undefined && body.latitude !== null;
const hasLng = body.longitude !== undefined && body.longitude !== null;
if (hasLat !== hasLng || (hasLat && (typeof body.latitude !== "number" || !Number.isFinite(body.latitude) || Math.abs(body.latitude)>90 || typeof body.longitude !== "number" || !Number.isFinite(body.longitude) || Math.abs(body.longitude)>180))) return fail("Supply valid latitude and longitude together",400);
if (body.location_accuracy_m !== undefined && body.location_accuracy_m !== null && (!hasLat || typeof body.location_accuracy_m !== "number" || !Number.isFinite(body.location_accuracy_m) || body.location_accuracy_m<0)) return fail("Location accuracy requires coordinates and must be non-negative",400);
const gps = { has_location: hasLat, ...(hasLat ? { latitude: body.latitude, longitude: body.longitude, location_accuracy_m: body.location_accuracy_m ?? 0 } : {}) };
try {
const pb = await pbAdmin();
let employee: EmployeeRecord;
try {
const matches = await pb.collection("employees").getList<EmployeeRecord>(1,2,{filter:`employee_no:lower="${escapePb(employeeNo.toLowerCase())}"`});
if (matches.items.length>1) return fail("Duplicate employee number. The office must resolve this badge before it can be scanned.",409);
if (!matches.items[0]) return fail("Unknown badge",404);
employee=matches.items[0];
}
catch(error) { if (missing(error)) return fail("Unknown badge",404); throw error; }
// A known badge can move into a scanner's authorised site. Private worker
// records and roster search remain restricted by their existing rules.
const site = typeof body.site === "string" ? body.site : employee.site;
const denied = requireSiteAccess(user,site); if (denied) return denied;
try { await pb.collection("sites").getOne(site,{fields:"id"}); }
catch(error) { if (missing(error)) return fail("Scanning site is unavailable",404); throw error; }
if (employee.id !== body.expected_employee_id) return fail("This badge now identifies a different worker. Scan again before confirming.",409);
const requestId = typeof body.request_id === "string" ? idFor(`clock-request:${employee.id}:${body.request_id}`) : null;
if (requestId) {
try {
const saved = await pb.collection("clock_events").getOne<ClockEventRecord>(requestId);
if (saved.site !== site) return fail("This request was already used at another site. Scan again.",409);
return NextResponse.json({direction:saved.direction,employeeName:employee.name,at:saved.scanned_at,siteId:saved.site,duplicate:true,hasFix:saved.has_location===true});
} catch(error) { if (!missing(error)) throw error; }
}
let lock: {id:string};
try { lock = await pb.collection("clock_correction_locks").create({employee:employee.id,owner:randomUUID()}); }
catch(error) { if ([400,409].includes((error as {status:number}).status)) return fail("Another scan or correction is being saved for this worker. Try again.",423); throw error; }
try {
// Re-read identity as well as scans: a number change/removal may have won the lock.
const currentEmployee = await pb.collection("employees").getOne<EmployeeRecord>(employee.id, { requestKey: null });
if (currentEmployee.deleted_at || String(currentEmployee.employee_no ?? "").trim().toUpperCase() !== employeeNo.toUpperCase()) return fail("This badge changed or the worker was removed. Scan their current badge again.",409);
employee = currentEmployee;
// Re-read after acquiring the cross-process lock; a cached lookup is not authority.
let last: ClockEventRecord | null = null;
try { last = await pb.collection("clock_events").getFirstListItem<ClockEventRecord>(`employee="${employee.id}"`,{sort:"-scanned_at",requestKey:null}); }
catch(error) { if (!missing(error)) throw error; }
if (body.expected_event_id !== undefined && body.expected_event_id !== (last?.id ?? null)) return fail("Another scan just landed. Scan the badge again before confirming.",409);
const now = new Date().toISOString(); const today = workDate(now);
if (body.expected_work_date !== undefined && body.expected_work_date !== today) return fail("A new work day has started. Scan the badge again.",409);
if (last && (Date.parse(now)-Date.parse(last.scanned_at)<1000 || (Date.parse(now)-Date.parse(last.scanned_at)<10_000 && !(body.allow_recent===true && body.expected_event_id===last.id)))) return fail("This worker was just scanned. Scan again and confirm the recent-scan warning if intentional.",409);
const open = last?.direction === "in" && workDate(last.scanned_at) === today;
const transfer = !!open && last!.site !== site;
const direction = open && !transfer ? "out" : "in";
if (body.expected_direction !== undefined && body.expected_direction !== direction) return fail("The clock action has changed. Scan the badge again.",409);
if (direction === "in" && !isWorkerActive(employee)) return fail("Worker is inactive. Reactivate them before clocking in.",409);
const eventId = requestId ?? idFor(`clock:${employee.id}:${today}:${last?.id??"first"}:${direction}`);
const transferOutId = idFor(`clock-transfer-out:${employee.id}:${today}:${last?.id??"first"}`);
const batch = pb.createBatch(); let operationCount = 0;
let sessionHours: number | undefined; let dailyTotalHours: number | undefined;
if (open && last) {
// Scanning at another site is the worker's physical move: it closes the
// origin session at the scan time (a transfer OUT, no shift-end rounding)
// and the same scan opens the new one. Nothing is left for the office.
const outAt = transfer ? new Date(Date.parse(now)-1).toISOString() : now;
const outId = transfer ? transferOutId : eventId;
const dayEvents = await pb.collection("clock_events").getFullList<ClockEventRecord>({filter:`employee="${employee.id}" && scanned_at>="${new Date(Date.parse(dateLogged(today))-7_200_000).toISOString()}" && scanned_at<="${now}"`,sort:"scanned_at",requestKey:null});
const corrections = await pb.collection("clock_missed_out_corrections").getFullList<ClockMissedOutCorrectionRecord>({filter:`employee="${employee.id}"`,requestKey:null});
const before=clockDayPaidMinutes(dayEvents,corrections,today);
const after=clockDayPaidMinutes([...dayEvents,{...last,id:outId,direction:"out",scanned_at:outAt,is_transfer:transfer}],corrections,today);
sessionHours=((after.get(last.site)??0)-(before.get(last.site)??0))/60;
const entryDate = dateLogged(today);
const entries = await pb.collection("timesheet_entries").getFullList<TimesheetEntryRecord>({filter:`employee="${employee.id}" && site="${escapePb(last.site)}" && date_logged="${entryDate.replace("T"," ")}"`,requestKey:null});
if (entries.length > 1 || entries.some(entry=>entry.timesheet)) return fail("This day already has uploaded or overlapping hours at the site. Review the timesheet before adding QR hours.",409);
const entry = entries[0]; const entryId = entry?.id ?? idFor(`clock-direct:${employee.id}:${last.site}:${today}`);
dailyTotalHours = (Math.round((entry?.hours_worked ?? 0)*60) + Math.round(sessionHours*60))/60;
if (entry) batch.collection("timesheet_entries").update(entry.id,{hours_worked:dailyTotalHours});
else batch.collection("timesheet_entries").create({id:entryId,employee:employee.id,site:last.site,date_logged:entryDate,hours_worked:dailyTotalHours,import_key:`${employee.id}:${last.site}:${today}`});
operationCount++;
batch.collection("clock_events").create({id:outId,employee:employee.id,site:last.site,direction:"out",scanned_at:outAt,timesheet_entry:entryId,is_transfer:transfer,...gps});operationCount++;
}
if (direction === "in") {
batch.collection("clock_events").create({id:eventId,employee:employee.id,site,direction:"in",scanned_at:now,...gps}); operationCount++;
if (employee.site !== site) { batch.collection("employees").update(employee.id,{site}); operationCount++; }
}
const results = await batch.send({requestKey:null});
if (results.length !== operationCount || results.some(row=>row.status<200||row.status>=300)) throw new Error("Clock transaction could not be verified");
return NextResponse.json({direction,employeeName:employee.name,at:now,siteId:site,hasFix:hasLat,transferred:transfer,sessionHours,dailyTotalHours});
} finally {
try { await pb.collection("clock_correction_locks").delete(lock.id); }
catch(error) { console.error("Could not release clock scan lock",lock.id,error); }
}
} catch(error) {
console.error("Clock scan could not be saved",error);
return fail("Could not save this scan. Please try again; the same confirmed request is safe to retry.",503);
}
}