Major additions accumulated over 9 days — single commit per request. Flow editor (new): - Generic visual editor for step trees, usable by project wizard + agent flows - PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain - Drag-and-drop reorder via vuedraggable with scope isolation per peer group - Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved) - Variable picker with per-applies_to catalog (Customer / Quotation / Service Contract / Issue / Subscription), insert + copy-clipboard modes - trigger_condition helper with domain-specific JSONLogic examples - Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern - Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js - ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates - depends_on chips resolve to step labels instead of opaque "s4" ids QR/OCR scanner (field app): - Camera capture → Gemini Vision via targo-hub with 8s timeout - IndexedDB offline queue retries photos when signal returns - Watcher merges late-arriving scan results into the live UI Dispatch: - Planning mode (draft → publish) with offer pool for unassigned jobs - Shared presets, recurrence selector, suggested-slots dialog - PublishScheduleModal, unassign confirmation Ops app: - ClientDetailPage composables extraction (useClientData, useDeviceStatus, useWifiDiagnostic, useModemDiagnostic) - Project wizard: shared detail sections, wizard catalog/publish composables - Address pricing composable + pricing-mock data - Settings redesign hosting flow templates Targo-hub: - Contract acceptance (JWT residential + DocuSeal commercial tracks) - Referral system - Modem-bridge diagnostic normalizer - Device extractors consolidated Migration scripts: - Invoice/quote print format setup, Jinja rendering - Additional import + fix scripts (reversals, dates, customers, payments) Docs: - Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS, FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT, APP_DESIGN_GUIDELINES - Archived legacy wizard PHP for reference - STATUS snapshots for 2026-04-18/19 Cleanup: - Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*) - .gitignore now covers invoice preview output + nested .DS_Store Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
693 lines
28 KiB
JavaScript
693 lines
28 KiB
JavaScript
'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">📄 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,
|
||
}
|