'use strict' const crypto = require('crypto') const cfg = require('./config') const { log, json, parseBody } = require('./helpers') // ── Lightweight JWT (no dependency needed) ───────────────────────────────── // We use HMAC-SHA256 for signing — same as jsonwebtoken but zero deps. function base64url (buf) { return (typeof buf === 'string' ? Buffer.from(buf) : buf) .toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') } function base64urlDecode (str) { str = str.replace(/-/g, '+').replace(/_/g, '/') while (str.length % 4) str += '=' return Buffer.from(str, 'base64') } const JWT_HEADER = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) function signJwt (payload) { const body = base64url(JSON.stringify(payload)) const data = JWT_HEADER + '.' + body const sig = base64url(crypto.createHmac('sha256', cfg.JWT_SECRET).update(data).digest()) return data + '.' + sig } function verifyJwt (token) { const parts = token.split('.') if (parts.length !== 3) return null const data = parts[0] + '.' + parts[1] const sig = base64url(crypto.createHmac('sha256', cfg.JWT_SECRET).update(data).digest()) if (sig !== parts[2]) return null try { const payload = JSON.parse(base64urlDecode(parts[1]).toString()) if (payload.exp && Date.now() / 1000 > payload.exp) return null return payload } catch { return null } } // ── Magic Link API ───────────────────────────────────────────────────────── /** * Generate a magic link token for a technician + job. * Default TTL: 72 hours. */ function generateToken (techId, jobId, ttlHours = 72) { const payload = { sub: techId, job: jobId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + ttlHours * 3600, } return signJwt(payload) } /** * Generate a magic link URL pointing to the field app. */ function generateLink (techId, jobId, ttlHours = 72) { const token = generateToken(techId, jobId, ttlHours) return `${cfg.FIELD_APP_URL}#/j/${token}` } /** * Generate a tech-level token (access to all their jobs). * Used in the schedule SMS — one link per tech for the day. */ function generateTechToken (techId, ttlHours = 72) { const payload = { sub: techId, scope: 'all', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + ttlHours * 3600, } return signJwt(payload) } function generateTechLink (techId, ttlHours = 72) { const token = generateTechToken(techId, ttlHours) return `${cfg.FIELD_APP_URL}#/j/${token}` } // ── HTTP handler ─────────────────────────────────────────────────────────── async function handle (req, res, method, path) { // POST /magic-link/generate — create a magic link if (path === '/magic-link/generate' && method === 'POST') { const body = await parseBody(req) const { tech_id, job_id, ttl_hours } = body if (!tech_id) return json(res, 400, { error: 'tech_id required' }) if (job_id) { const link = generateLink(tech_id, job_id, ttl_hours || 72) log(`Magic link generated: tech=${tech_id} job=${job_id}`) return json(res, 200, { ok: true, link, token: generateToken(tech_id, job_id, ttl_hours || 72) }) } else { const link = generateTechLink(tech_id, ttl_hours || 72) log(`Magic link generated (all jobs): tech=${tech_id}`) return json(res, 200, { ok: true, link, token: generateTechToken(tech_id, ttl_hours || 72) }) } } // GET /magic-link/verify?token=xxx — validate a token if (path === '/magic-link/verify' && method === 'GET') { const url = new URL(req.url, `http://localhost:${cfg.PORT}`) const token = url.searchParams.get('token') if (!token) return json(res, 400, { error: 'token required' }) const payload = verifyJwt(token) if (!payload) return json(res, 401, { error: 'invalid_or_expired', message: 'Ce lien a expiré. Un nouveau lien vous sera envoyé par SMS.' }) log(`Magic link verified: tech=${payload.sub} job=${payload.job || 'all'}`) return json(res, 200, { ok: true, tech_id: payload.sub, job_id: payload.job || null, scope: payload.scope || 'job', exp: payload.exp }) } // POST /magic-link/refresh — generate + send a new link via SMS if (path === '/magic-link/refresh' && method === 'POST') { const body = await parseBody(req) const { tech_id } = body if (!tech_id) return json(res, 400, { error: 'tech_id required' }) // Lookup tech phone from ERPNext const { erpFetch } = require('./helpers') const techRes = await erpFetch(`/api/resource/Dispatch%20Technician/${encodeURIComponent(tech_id)}`) if (techRes.status !== 200) return json(res, 404, { error: 'Tech not found' }) const tech = techRes.data.data if (!tech.phone) return json(res, 400, { error: 'No phone number for this technician' }) const link = generateTechLink(tech_id, 72) const msg = `Voici votre nouveau lien pour accéder à vos tâches:\n${link}` const { sendSmsInternal } = require('./twilio') const sid = await sendSmsInternal(tech.phone, msg) if (!sid) return json(res, 502, { error: 'SMS send failed' }) log(`Magic link refreshed + SMS sent: tech=${tech_id} phone=${tech.phone}`) return json(res, 200, { ok: true, sent_to: tech.phone }) } return json(res, 404, { error: 'Magic link endpoint not found' }) } module.exports = { handle, generateToken, generateLink, generateTechToken, generateTechLink, verifyJwt, signJwt }