MEGAINFRA-WEBAPP/src/app/api/clock/employee-lookup/route.ts
DEls 0a7138f60c fix: harden workforce, plant and financials
Improve workforce scanning, site transfers and timesheet exports.
Audit plant imports, duplicate identities, rates and dashboard totals.
Repair delivery PO selection, periodic rechecks and receiving guards.
Align financial ledgers, supplier periods and mobile layouts.
Add the site map preview, shared theme controls and audit evidence.

Verify 3,455 application tests, six fuel export tests, TypeScript and
an optimized production build. Clean up a leaked test timer and keep
local worktrees and generated reports out of ESLint traversal.

Changed-file lint passes; repository-wide lint retains 33 existing
errors in unchanged files.
2026-10-19 13:18:31 +02:00

108 lines
4.5 KiB
TypeScript

import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { workDate } from "@/lib/clock-day";
import type { ClockEventRecord, EmployeeRecord } from "@/lib/pb-types";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
import { isWorkerActive } from "@/lib/worker-status";
function escapePb(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/**
* GET /api/clock/employee-lookup?employee_no=X
*
* Called by the MEGA Clock Person scanner the instant a badge QR is
* decoded, before the scan is written. Looks up the employee and
* reports the most recent clock_event so the kiosk can:
*
* 1. Show the operator *who* is about to be scanned (the badge's
* employee number is opaque; the human's name is not).
* 2. Tell them whether this will be an "in" or an "out" (derived
* from the last event — null last event implies "in").
* 3. Warn if the last scan was recent — accidental in/out toggles
* used to be possible in one wrist-flick.
*
* Distinct from /api/clock/scan, which writes the row: this route is
* read-only and safe to spam. The Person scanner calls it once per
* decoded badge, gated by the existing SCAN_COOLDOWN_MS so the same
* code can't double-fire from frame-to-frame jitter.
*
* Auth: server-only (pbAdmin). The /clock PWA sits behind the PIN
* gate, but the underlying transport is open — same posture as
* /api/clock/scan, /api/clock/vehicle-lookup, etc.
*
* Response shape (200):
* {
* employee: { id, name, employeeNo, siteId },
* lastEvent: { direction: "in"|"out", scannedAt } | null,
* nextDirection: "in" | "out" // what /api/clock/scan will write
* }
*/
export async function GET(request: Request) {
const user = await requireApiUser("clock");
if (user instanceof Response) return user;
const url = new URL(request.url);
const raw = url.searchParams.get("employee_no") ?? "";
const employeeNo = raw.trim();
if (!employeeNo) {
return NextResponse.json({ error: "employee_no is required" }, { status: 400 });
}
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 NextResponse.json({error:"Duplicate employee number. The office must resolve this badge before it can be scanned."},{status:409});
if (!matches.items[0]) return NextResponse.json({error:"Unknown badge"},{status:404});
employee=matches.items[0];
} catch (error) {
return NextResponse.json({ error: (error as {status?:number}).status===404?"Unknown badge":"Employee lookup unavailable" }, { status: (error as {status?:number}).status===404?404:503 });
}
const site = url.searchParams.get("site") || employee.site;
if (!/^[a-z0-9]{15}$/.test(site)) return NextResponse.json({error:"Choose a valid scanning site"},{status:400});
const denied = requireSiteAccess(user, site);
if (denied) return denied;
let lastEvent: { id: string; siteId: string; direction: "in" | "out"; scannedAt: string } | null = null;
try {
const latest = await pb
.collection("clock_events")
.getFirstListItem<ClockEventRecord>(`employee="${employee.id}"`, {
sort: "-scanned_at",
});
if (latest) {
lastEvent = {
id: latest.id,
siteId: latest.site,
direction: latest.direction,
scannedAt: latest.scanned_at,
};
}
} catch (error) {
if ((error as {status?:number}).status !== 404) return NextResponse.json({error:"Scan history unavailable. Try again."},{status:503});
}
// Each South African calendar day starts independently. An unmatched
// earlier-day IN remains an exception; it never becomes today's OUT.
const today = workDate(new Date().toISOString());
const open = lastEvent?.direction === "in" && workDate(lastEvent.scannedAt) === today;
const transfer = !!open && !!lastEvent?.siteId && lastEvent.siteId !== site;
const nextDirection: "in" | "out" = open && !transfer ? "out" : "in";
if (nextDirection === "in" && !isWorkerActive(employee)) return NextResponse.json({ error: "Worker is inactive. Reactivate them before clocking in." }, { status: 409 });
return NextResponse.json({
employee: {
id: employee.id,
name: employee.name,
employeeNo: employee.employee_no,
siteId: employee.site,
},
lastEvent,
nextDirection,
transfer,
destinationSiteId: site,
workDate: today,
}, { headers: { "Cache-Control": "no-store" } });
}