gigafibre-fsm/services/targo-hub/lib/acceptance.js
louispaulb fd326ac52e perf: parallelize dispatch API fetches + add sales_order/order_source fields
Dispatch performance:
- Replace sequential batch fetches (batches of 15, one after another)
  with full parallel Promise.all — all doc fetches fire simultaneously
- With 20 jobs: was ~3 sequential round-trips, now ~2 (1 list + 1 parallel)

Order traceability:
- Add sales_order (Link) and order_source (Select) fields to Dispatch Job
- checkout.js sets order_source='Online' + sales_order link on job creation
- acceptance.js sets order_source='Quotation' on quotation-sourced jobs
- Store maps new fields: salesOrder, orderSource

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 18:07:14 -04:00

673 lines
28 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 : ''
const payload = {
ticket_id: ticketId,
subject: step.subject || 'Tâche',
address: ctx.address || '',
duration_h: step.duration_h || 1,
priority: step.priority || 'medium',
status: 'open',
job_type: step.job_type || 'Autre',
source_issue: ctx.issue || '',
customer: ctx.customer || '',
service_location: ctx.service_location || '',
order_source: 'Quotation',
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}` : '',
`Quotation: ${quotationName}`,
'Créé automatiquement après acceptation client',
].filter(Boolean).join(' | '),
scheduled_date: step.scheduled_date || '',
}
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 subscriptions for recurring items on accepted quotation ───────────
async function createDeferredSubscriptions (quotation, ctx) {
const { erpFetch } = require('./helpers')
const customer = ctx.customer || quotation.customer || quotation.party_name || ''
if (!customer) return
const recurringItems = (quotation.items || []).filter(i =>
(i.description || '').includes('$/mois')
)
if (!recurringItems.length) return
const today = new Date().toISOString().split('T')[0]
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 rate = rateMatch ? parseFloat(rateMatch[1]) : item.rate
try {
// Create or find Subscription Plan
let planName = null
const planPayload = {
plan_name: item.item_name || item.item_code,
item: item.item_code || item.item_name,
currency: 'CAD',
price_determination: 'Fixed Rate',
cost: rate,
billing_interval: 'Month',
billing_interval_count: 1,
}
const planRes = await erpFetch('/api/resource/Subscription%20Plan', {
method: 'POST',
body: JSON.stringify(planPayload),
})
if (planRes.status === 200 && planRes.data?.data) {
planName = planRes.data.data.name
} else {
// Try to find existing plan
const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code || item.item_name)}`)
if (findRes.status === 200) planName = findRes.data?.data?.name
}
await erpFetch('/api/resource/Subscription', {
method: 'POST',
body: JSON.stringify({
party_type: 'Customer',
party: customer,
company: 'TARGO',
status: 'Active',
start_date: today,
generate_invoice_at: 'Beginning of the current subscription period',
days_until_due: 30,
follow_calendar_months: 1,
plans: planName ? [{ plan: planName, qty: item.qty || 1 }] : [],
}),
})
log(` + Subscription created for ${item.item_name}`)
} catch (e) {
log(` ! Subscription creation failed for ${item.item_name}: ${e.message}`)
}
}
}
// ── 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}`)
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 || 1,
email: email || '',
name: customer,
phone: phone || '',
values: { 'Nom': customer, 'Quotation': quotation },
completedRedirectUrl: acceptLink + '?signed=1',
})
if (dsResult) {
result.method = 'docuseal'
result.sign_url = dsResult.signUrl
result.submission_id = dsResult.submissionId
} 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,
}