- contracts.js: built-in install chain fallback when no Flow Template matches on_contract_signed — every accepted contract now creates a master Issue + chained Dispatch Jobs (fiber_install template) so we never lose a signed contract to a missing flow config. - acceptance.js: export createDeferredJobs + propagate assigned_group into Dispatch Job payload (was only in notes, not queryable). - dispatch.js: chain-walk helpers (unblockDependents, _isChainTerminal, setJobStatusWithChain) + terminal-node detection that activates pending Service Subscriptions (En attente → Actif, start_date=tomorrow) and emits a prorated Sales Invoice covering tomorrow → EOM. Courtesy-day billing convention: activation day is free, first period starts next day. - dispatch.js: fix Sales Invoice 417 by resolving company default income account (Ventes - T) and passing company + income_account on each item. - dispatch.js: GET /dispatch/group-jobs + POST /dispatch/claim-job for tech self-assignment from the group queue; enriches with customer_name / service_location via per-job fetches since those fetch_from fields aren't queryable in list API. - TechTasksPage.vue: redesigned mobile-first UI with progress arc, status chips, and new "Tâches du groupe" section showing claimable unassigned jobs with a "Prendre" CTA. Live updates via SSE job-claimed / job-unblocked. - NetworkPage.vue + poller-control.js: poller toggle semantics flipped — green when enabled, red/gray when paused; explicit status chips for clarity. E2E verified end-to-end: CTR-00007 → 4 chained jobs → claim → In Progress → Completed walks chain → SUB-0000100002 activated (start=2026-04-24) → SINV-2026-700012 prorata $9.32 (= 39.95 × 7/30). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
738 lines
31 KiB
JavaScript
738 lines
31 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 : ''
|
||
|
||
// 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: 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 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">📄 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,
|
||
}
|