gigafibre-fsm/services/targo-hub/lib/acceptance.js
louispaulb 3db1dbae06 fix(contract): always run built-in chain + send ack SMS + default scheduled_date
Three bugs combined to make CTR-00008 (and likely others) land silently:

1. Fallback was count-based, not outcome-based.
   _fireFlowTrigger returned >0 when a broken Flow Template (FT-00005)
   "matched" on_contract_signed but did nothing. We took that as success
   and skipped the built-in install chain. Now we ALWAYS run the built-in
   chain; the idempotency check inside (look up existing Issue linked to
   contract) lets a healthy Flow Template short-circuit us naturally.

2. scheduled_date was null on all chained jobs.
   createDeferredJobs passed '' when no step.scheduled_date was set, and
   the fiber_install template doesn't set one. Jobs with null dates are
   filtered out of most dispatch board views, giving the user-visible
   "Aucune job disponible pour dispatch" symptom even after the chain was
   built. Default to today (via ctx.scheduled_date) so jobs appear on the
   board; dispatcher reschedules per capacity.

3. No post-sign acknowledgment to the customer.
   Previously the Flow Template was expected to send the confirmation SMS;
   since the template was broken, the customer got nothing after signing.
   Add _sendPostSignAcknowledgment that sends a "Bon de commande reçu"
   SMS with contract ref + service details + next steps. Fires only when
   the chain is actually created (not on idempotent skip) so we never
   double-notify.

Also:
- Resolve phone/email from cell_phone + email_billing (legacy-migrated
  Customer records use those fields, not Frappe defaults mobile_no /
  email_id) — otherwise we'd keep skipping SMS with "no phone on file".
- _createBuiltInInstallChain now returns { created, issue, jobs,
  scheduled_date, reason } so callers can branch on outcome.
- Export sendPostSignAcknowledgment so one-shot backfill scripts can
  re-notify customers whose contracts were signed during the broken
  window.
- Set order_source='Contract' (existing Select options patched separately
  to include 'Contract' alongside Manual/Online/Quotation).

Backfilled CTR-00008: ISS-0000250003 + 4 chained Dispatch Jobs all with
scheduled_date=2026-04-23, ack SMS delivered to Louis-Paul's cell.

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

743 lines
31 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 { 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) {
const { erpFetch } = require('./helpers')
const res = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`)
if (res.status !== 200) return null
return res.data.data
}
async function acceptQuotation (name, acceptanceData) {
const { erpFetch } = require('./helpers')
// Add acceptance comment with proof
await erpFetch(`/api/resource/Comment`, {
method: 'POST',
body: JSON.stringify({
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
try {
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({ 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 erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({ 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 { erpFetch } = require('./helpers')
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 || 'medium',
status: dependsOn ? 'On Hold' : '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),
}
try {
const res = await erpFetch('/api/resource/Dispatch%20Job', {
method: 'POST',
body: JSON.stringify(payload),
})
if (res.status === 200 && res.data?.data) {
createdJobs.push(res.data.data)
log(` + Job ${res.data.data.name}: ${step.subject}`)
} else {
createdJobs.push({ name: ticketId })
log(` ! Job creation returned ${res.status} for: ${step.subject}`)
}
} catch (e) {
createdJobs.push({ name: ticketId })
log(` ! Job creation failed for: ${step.subject}${e.message}`)
}
}
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 { erpFetch } = require('./helpers')
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 || ''}`,
}
try {
const res = await erpFetch('/api/resource/Service%20Subscription', {
method: 'POST',
body: JSON.stringify(payload),
})
if (res.status === 200 && res.data?.data) {
created.push(res.data.data.name)
log(` + Service Subscription ${res.data.data.name} (En attente) — ${item.item_name}`)
} else {
log(` ! Service Subscription creation returned ${res.status} for ${item.item_name}`)
}
} catch (e) {
log(` ! Service Subscription creation failed for ${item.item_name}: ${e.message}`)
}
}
return created
}
// ── PDF generation via ERPNext ────────────────────────────────────────────────
async function getQuotationPdfBuffer (quotationName, printFormat) {
const { erpFetch } = require('./helpers')
const format = printFormat || 'Standard'
const url = `/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0`
const res = await erpFetch(url, { rawResponse: true })
if (res.status !== 200) return null
// erpFetch returns parsed JSON by default; we need the raw buffer
// Use direct fetch instead
const directUrl = `${cfg.ERP_URL}${url}`
const pdfRes = await fetch(directUrl, {
headers: {
'Authorization': `token ${cfg.ERP_TOKEN}`,
'X-Frappe-Site-Name': cfg.ERP_SITE,
},
})
if (!pdfRes.ok) return null
const buf = Buffer.from(await pdfRes.arrayBuffer())
return buf
}
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') {
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, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lien expiré</title></head><body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#f1f5f9">
<div style="text-align:center;background:white;padding:2rem;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08)">
<div style="font-size:3rem;margin-bottom:1rem">🔗</div>
<h2 style="color:#1e293b">Lien expiré</h2>
<p style="color:#64748b;margin-top:0.5rem">Ce lien d'acceptation a expiré ou est invalide.<br>Contactez-nous pour recevoir un nouveau lien.</p>
</div></body></html>`)
}
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
try {
const { erpFetch } = require('./helpers')
await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`, {
method: 'PUT',
body: JSON.stringify({ custom_docuseal_signing_url: dsResult.signUrl }),
})
} catch (e) {
log('Failed to save DocuSeal signing URL to Quotation:', e.message)
}
} 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,
}