'use strict' const cfg = require('./config') const { log, json } = require('./helpers') const erp = require('./erp') const { verifyJwt } = require('./magic-link') const { extractField } = require('./vision') const ui = require('./ui') // ── Tech mobile SPA ────────────────────────────────────────────────────────── // Server-rendered shell + hash-routed client views (home/hist/cal/profile/job-detail). // All UI primitives come from ../ui (design.css, components, client.js, scanner.js). // All jobs pre-loaded and embedded as JSON; detail view pops open instantly from // the cache and async-refreshes notes / photos / equipment. // // Field-targeted scans: each form field has a 📷 that opens a Gemini-backed // single-field extractor — "find the Wi-Fi password on this label" rather than // "read everything". Tech approves or retries the value before it fills the input. // ═════════════════════════════════════════════════════════════════════════════ // Auth + request helpers // ═════════════════════════════════════════════════════════════════════════════ function authToken (path) { const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/) return m ? verifyJwt(m[1]) : null } async function readBody (req) { const chunks = [] for await (const c of req) chunks.push(c) try { return JSON.parse(Buffer.concat(chunks).toString()) } catch { return null } } // ═════════════════════════════════════════════════════════════════════════════ // Route dispatcher // ═════════════════════════════════════════════════════════════════════════════ async function route (req, res, method, path) { if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path) if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path) if (method === 'POST' && path.endsWith('/note')) return handleNote(req, res, path) if (method === 'POST' && path.endsWith('/photo')) return handlePhotoUpload(req, res, path) if (method === 'GET' && path.endsWith('/photos')) return handlePhotoList(req, res, path) if (method === 'GET' && path.endsWith('/photo-serve')) return handlePhotoServe(req, res, path) if (method === 'GET' && path.endsWith('/job')) return handleJobDetail(req, res, path) if (method === 'POST' && path.endsWith('/field-scan')) return handleFieldScan(req, res, path) if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path) if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path) if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path) if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path) if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path) if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path) return json(res, 404, { error: 'Not found' }) } // ═════════════════════════════════════════════════════════════════════════════ // Page shell + data load // ═════════════════════════════════════════════════════════════════════════════ async function handlePage (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(200, ui.htmlHeaders()); return res.end(ui.pageExpired()) } const techId = payload.sub // Montreal local time — UTC midnight rolls over 4-5 h before Montreal midnight, // which would mislabel evening jobs as "hier". const today = ui.montrealDate() const rearDate = ui.montrealDate(new Date(Date.now() - 60 * 86400 * 1000)) const frontDate = ui.montrealDate(new Date(Date.now() + 60 * 86400 * 1000)) try { const tech = await erp.get('Dispatch Technician', techId, { fields: ['name', 'full_name', 'phone', 'email', 'assigned_group'], }) || { name: techId, full_name: techId } // Real time field is `start_time`; customer_name / service_location_name // are fetched-from links that v16 blocks in queries, so we pull the link // IDs and resolve labels via hydrateLabels. const jobs = await erp.list('Dispatch Job', { fields: ['name', 'subject', 'status', 'customer', 'service_location', 'start_time', 'scheduled_date', 'duration_h', 'priority', 'job_type', 'notes', 'assigned_group', 'address', 'ticket_id', 'source_issue', 'actual_start', 'actual_end'], filters: [ ['assigned_tech', '=', techId], ['scheduled_date', 'between', [rearDate, frontDate]], ], orderBy: 'scheduled_date desc, start_time asc', limit: 300, }) await erp.hydrateLabels(jobs, { customer: { doctype: 'Customer', out: 'customer_name', fields: ['name', 'customer_name'] }, service_location: { doctype: 'Service Location', out: 'service_location_name', fields: ['name', 'address_line_1', 'city'], format: l => [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name }, }) const token = path.split('/')[2] res.writeHead(200, ui.htmlHeaders()) res.end(renderPage({ tech, jobs, token, today })) } catch (e) { log('tech-mobile page error:', e.message) res.writeHead(500, ui.htmlHeaders()) res.end(ui.pageError()) } } // ═════════════════════════════════════════════════════════════════════════════ // API handlers (unchanged behavior — just cleaner layout) // ═════════════════════════════════════════════════════════════════════════════ async function handleStatus (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' }) if (!['In Progress', 'Completed'].includes(body.status)) return json(res, 400, { error: 'Invalid status' }) try { const { setJobStatusWithChain } = require('./dispatch') const result = await setJobStatusWithChain(body.job, body.status) return json(res, 200, { ...result, tech: payload.sub }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleNote (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job) return json(res, 400, { error: 'job required' }) try { const r = await erp.update('Dispatch Job', body.job, { notes: body.notes || '' }) if (!r.ok) return json(res, r.status || 500, { error: r.error }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handlePhotoUpload (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.job || !body?.image) return json(res, 400, { error: 'job and image required' }) try { const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') const ext = (body.image.match(/^data:image\/([a-z]+);/) || [, 'jpg'])[1].replace('jpeg', 'jpg') const fileName = body.file_name || `dj-${body.job}-${Date.now()}.${ext}` const r = await erp.create('File', { file_name: fileName, is_private: 1, content: base64, decode: 1, attached_to_doctype: 'Dispatch Job', attached_to_name: body.job, }) if (!r.ok) return json(res, r.status || 500, { error: r.error }) return json(res, 200, { ok: true, name: r.data?.name, file_url: r.data?.file_url, file_name: r.data?.file_name }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handlePhotoList (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const url = new URL(req.url, 'http://localhost') const jobName = url.searchParams.get('job') if (!jobName) return json(res, 400, { error: 'job required' }) try { const items = await erp.list('File', { fields: ['name', 'file_name', 'file_url', 'is_private', 'creation'], filters: [ ['attached_to_doctype', '=', 'Dispatch Job'], ['attached_to_name', '=', jobName], ['file_name', 'like', 'dj-%'], ], orderBy: 'creation desc', limit: 50, }) return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } // Proxy a private file so the tech browser can render without ERPNext creds. async function handlePhotoServe (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(401); return res.end() } const url = new URL(req.url, 'http://localhost') const filePath = url.searchParams.get('p') if (!filePath || !/^\/(private\/files|files)\//.test(filePath)) { res.writeHead(400); return res.end() } try { const erpBase = (cfg.ERPNEXT_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '') const headers = {} if (cfg.ERPNEXT_API_KEY && cfg.ERPNEXT_API_SECRET) { headers.Authorization = `token ${cfg.ERPNEXT_API_KEY}:${cfg.ERPNEXT_API_SECRET}` } const r = await fetch(erpBase + filePath, { headers }) if (!r.ok) { res.writeHead(404); return res.end() } const ct = r.headers.get('content-type') || 'application/octet-stream' const buf = Buffer.from(await r.arrayBuffer()) res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'private, max-age=300' }) res.end(buf) } catch (e) { log('photo-serve error:', e.message) res.writeHead(500); res.end() } } async function handleJobDetail (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const url = new URL(req.url, 'http://localhost') const name = url.searchParams.get('name') if (!name) return json(res, 400, { error: 'name required' }) try { const job = await erp.get('Dispatch Job', name) if (!job) return json(res, 404, { error: 'not found' }) return json(res, 200, { ok: true, job }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleFieldScan (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.image || !body?.field) return json(res, 400, { error: 'image and field required' }) try { const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') const out = await extractField(base64, body.field, { hint: body.hint, equipment_type: body.equipment_type, brand: body.brand, model: body.model, }) return json(res, 200, { ok: true, ...out }) } catch (e) { log('field-scan error:', e.message) return json(res, 500, { error: e.message }) } } async function handleScan (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.code) return json(res, 400, { error: 'code required' }) const q = body.code.replace(/[:\-\s]/g, '').toUpperCase() try { for (const field of ['serial_number', 'mac_address']) { const rows = await erp.list('Service Equipment', { fields: ['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location'], filters: [[field, 'like', `%${q}%`]], limit: 5, }) if (rows.length) return json(res, 200, { ok: true, results: rows, source: 'Service Equipment', query: q }) } const sn = await erp.list('Serial No', { fields: ['name', 'serial_no', 'item_code', 'status', 'warehouse'], filters: [['serial_no', 'like', `%${q}%`]], limit: 5, }) if (sn.length) return json(res, 200, { ok: true, results: sn, source: 'Serial No', query: q }) return json(res, 200, { ok: true, results: [], query: q }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleVision (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.image) return json(res, 400, { error: 'image required' }) try { const { extractBarcodes } = require('./vision') const base64 = body.image.replace(/^data:image\/[^;]+;base64,/, '') const result = await extractBarcodes(base64) return json(res, 200, { ok: true, ...result }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleEquipInstall (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body) return json(res, 400, { error: 'body required' }) const { job, barcode, mac_address, gpon_sn, equipment_type, brand, model, wifi_ssid, wifi_password, notes, customer, location, action, } = body const today = new Date().toISOString().slice(0, 10) try { const installData = { request: job || '', barcode: barcode || '', equipment_type: equipment_type || '', brand: brand || '', model: model || '', notes: [ notes || '', mac_address ? `MAC: ${mac_address}` : '', gpon_sn ? `GPON-SN: ${gpon_sn}` : '', wifi_ssid ? `SSID: ${wifi_ssid}` : '', wifi_password ? `Wi-Fi PWD: ${wifi_password}` : '', ].filter(Boolean).join(' | '), installation_date: today, technician: payload.sub, action: action || 'install', } const r = await erp.create('Equipment Install', installData) if (!r.ok) return json(res, r.status || 500, { error: r.error }) if (barcode && (action === 'install' || action === 'replace')) { try { const q = barcode.replace(/[:\-\s]/g, '').toUpperCase() const existing = await erp.list('Service Equipment', { fields: ['name'], filters: [['serial_number', 'like', `%${q}%`]], limit: 1, }) const seCommon = { status: 'Actif', customer: customer || '', service_location: location || '', mac_address: mac_address || '', gpon_sn: gpon_sn || '', wifi_ssid: wifi_ssid || '', wifi_password: wifi_password || '', brand: brand || '', model: model || '', equipment_type: equipment_type || '', } const seUpdate = Object.fromEntries(Object.entries(seCommon).filter(([, v]) => v !== '' && v != null)) if (existing.length) { await erp.update('Service Equipment', existing[0].name, seUpdate) } else { await erp.create('Service Equipment', { serial_number: barcode, ...seCommon }) } } catch (e) { log('Equip link/create warn:', e.message) } } return json(res, 200, { ok: true, name: r.name || '' }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleEquipRemove (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const body = await readBody(req) if (!body?.equipment) return json(res, 400, { error: 'equipment name required' }) try { await erp.update('Service Equipment', body.equipment, { status: body.status || 'Retourné', customer: '', service_location: '', }) await erp.create('Equipment Install', { request: body.job || '', barcode: body.serial || '', equipment_type: body.equipment_type || '', notes: `Retrait par ${payload.sub}`, installation_date: new Date().toISOString().slice(0, 10), technician: payload.sub, action: 'remove', }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleCatalog (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) try { const items = await erp.list('Item', { fields: ['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description'], filters: [['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]], limit: 50, }) if (!items.length) { return json(res, 200, { ok: true, items: [ { name: 'ONT-GPON', item_name: 'ONT GPON', item_group: 'CPE', standard_rate: 0, description: 'Terminal fibre client' }, { name: 'ROUTER-WIFI6', item_name: 'Routeur Wi-Fi 6', item_group: 'CPE', standard_rate: 9.99, description: 'Routeur sans fil' }, { name: 'DECODER-IPTV', item_name: 'Décodeur IPTV', item_group: 'CPE', standard_rate: 7.99, description: 'Décodeur télévision' }, { name: 'VOIP-ATA', item_name: 'Adaptateur VoIP', item_group: 'CPE', standard_rate: 4.99, description: 'Adaptateur téléphonie' }, { name: 'AMP-COAX', item_name: 'Amplificateur coaxial', item_group: 'Network Equipment', standard_rate: 0, description: 'Amplificateur signal' }, ]}) } return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } async function handleEquipList (req, res, path) { const payload = authToken(path) if (!payload) return json(res, 401, { error: 'expired' }) const url = new URL(req.url, 'http://localhost') const jobName = url.searchParams.get('job') if (!jobName) return json(res, 400, { error: 'job param required' }) try { const items = await erp.list('Equipment Install', { fields: ['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date'], filters: [['request', '=', jobName]], orderBy: 'creation desc', limit: 20, }) return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } // ═════════════════════════════════════════════════════════════════════════════ // Server-side HTML rendering (uses ../ui primitives) // ═════════════════════════════════════════════════════════════════════════════ function jobCard (j, today) { const urgent = j.priority === 'urgent' || j.priority === 'high' const done = j.status === 'Completed' || j.status === 'Cancelled' const inProg = j.status === 'In Progress' || j.status === 'in_progress' const border = urgent ? 'var(--danger)' : done ? 'var(--text-dim)' : inProg ? 'var(--warning)' : 'var(--brand)' const overdue = !done && j.scheduled_date && j.scheduled_date < today const dlbl = ui.dateLabelFr(j.scheduled_date, today) const tlbl = j.start_time ? ui.fmtTime(j.start_time) : '' const datePart = [dlbl, tlbl].filter(Boolean).join(' · ') const inner = `