2026-10-21 04:39:15 +00:00
|
|
|
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 = {
|
2026-10-22 09:39:18 +00:00
|
|
|
id:string; employee:string; employee_no:string; employee_name:string; /** Empty on Plant scans; kept for older rows. */ site:string;
|
2026-10-21 04:39:15 +00:00
|
|
|
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); }
|
|
|
|
|
}
|
2026-10-22 09:39:18 +00:00
|
|
|
/** 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;
|
2026-10-21 04:39:15 +00:00
|
|
|
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];
|
|
|
|
|
}
|
2026-10-22 09:39:18 +00:00
|
|
|
/** 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);
|
|
|
|
|
}
|
2026-10-21 04:39:15 +00:00
|
|
|
}
|
2026-10-22 09:39:18 +00:00
|
|
|
type ScanInput = { employee_no:string; request_id:string };
|
2026-10-21 04:39:15 +00:00
|
|
|
function parseInput(raw:unknown):ScanInput {
|
2026-10-22 09:39:18 +00:00
|
|
|
if (!raw || typeof raw!=='object' || Array.isArray(raw)) throw new ClockError('Scan a badge.',400);
|
2026-10-21 04:39:15 +00:00
|
|
|
const b=raw as Record<string,unknown>;
|
2026-10-22 09:39:18 +00:00
|
|
|
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);
|
2026-10-21 04:39:15 +00:00
|
|
|
}
|
2026-10-22 09:39:18 +00:00
|
|
|
return {employee_no:b.employee_no.trim(),request_id:b.request_id};
|
2026-10-21 04:39:15 +00:00
|
|
|
}
|
|
|
|
|
const hash=(v:string)=>createHash('sha256').update(v).digest('hex');
|
2026-10-22 09:39:18 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-10-21 04:39:15 +00:00
|
|
|
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);
|
2026-10-22 09:39:18 +00:00
|
|
|
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.');
|
2026-10-21 04:39:15 +00:00
|
|
|
return existing;
|
2026-10-22 09:39:18 +00:00
|
|
|
};
|
|
|
|
|
const existing=await replay();if (existing) return existing;
|
2026-10-21 04:39:15 +00:00
|
|
|
const lock=await store.lock(employee.id);
|
|
|
|
|
try {
|
2026-10-22 09:39:18 +00:00
|
|
|
const saved=await replay();if (saved) return saved;
|
2026-10-21 04:39:15 +00:00
|
|
|
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;
|
2026-10-22 09:39:18 +00:00
|
|
|
if (last && gap<REPEAT_SCAN_MS) throw new ClockRepeat(employee.name,last);
|
|
|
|
|
const direction=clockAction(last,now);
|
2026-10-21 04:39:15 +00:00
|
|
|
return await store.create({id,employee:employee.id,employee_no:employee.employeeNo,employee_name:employee.name,
|
2026-10-22 09:39:18 +00:00
|
|
|
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});
|
2026-10-21 04:39:15 +00:00
|
|
|
} finally { await store.unlock(lock); }
|
|
|
|
|
}
|