// Additive only. Never seeds, copies, deletes or updates employee/financial rows. // node --env-file=.env.local scripts/setup-clock.mjs [--apply] import PocketBase from 'pocketbase'; const apply=process.argv.includes('--apply'); try { const pb=new PocketBase(process.env.NEXT_PUBLIC_PB_URL); pb.autoCancellation(false); await pb.collection('_superusers').authWithPassword(process.env.PB_ADMIN,process.env.PB_PASS); const sites=await pb.collections.getOne('sites'); const users=await pb.collections.getOne('users'); const schemas=[ {name:'clock_events',fields:[ {name:'employee',type:'text',required:true}, {name:'employee_no',type:'text',required:true},{name:'employee_name',type:'text',required:true}, // Plant staff clock in at the Plant, not a site. Optional so older rows keep theirs. {name:'site',type:'relation',collectionId:sites.id,required:false,maxSelect:1,cascadeDelete:false}, {name:'direction',type:'select',required:true,maxSelect:1,values:['in','out']}, {name:'scanned_at',type:'date',required:true}, {name:'actor',type:'relation',collectionId:users.id,required:true,maxSelect:1,cascadeDelete:false}, {name:'previous_event',type:'text'},{name:'request_signature',type:'text',required:true}, {name:'session_minutes',type:'number',min:0,onlyInt:true}, {name:'has_location',type:'bool'}, {name:'latitude',type:'number',min:-90,max:90}, {name:'longitude',type:'number',min:-180,max:180},{name:'location_accuracy_m',type:'number',min:0}, ], indexes:[ 'CREATE INDEX idx_plant_clock_employee_time ON clock_events (employee, scanned_at DESC)', 'CREATE UNIQUE INDEX idx_plant_clock_previous ON clock_events (employee, previous_event)', ]}, {name:'clock_scan_locks',fields:[{name:'employee',type:'text',required:true},{name:'created',type:'autodate',onCreate:true,onUpdate:false}], indexes:['CREATE UNIQUE INDEX idx_plant_clock_lock ON clock_scan_locks (employee)']}, ]; for(const schema of schemas) { let existing; try {existing=await pb.collections.getOne(schema.name);}catch(e){if(e.status!==404)throw e;} if(existing) { const missing=schema.fields.filter(f=>!existing.fields.some(h=>h.name===f.name && h.type===f.type)); if(missing.length || schema.indexes.some(i=>!existing.indexes.includes(i)) || ['listRule','viewRule','createRule','updateRule','deleteRule'].some(r=>existing[r]!==null)) { throw new Error(`${schema.name} schema/rules differ from the clock contract; review before changing existing data.`); } // Relax-only migration: the first build required a scanning site. const site=existing.fields.find(f=>f.name==='site'); if(schema.name==='clock_events' && site?.required) { if(!apply){console.log('clock_events.site: would make optional (Plant scans have no site)');continue;} await pb.collections.update(existing.id,{fields:existing.fields.map(f=>f.name==='site'?{...f,required:false}:f)}); console.log('clock_events.site: now optional');continue; } console.log(`${schema.name}: already ready`);continue; } if(!apply){console.log(`${schema.name}: would create private collection`);continue;} await pb.send('/api/collections',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ type:'base',...schema,listRule:null,viewRule:null,createRule:null,updateRule:null,deleteRule:null, })}); console.log(`${schema.name}: created (server access only)`); } } catch(e) {console.error('Clock setup failed:',e.status??'',e.message);process.exitCode=1;}