107 lines
6.9 KiB
TypeScript
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); }
|
|
}
|