'use strict' const cfg = require('./config') const { log, json, erpFetch } = require('./helpers') const { verifyJwt } = require('./magic-link') const { extractField } = require('./vision') // ── Tech mobile SPA ────────────────────────────────────────────────────────── // Server-rendered shell + hash-routed client views (home/hist/cal/profile/job-detail). // No framework. 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 helpers ───────────────────────────────────────────────────────────── function authToken (path) { const m = path.match(/^\/t\/([A-Za-z0-9_\-\.]+)/) if (!m) return null return verifyJwt(m[1]) } 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 } } // YYYY-MM-DD in America/Montreal regardless of server TZ function montrealDate (d = new Date()) { return d.toLocaleDateString('en-CA', { timeZone: 'America/Montreal', year: 'numeric', month: '2-digit', day: '2-digit' }) } // ── Route dispatcher ───────────────────────────────────────────────────────── async function route (req, res, method, path) { // GET /t/{token} → page shell (server-rendered, all jobs embedded) if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path) // POST /t/{token}/status → set status + walk chain if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path) // POST /t/{token}/note → save notes on a Dispatch Job if (method === 'POST' && path.endsWith('/note')) return handleNote(req, res, path) // POST /t/{token}/photo → upload photo (base64) attached to a job if (method === 'POST' && path.endsWith('/photo')) return handlePhotoUpload(req, res, path) // GET /t/{token}/photos?job= → list photos on a job if (method === 'GET' && path.endsWith('/photos')) return handlePhotoList(req, res, path) // GET /t/{token}/photo-serve?p=/private/files/... → proxy a private file if (method === 'GET' && path.endsWith('/photo-serve')) return handlePhotoServe(req, res, path) // GET /t/{token}/job?name= → refresh a single job if (method === 'GET' && path.endsWith('/job')) return handleJobDetail(req, res, path) // POST /t/{token}/field-scan → Gemini single-field extraction (SN, MAC, SSID, etc) if (method === 'POST' && path.endsWith('/field-scan')) return handleFieldScan(req, res, path) // POST /t/{token}/scan → known-code lookup in Service Equipment / Serial No if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path) // POST /t/{token}/vision → generic multi-barcode extraction if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path) // POST /t/{token}/equip → create Equipment Install + Service Equipment if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path) // POST /t/{token}/equip-remove if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path) // GET /t/{token}/catalog if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path) // GET /t/{token}/equip-list?job= if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path) return json(res, 404, { error: 'Not found' }) } // ── Shell + data fetch ─────────────────────────────────────────────────────── async function handlePage (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(200, html()); return res.end(pageExpired()) } const techId = payload.sub // Use Montreal local time for "today" — UTC midnight rolls over 4-5 hours // before Montreal midnight, which would mislabel evening jobs as "hier". const today = montrealDate(new Date()) const rearDate = montrealDate(new Date(Date.now() - 60 * 86400 * 1000)) const frontDate = montrealDate(new Date(Date.now() + 60 * 86400 * 1000)) try { const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(techId)}?fields=${encodeURIComponent(JSON.stringify(['name', 'full_name', 'phone', 'email', 'assigned_group']))}`) const tech = techRes.status === 200 && techRes.data?.data ? techRes.data.data : { name: techId, full_name: techId } // Frappe v16 blocks fetched/linked fields (customer_name, service_location_name) // AND blocks fields not in the doctype DocField list. Real time field is // `start_time` — `scheduled_time` was a phantom field that v15 silently // ignored. We fetch link IDs and resolve names separately. const fields = JSON.stringify(['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']) const filters = JSON.stringify([ ['assigned_tech', '=', techId], ['scheduled_date', 'between', [rearDate, frontDate]], ]) const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&order_by=scheduled_date+desc,start_time+asc&limit_page_length=300`) const jobs = jobsRes.status === 200 && Array.isArray(jobsRes.data?.data) ? jobsRes.data.data : [] // Resolve customer + service_location labels in two batch queries const custIds = [...new Set(jobs.map(j => j.customer).filter(Boolean))] const locIds = [...new Set(jobs.map(j => j.service_location).filter(Boolean))] const custNames = {} const locNames = {} if (custIds.length) { try { const cf = encodeURIComponent(JSON.stringify([['name', 'in', custIds]])) const cr = await erpFetch(`/api/resource/Customer?filters=${cf}&fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name']))}&limit_page_length=200`) for (const c of (cr.data?.data || [])) custNames[c.name] = c.customer_name || c.name } catch (_) { /* non-fatal */ } } if (locIds.length) { try { const lf = encodeURIComponent(JSON.stringify([['name', 'in', locIds]])) const lr = await erpFetch(`/api/resource/Service%20Location?filters=${lf}&fields=${encodeURIComponent(JSON.stringify(['name', 'address_line_1', 'city']))}&limit_page_length=200`) for (const l of (lr.data?.data || [])) locNames[l.name] = [l.address_line_1, l.city].filter(Boolean).join(', ') || l.name } catch (_) { /* non-fatal */ } } for (const j of jobs) { j.customer_name = custNames[j.customer] || j.customer || '' j.service_location_name = locNames[j.service_location] || j.service_location || '' } res.writeHead(200, html()) res.end(renderPage({ tech, jobs, token: path.split('/')[2], today })) } catch (e) { log('tech-mobile page error:', e.message) res.writeHead(500, html()) res.end(pageError()) } } // ── Status update (reuse dispatch chain walker) ────────────────────────────── 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' }) const allowed = ['In Progress', 'Completed'] if (!allowed.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 }) } } // ── Notes save ─────────────────────────────────────────────────────────────── 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 erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, { method: 'PUT', body: JSON.stringify({ notes: body.notes || '' }), }) if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'save failed' }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } // ── Photo upload (base64 → File doc, attached to job) ─────────────────────── 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 erpFetch('/api/resource/File', { method: 'POST', body: JSON.stringify({ file_name: fileName, is_private: 1, content: base64, decode: 1, attached_to_doctype: 'Dispatch Job', attached_to_name: body.job, }), }) if (r.status >= 400) return json(res, r.status, { error: r.data?.exception || r.data?._error_message || 'upload failed' }) return json(res, 200, { ok: true, name: r.data?.data?.name, file_url: r.data?.data?.file_url, file_name: r.data?.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 f = encodeURIComponent(JSON.stringify([ ['attached_to_doctype', '=', 'Dispatch Job'], ['attached_to_name', '=', jobName], ['file_name', 'like', 'dj-%'], ])) const r = await erpFetch(`/api/resource/File?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'file_name', 'file_url', 'is_private', 'creation']))}&order_by=creation+desc&limit_page_length=50`) const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : [] return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } // Proxy a private file from ERPNext so the tech browser can render it without // carrying ERPNext credentials. Query param `p` must be a /private/files/ path. 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() } } // ── Single-job refresh (detail view uses embedded data, but can refresh) ───── 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 r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(name)}`) if (r.status !== 200 || !r.data?.data) return json(res, 404, { error: 'not found' }) return json(res, 200, { ok: true, job: r.data.data }) } catch (e) { return json(res, 500, { error: e.message }) } } // ── Gemini field-scan ──────────────────────────────────────────────────────── 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 }) } } // ── Barcode lookup in ERPNext ─────────────────────────────────────────────── 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 f = encodeURIComponent(JSON.stringify([[field, 'like', `%${q}%`]])) const r = await erpFetch(`/api/resource/Service%20Equipment?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_number', 'item_name', 'equipment_type', 'mac_address', 'status', 'customer', 'service_location']))}&limit_page_length=5`) if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, source: 'Service Equipment', query: q }) } const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]])) const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'serial_no', 'item_code', 'status', 'warehouse']))}&limit_page_length=5`) if (r.status === 200 && r.data?.data?.length) return json(res, 200, { ok: true, results: r.data.data, 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 }) } } // ── Equipment Install / Remove / Catalog ──────────────────────────────────── 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 { // 1. Equipment Install log 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 erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) }) // 2. Link/create Service Equipment when installing/replacing if (barcode && (action === 'install' || action === 'replace')) { try { const q = barcode.replace(/[:\-\s]/g, '').toUpperCase() const ef = encodeURIComponent(JSON.stringify([['serial_number', 'like', `%${q}%`]])) const existing = await erpFetch(`/api/resource/Service%20Equipment?filters=${ef}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=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 || '', } // Drop undefined/empty string keys so we don't wipe existing data on update const seUpdate = Object.fromEntries(Object.entries(seCommon).filter(([, v]) => v !== '' && v != null)) if (existing.status === 200 && existing.data?.data?.length) { await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, { method: 'PUT', body: JSON.stringify(seUpdate), }) } else { await erpFetch('/api/resource/Service%20Equipment', { method: 'POST', body: JSON.stringify({ serial_number: barcode, ...seCommon }), }) } } catch (e) { log('Equip link/create warn:', e.message) } } return json(res, 200, { ok: true, name: r.data?.data?.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 erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(body.equipment)}`, { method: 'PUT', body: JSON.stringify({ status: body.status || 'Retourné', customer: '', service_location: '' }), }) await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify({ 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 groups = encodeURIComponent(JSON.stringify([['item_group', 'in', ['Network Equipment', 'CPE', 'Équipements réseau', 'Products']]])) const r = await erpFetch(`/api/resource/Item?filters=${groups}&fields=${encodeURIComponent(JSON.stringify(['name', 'item_name', 'item_group', 'standard_rate', 'image', 'description']))}&limit_page_length=50`) const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : [] 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 f = encodeURIComponent(JSON.stringify([['request', '=', jobName]])) const r = await erpFetch(`/api/resource/Equipment%20Install?filters=${f}&fields=${encodeURIComponent(JSON.stringify(['name', 'barcode', 'equipment_type', 'brand', 'model', 'action', 'notes', 'installation_date']))}&order_by=creation+desc&limit_page_length=20`) const items = r.status === 200 && Array.isArray(r.data?.data) ? r.data.data : [] return json(res, 200, { ok: true, items }) } catch (e) { return json(res, 500, { error: e.message }) } } // ═════════════════════════════════════════════════════════════════════════════ // HTML // ═════════════════════════════════════════════════════════════════════════════ function html () { return { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } } function esc (s) { return (s == null ? '' : String(s)).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') } function fmtTime (t) { if (!t) return ''; const [h, m] = String(t).split(':'); return `${h}h${m || '00'}` } function fmtDate (d) { if (!d) return '' try { return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' }) } catch { return d } } function dateLabelFr (d, today) { if (!d) return 'Sans date' if (d === today) return "Aujourd'hui" try { const diff = Math.round((new Date(d + 'T00:00:00') - new Date(today + 'T00:00:00')) / 86400000) if (diff === -1) return 'Hier' if (diff === 1) return 'Demain' if (diff > 1 && diff <= 6) return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'long' }) return fmtDate(d) } catch { return fmtDate(d) } } function todayFr () { return new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' }) } const STATUS_META = { Scheduled: { label: 'Planifié', color: '#818cf8' }, assigned: { label: 'Assigné', color: '#818cf8' }, open: { label: 'Ouvert', color: '#818cf8' }, 'In Progress':{ label: 'En cours', color: '#f59e0b' }, in_progress: { label: 'En cours', color: '#f59e0b' }, Completed: { label: 'Terminé', color: '#22c55e' }, Cancelled: { label: 'Annulé', color: '#94a3b8' }, } function badge (s) { const m = STATUS_META[s] || { label: s || '—', color: '#94a3b8' } return `${esc(m.label)}` } function jobCard (j, today) { const urgent = j.priority === 'urgent' || j.priority === 'high' const done = j.status === 'Completed' || j.status === 'Cancelled' const border = urgent ? '#ef4444' : done ? '#94a3b8' : j.status === 'In Progress' || j.status === 'in_progress' ? '#f59e0b' : '#5c59a8' const overdue = !done && j.scheduled_date && j.scheduled_date < today const dlbl = dateLabelFr(j.scheduled_date, today) const tlbl = j.start_time ? fmtTime(j.start_time) : '' const datePart = [dlbl, tlbl].filter(Boolean).join(' · ') return `
Aucune tâche active.
Profitez de la pause !
Aucun historique.
La vue calendrier avec navigation mois/semaine arrive à la phase 4.
Pour l'instant, utilisez Aujourd'hui ou Historique.
Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.
Réessayez dans quelques instants.