'use strict' const cfg = require('./config') const { log, json, erpFetch } = require('./helpers') const { verifyJwt } = require('./magic-link') // ── Lightweight server-rendered mobile page for field techs ─────────────── // No framework, inline CSS+JS, <15KB HTML. Scanner uses native BarcodeDetector // + fallback photo→Gemini Vision. Equipment CRUD via targo-hub proxy to ERPNext. // ── Auth helper ────────────────────────────────────────────────────────────── 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 } } // ── Route dispatcher ───────────────────────────────────────────────────────── async function route (req, res, method, path) { // GET /t/{token} → page if (method === 'GET' && /^\/t\/[A-Za-z0-9_\-\.]+$/.test(path)) return handlePage(req, res, path) // POST /t/{token}/status → job status update if (method === 'POST' && path.endsWith('/status')) return handleStatus(req, res, path) // POST /t/{token}/scan → barcode/serial lookup if (method === 'POST' && path.endsWith('/scan')) return handleScan(req, res, path) // POST /t/{token}/vision → photo → Gemini AI equipment label read if (method === 'POST' && path.endsWith('/vision')) return handleVision(req, res, path) // POST /t/{token}/equip → create Equipment Install if (method === 'POST' && path.endsWith('/equip')) return handleEquipInstall(req, res, path) // POST /t/{token}/equip-remove → remove/return equipment if (method === 'POST' && path.endsWith('/equip-remove')) return handleEquipRemove(req, res, path) // GET /t/{token}/catalog → equipment catalog items if (method === 'GET' && path.endsWith('/catalog')) return handleCatalog(req, res, path) // GET /t/{token}/equip-list → equipment installed on a job if (method === 'GET' && path.endsWith('/equip-list')) return handleEquipList(req, res, path) return json(res, 404, { error: 'Not found' }) } // ── GET /t/{token} — Server-rendered page ──────────────────────────────────── async function handlePage (req, res, path) { const payload = authToken(path) if (!payload) { res.writeHead(200, html()); return res.end(pageExpired()) } const techId = payload.sub const today = new Date().toISOString().slice(0, 10) try { const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(techId)}?fields=["name","full_name","phone"]`) const techName = techRes.status === 200 && techRes.data?.data?.full_name ? techRes.data.data.full_name : techId const fields = '["name","subject","status","customer","customer_name","service_location","service_location_name","scheduled_time","scheduled_date","duration_h","priority","description","job_type"]' const filters = encodeURIComponent(JSON.stringify([['assigned_tech', '=', techId], ['scheduled_date', '=', today]])) const jobsRes = await erpFetch(`/api/resource/Dispatch%20Job?fields=${fields}&filters=${filters}&order_by=scheduled_time+asc&limit_page_length=50`) const jobs = jobsRes.status === 200 && Array.isArray(jobsRes.data?.data) ? jobsRes.data.data : [] res.writeHead(200, html()) res.end(renderPage(techName, jobs, path.split('/')[2])) } catch (e) { log('tech-mobile error:', e.message) res.writeHead(500, html()) res.end(pageError()) } } // ── POST /t/{token}/status — Update job status ────────────────────────────── 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 r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(body.job)}`, { method: 'PUT', body: JSON.stringify({ status: body.status }), }) if (r.status >= 400) return json(res, r.status, { error: 'ERPNext error' }) require('./sse').broadcast('dispatch', 'job-status', { job: body.job, status: body.status, tech: payload.sub }) return json(res, 200, { ok: true }) } catch (e) { return json(res, 500, { error: e.message }) } } // ── POST /t/{token}/scan — Barcode/serial lookup ──────────────────────────── 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 { // Search Service Equipment 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=["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 }) } // Search Serial No const f = encodeURIComponent(JSON.stringify([['serial_no', 'like', `%${q}%`]])) const r = await erpFetch(`/api/resource/Serial%20No?filters=${f}&fields=["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 }) } } // ── POST /t/{token}/vision — Photo → Gemini AI label read ─────────────────── 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' }) // Proxy to existing vision.js extractBarcodes + equipment logic 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 }) } } // ── POST /t/{token}/equip — Create Equipment Install ──────────────────────── 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, equipment_type, brand, model, notes, customer, location, action } = body const today = new Date().toISOString().slice(0, 10) try { // 1. Create Equipment Install record const installData = { request: job || '', barcode: barcode || '', equipment_type: equipment_type || '', brand: brand || '', model: model || '', notes: notes || '', installation_date: today, technician: payload.sub, action: action || 'install', // install | replace | remove } const r = await erpFetch('/api/resource/Equipment%20Install', { method: 'POST', body: JSON.stringify(installData) }) // 2. Try to link/create Service Equipment record if (barcode && (action === 'install' || action === 'replace')) { try { // Check if equipment exists 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=["name"]&limit_page_length=1`) if (existing.status === 200 && existing.data?.data?.length) { // Update existing: link to customer/location await erpFetch(`/api/resource/Service%20Equipment/${encodeURIComponent(existing.data.data[0].name)}`, { method: 'PUT', body: JSON.stringify({ status: 'Actif', customer: customer || '', service_location: location || '', }), }) } else { // Create new await erpFetch('/api/resource/Service%20Equipment', { method: 'POST', body: JSON.stringify({ serial_number: barcode, equipment_type: equipment_type || 'ONT', brand: brand || '', model: model || '', status: 'Actif', customer: customer || '', service_location: location || '', }), }) } } 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 }) } } // ── POST /t/{token}/equip-remove — Remove/return equipment ────────────────── 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: '', }), }) // Log as Equipment Install with action=remove 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 }) } } // ── GET /t/{token}/catalog — Equipment catalog ────────────────────────────── 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=["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 : [] // Fallback hardcoded if API returns nothing 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 }) } } // ── GET /t/{token}/equip-list?job=X — Equipment on a job ──────────────────── 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=["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 RENDERING // ═════════════════════════════════════════════════════════════════════════════ function html () { return { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' } } function esc (s) { return (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } function fmtTime (t) { if (!t) return ''; const [h, m] = t.split(':'); return `${h}h${m}` } function todayFr () { return new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' }) } function badge (s) { const map = { Scheduled: ['À faire', '#818cf8'], assigned: ['Assigné', '#818cf8'], open: ['Ouvert', '#818cf8'], 'In Progress': ['En cours', '#f59e0b'], in_progress: ['En cours', '#f59e0b'], Completed: ['Terminé', '#22c55e'], Cancelled: ['Annulé', '#ef4444'] } const [l, c] = map[s] || [s, '#94a3b8'] return `${esc(l)}` } function jobCard (j) { const urgent = j.priority === 'urgent' || j.priority === 'high' const border = urgent ? '#ef4444' : j.status === 'Completed' ? '#22c55e' : '#5c59a8' const canStart = ['Scheduled', 'assigned', 'open'].includes(j.status) const canFinish = ['In Progress', 'in_progress'].includes(j.status) const done = j.status === 'Completed' const loc = j.service_location_name || '' const gps = loc ? `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}` : '' return `
Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.
Réessayez dans quelques instants.