mega-plant/lib/clock.ts

107 lines
6.9 KiB
TypeScript

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 ScanLocation = { latitude:number; longitude:number; accuracy:number };
export type ClockEvent = {
id:string; employee:string; employee_no:string; employee_name:string; 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[]>;
siteExists(id:string):Promise<boolean>;
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); }
}
const recordId = /^[a-z0-9]{15}$/;
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];
}
async function checkSite(store:ClockStore, site:string) {
if (!recordId.test(site)) throw new ClockError('Choose a scanning site.',400);
if (!await store.siteExists(site)) throw new ClockError('Scanning site is unavailable.',404);
}
export async function lookupClock(store:ClockStore, code:string, site:string, now=new Date().toISOString()) {
await checkSite(store,site);
const employee=await findEmployee(store,code);
const lastEvent=await store.latest(employee.id);
return {employee,lastEvent,nextDirection:clockAction(lastEvent,site,now),workDate:workDate(now),siteId:site,
transfer:lastEvent?.direction==='in' && workDate(lastEvent.scanned_at)===workDate(now) && lastEvent.site!==site};
}
export type ClockLookup = Awaited<ReturnType<typeof lookupClock>>;
type ScanInput = { employee_no:string; site:string; expected_employee_id:string; expected_event_id:string|null;
expected_direction:'in'|'out'; expected_work_date:string; request_id:string; allow_recent:boolean; location:ScanLocation|null };
function parseInput(raw:unknown):ScanInput {
if (!raw || typeof raw!=='object' || Array.isArray(raw)) throw new ClockError('Supply a scan confirmation.',400);
const b=raw as Record<string,unknown>;
if (typeof b.employee_no!=='string' || !b.employee_no.trim() || b.employee_no.length>100 ||
typeof b.site!=='string' || !recordId.test(b.site) || typeof b.expected_employee_id!=='string' ||
!/^(operators|mechanics):[a-z0-9]{15}$/.test(b.expected_employee_id) ||
(b.expected_event_id!==null && (typeof b.expected_event_id!=='string' || !recordId.test(b.expected_event_id))) ||
!['in','out'].includes(String(b.expected_direction)) || typeof b.expected_work_date!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(b.expected_work_date) ||
typeof b.request_id!=='string' || !/^[a-zA-Z0-9-]{16,64}$/.test(b.request_id) || typeof b.allow_recent!=='boolean') {
throw new ClockError('Invalid confirmation. Scan the badge again.',400);
}
if (b.location!==null) {
const l=b.location as ScanLocation|undefined;
if (!l || typeof l!=='object' || !Number.isFinite(l.latitude) || Math.abs(l.latitude)>90 ||
!Number.isFinite(l.longitude) || Math.abs(l.longitude)>180 || !Number.isFinite(l.accuracy) || l.accuracy<0) {
throw new ClockError('Supply valid GPS coordinates, or confirm without GPS.',400);
}
}
return {employee_no:b.employee_no.trim(),site:b.site,expected_employee_id:b.expected_employee_id,expected_event_id:b.expected_event_id as string|null,
expected_direction:b.expected_direction as 'in'|'out',expected_work_date:b.expected_work_date,request_id:b.request_id,
allow_recent:b.allow_recent,location:b.location as ScanLocation|null};
}
const hash=(v:string)=>createHash('sha256').update(v).digest('hex');
export async function saveClock(store:ClockStore, raw:unknown, actor:string, instant?:string) {
const input=parseInput(raw);
const employee=await findEmployee(store,input.employee_no);
if (employee.id!==input.expected_employee_id) throw new ClockError('This badge changed. Scan again.');
const id=hash(`plant-clock:${employee.id}:${input.request_id}`).slice(0,15);
const signature=hash(JSON.stringify({...input,employee_no:employee.employeeNo.toUpperCase(),actor}));
const existing=await store.event(id);
if (existing) {
if (existing.request_signature!==signature) throw new ClockError('This request belongs to another confirmation. Scan again.');
return existing;
}
await checkSite(store,input.site);
const lock=await store.lock(employee.id);
try {
const saved=await store.event(id);
if(saved) {
if(saved.request_signature!==signature)throw new ClockError('This request belongs to another confirmation. Scan again.');
return saved;
}
// Resolve again under the shared lock: a code must still name this employee.
if ((await findEmployee(store,input.employee_no)).id!==employee.id) throw new ClockError('This badge changed. Scan again.');
const last=await store.latest(employee.id);
const now=instant??new Date().toISOString();
if ((last?.id??null)!==input.expected_event_id) throw new ClockError('Another scan just landed. Scan the badge again.');
if (workDate(now)!==input.expected_work_date) throw new ClockError('A new work day has started. Scan again.');
const gap=last ? Date.parse(now)-Date.parse(last.scanned_at) : Infinity;
if (gap<1000) throw new ClockError('This worker was just scanned. Wait before scanning again.');
if (gap<3_600_000 && !input.allow_recent) throw new ClockError('Recent scan. Scan again and confirm the warning if intentional.');
const direction=clockAction(last,input.site,now);
if (direction!==input.expected_direction) throw new ClockError('The clock action changed. Scan again.');
return await store.create({id,employee:employee.id,employee_no:employee.employeeNo,employee_name:employee.name,
site:input.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:!!input.location,
...(input.location?{latitude:input.location.latitude,longitude:input.location.longitude,location_accuracy_m:input.location.accuracy}:{})});
} finally { await store.unlock(lock); }
}