gigafibre-fsm/services/targo-hub/lib/acceptance.js
louispaulb 041df48ac3 security: harden payments/hub auth + remove leaked ERPNext token from source
Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:17:17 -04:00

701 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const types = require('./types')
const ui = require('./ui')
const { signJwt, verifyJwt } = require('./magic-link')
// ── Acceptance Links ─────────────────────────────────────────────────────────
// Two modes:
// 1. Simple JWT acceptance (default) — customer clicks link, sees summary, clicks "J'accepte"
// 2. DocuSeal signature (when contract attached) — redirects to DocuSeal for e-signature
const DOCUSEAL_URL = cfg.DOCUSEAL_URL || '' // e.g. https://sign.gigafibre.ca
const DOCUSEAL_KEY = cfg.DOCUSEAL_API_KEY || '' // API key from DocuSeal settings
// ── Generate acceptance token ────────────────────────────────────────────────
function generateAcceptanceToken (quotationName, customer, ttlHours = 168) {
const payload = {
sub: customer,
doc: quotationName,
type: 'acceptance',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + ttlHours * 3600,
}
return signJwt(payload)
}
function generateAcceptanceLink (quotationName, customer, ttlHours = 168) {
const token = generateAcceptanceToken(quotationName, customer, ttlHours)
return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/accept/${token}`
}
// ── DocuSeal integration ─────────────────────────────────────────────────────
async function createDocuSealSubmission (opts) {
if (!DOCUSEAL_URL || !DOCUSEAL_KEY) return null
const { templateId, email, name, phone, values, completedRedirectUrl } = opts
try {
const res = await fetch(`${DOCUSEAL_URL}/api/submissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': DOCUSEAL_KEY,
},
body: JSON.stringify({
template_id: templateId,
send_email: !!email,
send_sms: false,
completed_redirect_url: completedRedirectUrl || '',
submitters: [{
role: 'Première partie',
email: email || '',
name: name || '',
phone: phone || '',
values: values || {},
}],
}),
})
if (!res.ok) {
const err = await res.text()
log(`DocuSeal submission failed: ${res.status} ${err}`)
return null
}
const data = await res.json()
// Response is array of submitters
const submitter = Array.isArray(data) ? data[0] : data
return {
submissionId: submitter.submission_id,
submitterId: submitter.id,
signUrl: submitter.embed_src || `${DOCUSEAL_URL}/s/${submitter.slug}`,
status: submitter.status,
}
} catch (e) {
log('DocuSeal error:', e.message)
return null
}
}
async function checkDocuSealStatus (submissionId) {
if (!DOCUSEAL_URL || !DOCUSEAL_KEY) return null
try {
const res = await fetch(`${DOCUSEAL_URL}/api/submissions/${submissionId}`, {
headers: { 'X-Auth-Token': DOCUSEAL_KEY },
})
if (!res.ok) return null
return await res.json()
} catch { return null }
}
// ── ERPNext helpers ──────────────────────────────────────────────────────────
async function fetchQuotation (name) {
return erp.get('Quotation', name)
}
async function acceptQuotation (name, acceptanceData) {
// Add acceptance comment with proof
await erp.create('Comment', {
comment_type: 'Info',
reference_doctype: 'Quotation',
reference_name: name,
content: `<b>✅ Devis accepté par le client</b><br>
<b>Horodatage:</b> ${new Date().toISOString()}<br>
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
<b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
})
// Update quotation status (best-effort — ignore failure)
await erp.update('Quotation', name, { accepted_by_client: 1 }).catch(() => {})
// ── Create deferred dispatch jobs if wizard_steps exist ──
try {
const quotation = await fetchQuotation(name)
if (quotation && quotation.wizard_steps) {
const steps = JSON.parse(quotation.wizard_steps)
const ctx = quotation.wizard_context ? JSON.parse(quotation.wizard_context) : {}
if (Array.isArray(steps) && steps.length) {
const createdJobs = await createDeferredJobs(steps, ctx, name)
log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`)
// Clear wizard_steps so they don't get created again
await erp.update('Quotation', name, { wizard_steps: '', wizard_context: '' })
// Also create subscriptions for recurring items on the quotation
await createDeferredSubscriptions(quotation, ctx)
}
}
} catch (e) {
log('Deferred job creation on acceptance failed:', e.message)
}
}
// ── Create dispatch jobs from wizard steps stored on quotation ───────────────
async function createDeferredJobs (steps, ctx, quotationName) {
const createdJobs = []
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
let dependsOn = ''
if (step.depends_on_step !== null && step.depends_on_step !== undefined) {
const depJob = createdJobs[step.depends_on_step]
if (depJob) dependsOn = depJob.name
}
const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : ''
// Chain gating: only the root job (no depends_on) is born "open".
// Dependents wait in "On Hold" — dispatch.unblockDependents() flips them
// to "open" when their parent completes. This keeps the tech's active
// list uncluttered (only currently-actionable work shows up).
const payload = {
ticket_id: ticketId,
subject: step.subject || 'Tâche',
address: ctx.address || '',
duration_h: step.duration_h || 1,
priority: step.priority || types.JOB_PRIORITY.MEDIUM,
status: dependsOn ? types.JOB_STATUS.ON_HOLD : types.JOB_STATUS.OPEN,
job_type: step.job_type || 'Autre',
source_issue: ctx.issue || '',
customer: ctx.customer || '',
service_location: ctx.service_location || '',
order_source: ctx.order_source || 'Quotation',
// assigned_group drives group-based subscription in the Tech PWA
// (techs see "Tâches du groupe" matching their assigned_group and can
// self-assign). Was previously only in notes which isn't queryable.
assigned_group: step.assigned_group || '',
depends_on: dependsOn,
parent_job: parentJob,
step_order: step.step_order || (i + 1),
on_open_webhook: step.on_open_webhook || '',
on_close_webhook: step.on_close_webhook || '',
notes: [
step.assigned_group ? `Groupe: ${step.assigned_group}` : '',
`Source: ${quotationName}`,
'Créé automatiquement après acceptation client',
].filter(Boolean).join(' | '),
// scheduled_date precedence: explicit step date > ctx default > today.
// We default to today because ERPNext list views + dispatcher boards
// commonly filter "scheduled_date >= today", and null dates make jobs
// disappear from the dispatch queue (user-visible symptom: "Aucune
// job disponible pour dispatch"). Dispatcher can always reschedule.
scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10),
}
const res = await erp.create('Dispatch Job', payload)
if (res.ok && res.data) {
createdJobs.push(res.data)
log(` + Job ${res.data.name}: ${step.subject}`)
} else {
createdJobs.push({ name: ticketId })
log(` ! Job creation failed for: ${step.subject}${res.error || 'unknown'}`)
}
}
return createdJobs
}
// ── Create Service Subscriptions for recurring items on accepted quotation ──
//
// Writes the *custom* Service Subscription doctype (not Frappe's built-in
// Subscription). Rows are born with status='En attente' and start_date=today
// as a placeholder — dispatch.activateSubscriptionForJob() flips status to
// 'Actif' and rewrites start_date to the real activation day when the final
// job in the install chain completes.
//
// Linkage to the chain is implicit: (customer, service_location, status='En
// attente') uniquely identifies a pending subscription. On terminal job
// completion we look it up and activate + prorate.
function _guessServiceCategory (item) {
const name = `${item.item_name || ''} ${item.item_code || ''} ${item.description || ''}`.toLowerCase()
if (/iptv|tv|t[ée]l[ée]|cha[îi]ne/.test(name)) return 'IPTV'
if (/voip|t[ée]l[ée]phon|ligne|pbx/.test(name)) return 'VoIP'
if (/bundle|combo|forfait.*combin|duo|trio/.test(name)) return 'Bundle'
if (/h[ée]bergement|hosting|cloud|email/.test(name)) return 'Hébergement'
if (/internet|fibre|fiber|dsl|\bmbps\b|\bgbps\b/.test(name)) return 'Internet'
return 'Autre'
}
function _extractSpeeds (item) {
const src = `${item.item_name || ''} ${item.description || ''}`
const both = src.match(/(\d+)\s*\/\s*(\d+)\s*mbps/i)
if (both) return { down: parseInt(both[1], 10), up: parseInt(both[2], 10) }
const down = src.match(/(\d+)\s*mbps/i)
if (down) return { down: parseInt(down[1], 10), up: null }
return { down: null, up: null }
}
function _extractDurationMonths (item) {
const m = (item.description || '').match(/×\s*(\d+)\s*mois/i) ||
(item.description || '').match(/(\d+)\s*mois/i)
return m ? parseInt(m[1], 10) : null
}
async function createDeferredSubscriptions (quotation, ctx) {
const customer = ctx.customer || quotation.customer || quotation.party_name || ''
const serviceLocation = ctx.service_location || ''
if (!customer) return []
if (!serviceLocation) {
log(' ! createDeferredSubscriptions: no service_location in ctx — skipping')
return []
}
const recurringItems = (quotation.items || []).filter(i =>
(i.description || '').includes('$/mois')
)
if (!recurringItems.length) return []
const today = new Date().toISOString().split('T')[0]
const created = []
for (const item of recurringItems) {
// Extract monthly rate from description like "Service — 49.99$/mois × 12 mois"
const rateMatch = (item.description || '').match(/([\d.]+)\$\/mois/)
const monthlyPrice = rateMatch ? parseFloat(rateMatch[1]) : Number(item.rate) || 0
const speeds = _extractSpeeds(item)
const durationMonths = _extractDurationMonths(item)
const payload = {
customer,
service_location: serviceLocation,
status: 'En attente', // waits for final job completion
service_category: _guessServiceCategory(item),
plan_name: item.item_name || item.item_code || 'Abonnement',
speed_down: speeds.down || 0,
speed_up: speeds.up || 0,
monthly_price: monthlyPrice,
billing_cycle: 'Mensuel',
contract_duration: durationMonths || 0,
// start_date is required by the doctype — use today as a placeholder.
// Real activation date is rewritten by activateSubscriptionForJob().
start_date: today,
notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`,
}
const res = await erp.create('Service Subscription', payload)
if (res.ok && res.name) {
created.push(res.name)
log(` + Service Subscription ${res.name} (En attente) — ${item.item_name}`)
} else {
log(` ! Service Subscription creation failed for ${item.item_name}: ${res.error || 'unknown'}`)
}
}
return created
}
// ── PDF generation via ERPNext ────────────────────────────────────────────────
async function getQuotationPdfBuffer (quotationName, printFormat) {
// PDF comes back as raw bytes, not JSON — go around erpFetch.
const format = printFormat || 'Standard'
const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0`
const pdfRes = await fetch(url, {
headers: {
'Authorization': `token ${cfg.ERP_TOKEN}`,
'X-Frappe-Site-Name': cfg.ERP_SITE,
},
})
if (!pdfRes.ok) return null
return Buffer.from(await pdfRes.arrayBuffer())
}
async function getDocPdfBuffer (doctype, name, printFormat) {
const format = printFormat || 'Standard'
const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=${encodeURIComponent(doctype)}&name=${encodeURIComponent(name)}&format=${encodeURIComponent(format)}&no_letterhead=0`
const pdfRes = await fetch(url, {
headers: {
'Authorization': `token ${cfg.ERP_TOKEN}`,
'X-Frappe-Site-Name': cfg.ERP_SITE,
},
})
if (!pdfRes.ok) return null
return Buffer.from(await pdfRes.arrayBuffer())
}
// ── Acceptance page HTML ─────────────────────────────────────────────────────
function renderAcceptancePage (quotation, token, accepted = false) {
const items = (quotation.items || []).map(i =>
`<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;">${i.item_name || i.item_code}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:center;">${i.qty}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right;">${Number(i.rate).toFixed(2)} $</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:700;">${Number(i.amount || i.qty * i.rate).toFixed(2)} $</td>
</tr>`
).join('')
const total = Number(quotation.grand_total || quotation.total || 0).toFixed(2)
const terms = quotation.terms || ''
return `<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Devis ${quotation.name} — Gigafibre</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:#f1f5f9;color:#1e293b;min-height:100vh;display:flex;justify-content:center;padding:1.5rem}
.card{background:white;border-radius:16px;max-width:640px;width:100%;box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden}
.header{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;padding:1.5rem 2rem}
.header h1{font-size:1.3rem;font-weight:700}
.header p{font-size:0.82rem;opacity:0.85;margin-top:0.25rem}
.body{padding:1.5rem 2rem}
table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:0.85rem}
th{background:#f8fafc;padding:8px 12px;text-align:left;font-size:0.72rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;border-bottom:2px solid #e2e8f0}
.total-row{display:flex;justify-content:space-between;align-items:center;background:#f0fdf4;padding:0.75rem 1rem;border-radius:10px;margin:1rem 0}
.total-row .amount{font-size:1.3rem;font-weight:700;color:#16a34a}
.terms{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:1rem;font-size:0.78rem;color:#475569;margin:1rem 0;max-height:200px;overflow-y:auto;white-space:pre-wrap}
.accept-form{text-align:center;padding:1rem 0}
.cb-label{display:flex;align-items:center;justify-content:center;gap:0.5rem;font-size:0.85rem;color:#475569;margin-bottom:1rem;cursor:pointer}
.cb-label input{accent-color:#6366f1;width:18px;height:18px}
.btn-accept{background:#22c55e;color:white;border:none;border-radius:12px;padding:0.85rem 2.5rem;font-size:1rem;font-weight:700;font-family:inherit;cursor:pointer;transition:background 0.15s}
.btn-accept:hover{background:#16a34a}
.btn-accept:disabled{background:#86efac;cursor:not-allowed}
.btn-pdf{display:inline-flex;align-items:center;gap:0.4rem;background:#6366f1;color:white;border:none;border-radius:10px;padding:0.6rem 1.5rem;font-size:0.85rem;font-weight:600;font-family:inherit;cursor:pointer;text-decoration:none;transition:background 0.15s;margin-bottom:1rem}
.btn-pdf:hover{background:#4f46e5}
.accepted-banner{background:#dcfce7;color:#166534;padding:1rem;text-align:center;font-weight:700;font-size:0.92rem;border-radius:10px;margin:1rem 0}
.footer{text-align:center;padding:1rem;font-size:0.72rem;color:#94a3b8}
</style></head><body>
<div class="card">
<div class="header">
<h1>Devis ${quotation.name}</h1>
<p>${quotation.customer_name || quotation.party_name || ''}${new Date().toLocaleDateString('fr-CA')}</p>
</div>
<div class="body">
<table>
<thead><tr><th>Description</th><th style="text-align:center">Qté</th><th style="text-align:right">Prix</th><th style="text-align:right">Total</th></tr></thead>
<tbody>${items}</tbody>
</table>
<div class="total-row">
<span style="font-weight:600;color:#475569">Total</span>
<span class="amount">${total} $</span>
</div>
<div style="text-align:center">
<a class="btn-pdf" href="/accept/pdf/${token}" target="_blank">&#128196; Télécharger le PDF</a>
</div>
${terms ? `<div class="terms"><strong>Conditions :</strong>\n${terms}</div>` : ''}
${accepted
? '<div class="accepted-banner">✅ Ce devis a été accepté. Merci!</div>'
: `<div class="accept-form">
<label class="cb-label"><input type="checkbox" id="cb-accept" onchange="document.getElementById('btn-accept').disabled=!this.checked"> J'ai lu et j'accepte les conditions</label>
<button class="btn-accept" id="btn-accept" disabled onclick="doAccept()">Accepter le devis</button>
<div id="accept-status" style="margin-top:0.75rem;font-size:0.82rem;color:#64748b"></div>
</div>
<script>
async function doAccept(){
const btn=document.getElementById('btn-accept');
const status=document.getElementById('accept-status');
btn.disabled=true;btn.textContent='Traitement...';
try{
const r=await fetch('/accept/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${token}'})});
const d=await r.json();
if(d.ok){status.innerHTML='<span style="color:#16a34a;font-weight:700">✅ Devis accepté! Vous recevrez une confirmation.</span>';btn.style.display='none';document.getElementById('cb-accept').parentElement.style.display='none';}
else{status.innerHTML='<span style="color:#dc2626">Erreur: '+(d.error||'Lien invalide')+'</span>';btn.disabled=false;btn.textContent='Accepter le devis';}
}catch(e){status.innerHTML='<span style="color:#dc2626">Erreur réseau</span>';btn.disabled=false;btn.textContent='Accepter le devis';}
}
<\/script>`
}
</div>
<div class="footer">Gigafibre — Targo Télécommunications</div>
</div>
</body></html>`
}
// ── HTTP handler ─────────────────────────────────────────────────────────────
async function handle (req, res, method, path) {
// GET /accept/pdf/:token — Download PDF of the quotation
if (path.startsWith('/accept/pdf/') && method === 'GET') {
const token = path.replace('/accept/pdf/', '')
const payload = verifyJwt(token)
if (!payload || payload.type !== 'acceptance') {
return json(res, 401, { error: 'Token invalide ou expiré' })
}
try {
const buf = await getDocPdfBuffer('Quotation', payload.doc)
if (!buf) return json(res, 404, { error: 'PDF non disponible' })
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${payload.doc}.pdf"`,
'Content-Length': buf.length,
})
return res.end(buf)
} catch (e) {
log('PDF download error:', e.message)
return json(res, 500, { error: 'Erreur lors de la génération du PDF' })
}
}
// GET /accept/doc-pdf/:doctype/:name — Download PDF of any document (authenticated)
if (path.startsWith('/accept/doc-pdf/') && method === 'GET') {
// Route « PDF de N'IMPORTE QUEL doc » → staff-only. Sous le préfixe PUBLIC
// /accept, le gate ne la couvre pas : on exige le token hub ici (Ops passe par
// le proxy /ops/hub qui l'injecte), sinon IDOR anonyme.
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!process.env.HUB_SERVICE_TOKEN || tok !== process.env.HUB_SERVICE_TOKEN) {
return json(res, 401, { error: 'Unauthorized' })
}
const parts = path.replace('/accept/doc-pdf/', '').split('/')
const doctype = decodeURIComponent(parts[0] || '')
const name = decodeURIComponent(parts[1] || '')
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
const format = url.searchParams.get('format') || 'Standard'
if (!doctype || !name) return json(res, 400, { error: 'doctype and name required' })
try {
const buf = await getDocPdfBuffer(doctype, name, format)
if (!buf) return json(res, 404, { error: 'PDF non disponible' })
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${name}.pdf"`,
'Content-Length': buf.length,
})
return res.end(buf)
} catch (e) {
log('Doc PDF error:', e.message)
return json(res, 500, { error: 'Erreur PDF' })
}
}
// GET /accept/:token — Show acceptance page
if (path.startsWith('/accept/') && method === 'GET' && !path.includes('/confirm')) {
const token = path.replace('/accept/', '')
const payload = verifyJwt(token)
if (!payload || payload.type !== 'acceptance') {
res.writeHead(200, ui.htmlHeaders())
return res.end(ui.pageExpired())
}
try {
const quotation = await fetchQuotation(payload.doc)
if (!quotation) {
return json(res, 404, { error: 'Quotation not found' })
}
const html = renderAcceptancePage(quotation, token)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
return res.end(html)
} catch (e) {
log('Acceptance page error:', e.message)
return json(res, 500, { error: 'Server error' })
}
}
// POST /accept/confirm — Record acceptance
if (path === '/accept/confirm' && method === 'POST') {
const body = await parseBody(req)
const payload = verifyJwt(body.token)
if (!payload || payload.type !== 'acceptance') {
return json(res, 401, { error: 'Token invalide ou expiré' })
}
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''
const ua = req.headers['user-agent'] || ''
try {
await acceptQuotation(payload.doc, {
method: 'Lien JWT',
contact: payload.sub,
ip,
userAgent: ua,
})
log(`Quotation accepted: ${payload.doc} by ${payload.sub} from ${ip}`)
// Fire flow trigger (on_quotation_accepted). Non-blocking.
try {
require('./flow-runtime').dispatchEvent('on_quotation_accepted', {
doctype: 'Quotation', docname: payload.doc, customer: payload.sub,
variables: { method: 'JWT', ip, user_agent: ua },
}).catch(e => log('flow trigger on_quotation_accepted failed:', e.message))
} catch (e) { log('flow trigger load error:', e.message) }
return json(res, 200, { ok: true, quotation: payload.doc, message: 'Devis accepté. Le projet a été lancé.' })
} catch (e) {
log('Accept confirm error:', e.message)
return json(res, 500, { error: 'Failed to record acceptance' })
}
}
// POST /accept/generate — Generate acceptance link (called from ops app / wizard)
if (path === '/accept/generate' && method === 'POST') {
const body = await parseBody(req)
const { quotation, customer, ttl_hours, send_sms, phone, send_email, email, use_docuseal, docuseal_template_id } = body
if (!quotation || !customer) {
return json(res, 400, { error: 'quotation and customer required' })
}
const result = { ok: true }
// Mode 1: DocuSeal e-signature
if (use_docuseal && DOCUSEAL_URL && DOCUSEAL_KEY) {
const acceptLink = generateAcceptanceLink(quotation, customer, ttl_hours || 168)
const dsResult = await createDocuSealSubmission({
templateId: docuseal_template_id || cfg.DOCUSEAL_DEFAULT_TEMPLATE_ID || 1,
email: email || '',
name: customer,
phone: phone || '',
values: { 'Nom': customer, 'Customer': customer, 'Quotation': quotation },
completedRedirectUrl: acceptLink + '?signed=1',
})
if (dsResult) {
result.method = 'docuseal'
result.sign_url = dsResult.signUrl
result.submission_id = dsResult.submissionId
// Persist signing URL on the Quotation so the print-format QR code is populated
const up = await erp.update('Quotation', quotation, { custom_docuseal_signing_url: dsResult.signUrl })
if (!up.ok) log('Failed to save DocuSeal signing URL to Quotation:', up.error)
} else {
// Fallback to JWT if DocuSeal fails
result.method = 'jwt'
result.link = acceptLink
}
} else {
// Mode 2: Simple JWT acceptance
result.method = 'jwt'
result.link = generateAcceptanceLink(quotation, customer, ttl_hours || 168)
}
// Send SMS if requested
if (send_sms && phone) {
const linkToSend = result.sign_url || result.link
const msg = `Gigafibre — Votre devis ${quotation} est prêt pour acceptation.\n\n📋 Voir le devis: ${linkToSend}\n\nCe lien expire dans ${Math.round((ttl_hours || 168) / 24)} jours.`
try {
const { sendSmsInternal } = require('./twilio')
await sendSmsInternal(phone, msg)
result.sms_sent = true
log(`Acceptance link sent via SMS to ${phone} for ${quotation}`)
} catch (e) {
result.sms_sent = false
log('SMS send failed:', e.message)
}
}
// Send email if requested — via nodemailer with PDF attachment
if (send_email && email) {
try {
const { sendQuotationEmail } = require('./email')
const linkToSend = result.sign_url || result.link
// Fetch PDF to attach
let pdfBuf = null
try { pdfBuf = await getDocPdfBuffer('Quotation', quotation) } catch {}
const sent = await sendQuotationEmail({
to: email,
quotationName: quotation,
acceptLink: linkToSend,
pdfBuffer: pdfBuf,
})
result.email_sent = sent
if (sent) log(`Acceptance email sent to ${email} for ${quotation}`)
} catch (e) {
result.email_sent = false
log('Email send failed:', e.message)
}
}
return json(res, 200, result)
}
// POST /accept/send — Send acceptance link via email or SMS (from success screen)
if (path === '/accept/send' && method === 'POST') {
const body = await parseBody(req)
const { quotation, customer, channel, to } = body
// channel: 'email' | 'sms'
if (!quotation || !to || !channel) {
return json(res, 400, { error: 'quotation, channel and to required' })
}
const result = { ok: true }
const link = generateAcceptanceLink(quotation, customer || '', 168)
result.link = link
if (channel === 'sms') {
const msg = `Gigafibre — Votre devis ${quotation} est prêt.\n\nConsultez-le ici: ${link}\n\nValide 7 jours.`
try {
const { sendSmsInternal } = require('./twilio')
await sendSmsInternal(to, msg)
result.sent = true
log(`Acceptance SMS sent to ${to} for ${quotation}`)
} catch (e) {
result.sent = false
result.error = e.message
log('SMS send failed:', e.message)
}
} else if (channel === 'email') {
try {
const { sendQuotationEmail } = require('./email')
let pdfBuf = null
try { pdfBuf = await getDocPdfBuffer('Quotation', quotation) } catch {}
const sent = await sendQuotationEmail({
to,
quotationName: quotation,
acceptLink: link,
pdfBuffer: pdfBuf,
})
result.sent = sent
if (sent) log(`Acceptance email sent to ${to} for ${quotation}`)
} catch (e) {
result.sent = false
result.error = e.message
log('Email send failed:', e.message)
}
}
return json(res, 200, result)
}
// POST /accept/docuseal-webhook — DocuSeal completion webhook
if (path === '/accept/docuseal-webhook' && method === 'POST') {
const body = await parseBody(req)
log('DocuSeal webhook:', body.event_type, body.data?.external_id || '')
if (body.event_type === 'form.completed') {
const submitter = body.data
const quotationName = submitter?.values?.find(v => v.field === 'Quotation')?.value
const customer = submitter?.values?.find(v => v.field === 'Customer')?.value
const signedDocUrl = submitter?.documents?.[0]?.url || ''
if (quotationName) {
await acceptQuotation(quotationName, {
method: 'DocuSeal e-signature',
contact: submitter.email || submitter.phone || customer || '',
ip: req.headers['x-forwarded-for'] || '',
userAgent: 'DocuSeal Webhook',
docusealUrl: signedDocUrl,
})
log(`Quotation ${quotationName} accepted via DocuSeal signature`)
}
}
return json(res, 200, { ok: true })
}
return json(res, 404, { error: 'Acceptance endpoint not found' })
}
module.exports = {
handle,
generateAcceptanceToken,
generateAcceptanceLink,
createDocuSealSubmission,
// Exposed so other modules (contracts.js) can build the same chained
// Dispatch Job structure without duplicating the On-Hold / depends_on logic.
createDeferredJobs,
}