import { employeeCodeFromBadge } from './clock-badge'; import { createHash } from 'node:crypto'; import { workDate, clockAction } from './clock-rules'; export { workDate, clockAction, missedClockIns } from './clock-rules'; export type ClockEmployee = { id:string; name:string; employeeNo:string; role:string; asset:string }; export type ClockEvent = { id:string; employee:string; employee_no:string; employee_name:string; /** Empty on Plant scans; kept for older rows. */ site:string; direction:'in'|'out'; scanned_at:string; actor:string; previous_event:string; session_minutes:number; has_location:boolean; latitude?:number; longitude?:number; location_accuracy_m?:number; request_signature?:string; }; export interface ClockStore { employees():Promise; latest(employee:string):Promise; event(id:string):Promise; lock(employee:string):Promise; unlock(id:string):Promise; create(event:ClockEvent):Promise; } export class ClockError extends Error { constructor(message:string, public status=409) { super(message); } } /** A badge held in front of the camera is read several times a second; anything inside this window is that, not a real IN/OUT. */ export const REPEAT_SCAN_MS = 60_000; async function findEmployee(store:ClockStore, raw:string) { const code=employeeCodeFromBadge(raw); if (!code) throw new ClockError('Enter an employee code (up to 100 characters).',400); const matches=(await store.employees()).filter(e=>e.employeeNo.trim().toUpperCase()===code.trim().toUpperCase()); if (matches.length>1) throw new ClockError('Duplicate employee code. Ask the office to resolve this badge.'); if (!matches[0]) throw new ClockError('Unknown badge. Check the employee code.',404); return matches[0]; } /** The same badge read again straight away. Carries the scan that already counts, so the screen can say so. */ export class ClockRepeat extends ClockError { constructor(public employeeName:string, public last:ClockEvent) { super(`${employeeName} was already clocked ${last.direction.toUpperCase()} just now.`,409); } } type ScanInput = { employee_no:string; request_id:string }; function parseInput(raw:unknown):ScanInput { if (!raw || typeof raw!=='object' || Array.isArray(raw)) throw new ClockError('Scan a badge.',400); const b=raw as Record; if (typeof b.employee_no!=='string' || !b.employee_no.trim() || b.employee_no.length>300 || typeof b.request_id!=='string' || !/^[a-zA-Z0-9-]{16,64}$/.test(b.request_id)) { throw new ClockError('Invalid scan. Scan the badge again.',400); } return {employee_no:b.employee_no.trim(),request_id:b.request_id}; } const hash=(v:string)=>createHash('sha256').update(v).digest('hex'); /** * One scan, one record. The server decides IN or OUT from the employee's last * scan — there is no site and no confirmation step: Plant staff clock in and * out of the Plant itself. A repeat inside REPEAT_SCAN_MS is refused, not saved. */ export async function saveClock(store:ClockStore, raw:unknown, actor:string, instant?:string) { const input=parseInput(raw); const employee=await findEmployee(store,input.employee_no); const id=hash(`plant-clock:${employee.id}:${input.request_id}`).slice(0,15); const signature=hash(JSON.stringify({employee:employee.id,request_id:input.request_id,actor})); const replay=async()=>{ const existing=await store.event(id); if (existing && existing.request_signature!==signature) throw new ClockError('This scan belongs to another request. Scan again.'); return existing; }; const existing=await replay();if (existing) return existing; const lock=await store.lock(employee.id); try { const saved=await replay();if (saved) return saved; const last=await store.latest(employee.id); const now=instant??new Date().toISOString(); const gap=last ? Date.parse(now)-Date.parse(last.scanned_at) : Infinity; if (last && gap