mega-plant/lib/clock.ts

80 lines
4.3 KiB
TypeScript
Raw Permalink Normal View History

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<ClockEmployee[]>;
latest(employee:string):Promise<ClockEvent|null>;
event(id:string):Promise<ClockEvent|null>;
lock(employee:string):Promise<string>;
unlock(id:string):Promise<void>;
create(event:ClockEvent):Promise<ClockEvent>;
}
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<string,unknown>;
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<REPEAT_SCAN_MS) throw new ClockRepeat(employee.name,last);
const direction=clockAction(last,now);
return await store.create({id,employee:employee.id,employee_no:employee.employeeNo,employee_name:employee.name,
site:'',direction,scanned_at:now,actor,previous_event:last?.id??'',request_signature:signature,
session_minutes:direction==='out'?Math.floor(gap/60_000):0,has_location:false});
} finally { await store.unlock(lock); }
}