gigafibre-fsm/services/targo-hub/lib/magic-link.js
louispaulb 01bb99857f refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.

Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
  field with "Field not permitted in query" — the field is dropped and the
  call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
  service_location_name, …) in one query per link field — no N+1, no
  v16 breakage.
- Consistent {ok, error, status} shape for mutations.

Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
  Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper

Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.

Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:27 -04:00

129 lines
4.8 KiB
JavaScript

'use strict'
const crypto = require('crypto')
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
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 }
}
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)
}
function generateLink (techId, jobId, ttlHours = 72) {
const token = generateToken(techId, jobId, ttlHours)
return `${cfg.FIELD_APP_URL}/t/${token}`
}
// Tech-level token: access to all their jobs (used in the schedule SMS)
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}/t/${token}`
}
async function handle (req, res, method, path) {
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) })
}
}
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 })
}
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' })
const erp = require('./erp')
const tech = await erp.get('Dispatch Technician', tech_id)
if (!tech) return json(res, 404, { error: 'Tech not found' })
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' })
}
function generateCustomerToken (customerId, customerName, email, ttlHours = 24) {
const payload = {
sub: customerId,
scope: 'customer',
name: customerName || customerId,
email: email || '',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + ttlHours * 3600,
}
return signJwt(payload)
}
module.exports = { handle, generateToken, generateLink, generateTechToken, generateTechLink, generateCustomerToken, verifyJwt, signJwt }