gigafibre-fsm/services/targo-hub/lib/giftbit.js
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

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

301 lines
31 KiB
JavaScript
Raw Permalink 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.

// lib/giftbit.js — Client API Giftbit (cartes-cadeaux). VALIDÉ testbed 2026-06-10 (voir memory/reference_giftbit.md).
// Capacités : solde (funds), marketplace (produits), création de campagne (SHORTLINK), statut des cartes,
// révocation (DELETE → GIVER_CANCELLED) pour remplacer un lien compromis.
// ⚠️ Cloudflare bannit les User-Agent non-navigateur (erreur 1010) → on envoie un UA Chrome.
// Secrets : GIFTBIT_TOKEN + GIFTBIT_BASE via ENV (jamais dans le repo). Défaut = TESTBED (aucune vraie carte).
const https = require('https')
const { json, parseBody } = require('./helpers')
const BASE = (process.env.GIFTBIT_BASE || 'https://testbedapp.giftbit.com/papi/v1').replace(/\/+$/, '')
const TOKEN = process.env.GIFTBIT_TOKEN || ''
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
const isTestbed = /testbed/i.test(BASE)
const enc = encodeURIComponent
function api (method, path, body) {
return new Promise((resolve) => {
let u; try { u = new URL(BASE + path) } catch (e) { return resolve({ ok: false, status: 0, error: 'bad url' }) }
const data = body != null ? Buffer.from(JSON.stringify(body)) : null
const headers = { Authorization: 'Bearer ' + TOKEN, Accept: 'application/json', 'User-Agent': UA }
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = data.length }
const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers }, (r) => {
let b = ''; r.on('data', c => { b += c }); r.on('end', () => {
let j = null; try { j = JSON.parse(b) } catch (e) {}
resolve({ ok: r.statusCode >= 200 && r.statusCode < 300, status: r.statusCode, body: j, raw: j ? undefined : String(b).slice(0, 300) })
})
})
req.on('error', e => resolve({ ok: false, status: 0, error: String((e && e.message) || e) }))
req.setTimeout(30000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }) })
if (data) req.write(data); req.end()
})
}
const ping = () => api('GET', '/ping')
const funds = () => api('GET', '/funds')
const marketplace = (q = {}) => api('GET', '/marketplace?' + new URLSearchParams({ region: String(q.region || '2'), limit: String(q.limit || '50'), offset: String(q.offset || '0') }).toString())
const brands = (q = {}) => api('GET', '/brands?' + new URLSearchParams({ region: String(q.region || '2'), limit: String(q.limit || '50'), offset: String(q.offset || '0') }).toString())
const listGifts = (q = {}) => { const p = new URLSearchParams(); for (const k of ['campaign_uuid', 'campaign_id', 'limit', 'offset', 'status']) if (q[k] != null && q[k] !== '') p.set(k, String(q[k])); return api('GET', '/gifts?' + p.toString()) }
const getGift = (uuid) => api('GET', '/gifts/' + enc(uuid))
const cancelGift = (uuid) => api('DELETE', '/gifts/' + enc(uuid)) // → GIVER_CANCELLED : révoque le shortlink gtbt.co + rend les fonds
// Construit la liste marketplace_gifts. 1 entrée = reward DIRECT (marque fixée) ; ≥2 = TEMPLATE (le destinataire choisit à la rédemption → brand_code null jusqu'au choix).
function buildMarketplaceGifts (o = {}) {
if (Array.isArray(o.marketplace_gifts) && o.marketplace_gifts.length) return o.marketplace_gifts.map(g => ({ id: Number(g.id), price_in_cents: Number(g.price_in_cents != null ? g.price_in_cents : o.price_in_cents) }))
if (Array.isArray(o.brand_ids) && o.brand_ids.length) return o.brand_ids.map(id => ({ id: Number(id), price_in_cents: Number(o.price_in_cents) }))
return [{ id: Number(o.marketplace_gift_id), price_in_cents: Number(o.price_in_cents) }]
}
// Création de campagne (schéma VALIDÉ) : marketplace_gifts:[{id, price_in_cents}] (PAS "price"/brand_codes) ; message/subject à la racine.
function createCampaign (o = {}) {
const body = {
id: String(o.id),
delivery_type: o.delivery_type || 'SHORTLINK',
expiry: o.expiry || defaultExpiry(),
message: o.message || '',
subject: o.subject || '',
suppress_default_greeting: !!o.suppress_default_greeting,
marketplace_gifts: buildMarketplaceGifts(o),
contacts: (o.contacts || []).map(c => ({ email: c.email, firstname: c.firstname || '', lastname: c.lastname || '' }))
}
return api('POST', '/campaign', body)
}
function defaultExpiry () { const d = new Date(); d.setFullYear(d.getFullYear() + 1); return d.toISOString().slice(0, 10) }
// Remplacer un lien (soupçon de piratage) : révoque l'ancien gift PUIS recrée pour le même contact.
// Renvoie { old:{uuid,shortlink,status}, created } → l'appelant conserve l'ancien en HISTORIQUE.
async function replaceGift (o = {}) {
const cur = await getGift(o.uuid)
const g = cur.body && cur.body.gift
if (!g) return { ok: false, error: 'gift introuvable', detail: cur.body || cur.error }
const cancel = await cancelGift(o.uuid)
if (!cancel.ok) return { ok: false, error: 'révocation échouée', cancel: cancel.body || cancel.error }
const created = await createCampaign({
id: o.new_campaign_id || ('replace-' + String(o.uuid).slice(0, 10) + '-' + Date.now()),
expiry: o.expiry || defaultExpiry(),
message: o.message || 'Nouveau lien de remplacement.',
subject: o.subject || 'Votre carte-cadeau (nouveau lien)',
marketplace_gift_id: o.marketplace_gift_id || g.marketplacegift_id,
price_in_cents: o.price_in_cents || g.price_in_cents,
contacts: [{ email: o.email || g.recipient_email, firstname: (o.firstname || (g.recipient_name || '').split(' ')[0] || ''), lastname: o.lastname || '' }]
})
return {
ok: !!created.ok,
old: { uuid: g.uuid, shortlink: g.shortlink, status: (cancel.body && cancel.body.gift && cancel.body.gift.status) || 'GIVER_CANCELLED', price_in_cents: g.price_in_cents, brand_code: g.brand_code, recipient_email: g.recipient_email, recipient_name: g.recipient_name },
created: created.body || null,
created_status: created.status
}
}
const DEMO_HTML = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>TARGO &times; Giftbit &mdash; Integration Demo (Sandbox)</title>
<style>
*{box-sizing:border-box} body{margin:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f4f6fa;color:#1c2533}
header{background:#0f1b2d;color:#fff;padding:18px 22px} header h1{margin:0;font-size:18px} header .sub{opacity:.7;font-size:12.5px;margin-top:3px}
.sandbox{background:#fff7e6;border-bottom:1px solid #ffe0a3;color:#7a5200;padding:9px 22px;font-size:12.5px}
main{max-width:860px;margin:18px auto;padding:0 16px}
.card{background:#fff;border:1px solid #e3e8ef;border-radius:12px;padding:16px 18px;margin-bottom:16px}
.card h2{font-size:14px;margin:0 0 10px;color:#0f1b2d}
.step{display:inline-block;width:22px;height:22px;border-radius:50%;background:#5b6ef5;color:#fff;text-align:center;line-height:22px;font-size:12px;margin-right:7px}
.modes label{display:inline-flex;align-items:center;gap:6px;margin-right:18px;font-size:13.5px;cursor:pointer}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:6px}
.brand{border:2px solid #e3e8ef;border-radius:10px;padding:10px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12.5px;transition:.12s}
.brand:hover{border-color:#b9c2d6} .brand.sel{border-color:#5b6ef5;background:#eef0ff}
.brand img{width:34px;height:34px;object-fit:contain;border-radius:5px;background:#fff}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px}
input[type=email]{padding:8px 10px;border:1px solid #cfd6e0;border-radius:8px;font-size:13px;min-width:240px}
button{background:#5b6ef5;color:#fff;border:0;border-radius:9px;padding:10px 16px;font-size:13.5px;font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:not-allowed}
.result{background:#f0fff4;border:1px solid #b7e4c7;border-radius:10px;padding:14px;margin-top:10px;display:none}
.result.show{display:block} .result.err{background:#fff0f0;border-color:#f5c2c2}
.link{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#fff;border:1px dashed #b9c2d6;border-radius:7px;padding:8px 10px;word-break:break-all;margin:8px 0}
.muted{color:#6a7686;font-size:12px} .ok{color:#1b873f;font-weight:600}
.sec{font-size:12.5px;color:#44506a;line-height:1.6} .sec b{color:#0f1b2d}
.tag{display:inline-block;font-size:10.5px;background:#eef0ff;color:#3b46b5;border-radius:5px;padding:1px 6px;margin-left:6px}
</style></head><body>
<header><h1>TARGO Communications &times; Giftbit &mdash; Integration Demo</h1><div class="sub">Reward creation via Giftbit API &mdash; direct brand &amp; multi-brand link (recipient choice)</div></header>
<div class="sandbox">🧪 <b>SANDBOX / TESTBED</b> &mdash; Runs only against <b>testbedapp.giftbit.com</b>. No real cards are issued. The API token is held <b>server-side</b> and is <b>never</b> sent to this page. Amount fixed at <b>$5 CAD</b>, single recipient.</div>
<main>
<div class="card" style="border-left:4px solid #5b6ef5">
<h2>For Giftbit reviewers</h2>
<div class="sec">This page demonstrates TARGO Communications' integration with the <b>Giftbit API (papi/v1)</b>. It is connected to the <b>testbed</b> account only. You can create a reward two ways and inspect the resulting shortlink redemption flow:
<br>&bull; <b>Direct reward</b> &mdash; one specific brand, fixed at issuance.
<br>&bull; <b>Reward link (multi-brand)</b> &mdash; several brands offered; the recipient selects their brand on the Giftbit redemption page (the gift's <code>brand_code</code> stays null until then).
<br>Brands are loaded live from <code>GET /marketplace</code>. Rewards are created via <code>POST /campaign</code> with <code>marketplace_gifts</code>. Links can be cancelled and re-issued with <code>DELETE /gifts/{uuid}</code>. Contact: <b>louis@targo.ca</b>.</div>
</div>
<div class="card"><h2><span class="step">1</span>Reward type</h2>
<div class="modes">
<label><input type="radio" name="mode" value="direct" checked> <b>Direct reward</b> &mdash; one brand (fixed)</label>
<label><input type="radio" name="mode" value="template"> <b>Reward link</b> &mdash; several brands, recipient chooses at redemption</label>
</div>
<div class="muted" id="modehint" style="margin-top:6px">Pick exactly one brand below.</div>
</div>
<div class="card"><h2><span class="step">2</span>Choose brand(s) <span class="tag">GET /marketplace</span></h2>
<div id="brands" class="grid"><div class="muted">Loading options&hellip;</div></div>
</div>
<div class="card"><h2><span class="step">3</span>Recipient &amp; create</h2>
<div class="row"><input type="email" id="email" placeholder="recipient@example.com (optional, sandbox)" value="demo@targo.ca">
<button id="go" disabled>Create $5 reward (testbed)</button></div>
<div class="result" id="result"></div>
</div>
<div class="card"><h2>Generated reward links <span class="tag">GET /gifts</span><button id="refresh" style="float:right;padding:5px 11px;font-size:12px;background:#eef0ff;color:#3b46b5">↻ Refresh</button></h2>
<div id="history"><div class="muted">Loading&hellip;</div></div>
<div class="muted" style="margin-top:6px">Live statuses from the testbed: <b>SENT_AND_REDEEMABLE</b> &rarr; <b>REDEEMED</b> (brand fills in once the recipient chooses) &rarr; <b>GIVER_CANCELLED</b> (revoked). &ldquo;Revoke&rdquo; cancels a reward link via <code>DELETE /gifts/{uuid}</code>.</div>
</div>
<div class="card"><h2>Activity by day <span class="tag">redemption trend</span></h2>
<div id="stats"><div class="muted">Loading&hellip;</div></div>
<div class="muted" style="margin-top:6px">Organic redemptions cluster right after delivery, then taper off. A steady daily trickle &mdash; or many <b>instant</b> redemptions (&lt; 15 min after issue) &mdash; can indicate automated / low-profile abuse.</div>
</div>
<div class="card"><h2>Security &amp; correct use</h2>
<div class="sec">
&bull; <b>Token server-side only</b> &mdash; the Giftbit Bearer token lives in the hub environment; this browser page never receives it.<br>
&bull; <b>Sandboxed</b> &mdash; this endpoint refuses to run against production; amount, brand list and recipient count are capped.<br>
&bull; <b>Direct</b> create sends one <code>marketplace_gifts</code> entry; <b>multi-brand</b> sends several &mdash; Giftbit returns a shortlink where the recipient picks their brand (<code>brand_code</code> is null until chosen).<br>
&bull; <b>HTTPS</b> end-to-end; links are delivered via our own wrapper with independent expiry; links can be cancelled (<code>DELETE /gifts/{uuid}</code>) and re-issued at any time.
</div>
</div>
</main>
<script>
var BRANDS=[],SEL=[];
function mode(){return document.querySelector('input[name=mode]:checked').value}
function refreshHint(){document.getElementById('modehint').textContent = mode()==='direct' ? 'Pick exactly one brand below.' : 'Pick 2 or more brands; the recipient chooses at redemption.';syncGo()}
function syncGo(){var m=mode();var ok = m==='direct' ? SEL.length===1 : SEL.length>=2;document.getElementById('go').disabled=!ok}
function toggle(id,el){var i=SEL.indexOf(id);if(i>=0){SEL.splice(i,1);el.classList.remove('sel')}else{if(mode()==='direct'){SEL=[id];document.querySelectorAll('.brand').forEach(function(b){b.classList.remove('sel')})}else{SEL.push(id)}el.classList.add('sel')}syncGo()}
fetch('/giftbit-demo/marketplace').then(function(r){return r.json()}).then(function(d){
BRANDS=d.brands||[];var c=document.getElementById('brands');c.innerHTML='';
if(!BRANDS.length){c.innerHTML='<div class="muted">No options.</div>';return}
BRANDS.forEach(function(b){var el=document.createElement('div');el.className='brand';
el.innerHTML=(b.image_url?'<img src="'+b.image_url+'" alt="">':'')+'<span>'+b.name.replace('CAD ','').replace(/ \\|.*$/,'')+'</span>';
el.onclick=function(){toggle(b.id,el)};c.appendChild(el)})
}).catch(function(){document.getElementById('brands').innerHTML='<div class="muted">Failed to load options.</div>'});
document.querySelectorAll('input[name=mode]').forEach(function(r){r.onchange=function(){SEL=[];document.querySelectorAll('.brand').forEach(function(b){b.classList.remove('sel')});refreshHint()}});
document.getElementById('go').onclick=function(){
var btn=this;btn.disabled=true;btn.textContent='Creating…';
var res=document.getElementById('result');res.className='result';
fetch('/giftbit-demo/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand_ids:SEL,email:document.getElementById('email').value})})
.then(function(r){return r.json()}).then(function(d){
btn.textContent='Create $5 reward (testbed)';syncGo();
if(!d.ok){res.className='result show err';res.innerHTML='<b>Error:</b> '+(d.error||'failed');return}
var g=(d.gifts&&d.gifts[0])||{};
if(g.shortlink){renderDemo(d,g);}else{res.className='result show';res.innerHTML='<div class="ok">✓ Reward created &mdash; generating secure link…</div><div class="muted">Campaign '+d.campaign_id+'</div>';pollDemo(d,0);}
}).catch(function(){btn.textContent='Create $5 reward (testbed)';res.className='result show err';res.innerHTML='Request failed.'})
};
function renderDemo(d,g){var res=document.getElementById('result');
var html='<div class="ok">✓ '+(d.mode==='template'?'Multi-brand reward link created':'Direct reward created')+' &mdash; $5 CAD (testbed)</div>';
html+='<div class="link">'+g.shortlink+'</div><a href="'+g.shortlink+'" target="_blank"><button>Open redemption page ↗</button></a>';
html+='<div class="muted" style="margin-top:8px">Campaign: '+d.campaign_id+' &middot; status: '+(g.status||'')+' &middot; brand: '+(g.brand_code||(d.mode==='template'?'(recipient chooses at redemption)':'set'))+'</div>';
res.className='result show';res.innerHTML=html;loadHistory();loadStats();}
function pollDemo(d,n){if(n>14){document.getElementById('result').innerHTML+='<div class="muted">Link still generating (testbed latency) — refresh status shortly.</div>';return}
setTimeout(function(){fetch('/giftbit-demo/status?campaign_id='+encodeURIComponent(d.campaign_id)).then(function(r){return r.json()}).then(function(s){var g=(s.gifts&&s.gifts[0])||{};if(g.shortlink){d.gifts=[g];renderDemo(d,g);}else{pollDemo(d,n+1);}}).catch(function(){pollDemo(d,n+1)})},1800);}
function statusBadge(s){var c=({REDEEMED:'#1b873f',SENT_AND_REDEEMABLE:'#3b46b5',GIVER_CANCELLED:'#b54343'})[s]||'#6a7686';return '<span style="font-size:10px;font-weight:700;color:#fff;background:'+c+';border-radius:5px;padding:1px 7px">'+(s||'—')+'</span>';}
var HIST=[],HPAGE=0,HSIZE=20,HALL=false;
function loadHistory(){fetch('/giftbit-demo/history').then(function(r){return r.json()}).then(function(d){HIST=d.gifts||[];renderHist()}).catch(function(){document.getElementById('history').innerHTML='<div class="muted">Failed to load history.</div>'})}
function renderHist(){var h=document.getElementById('history');if(!HIST.length){h.innerHTML='<div class="muted">No reward links generated yet.</div>';return}var total=HIST.length,pages=HALL?1:Math.max(1,Math.ceil(total/HSIZE));if(HPAGE>=pages)HPAGE=0;var slice=HALL?HIST:HIST.slice(HPAGE*HSIZE,HPAGE*HSIZE+HSIZE);var t='<table style="width:100%;border-collapse:collapse;font-size:12.5px"><tr style="text-align:left;color:#6a7686;border-bottom:1px solid #e3e8ef"><th style="padding:5px 4px">Created</th><th>Brand</th><th>Amount</th><th>Status</th><th>Redeemed</th><th>Link</th><th></th></tr>';slice.forEach(function(g){t+='<tr style="border-bottom:1px solid #f0f2f6"><td style="padding:6px 4px">'+(g.created_date||'')+'</td><td>'+(g.brand_code?g.brand_code:'<span class=muted>recipient chooses</span>')+'</td><td>$'+(((g.price_in_cents||0)/100).toFixed(2))+'</td><td>'+statusBadge(g.status)+'</td><td>'+(g.redeemed_date?g.redeemed_date:'<span class=muted>—</span>')+'</td><td>'+(g.shortlink?'<a href="'+g.shortlink+'" target="_blank">open ↗</a>':'<span class=muted>…</span>')+'</td><td style="text-align:right">'+(g.status==='SENT_AND_REDEEMABLE'?'<button class="rev" data-u="'+g.uuid+'" style="padding:3px 9px;font-size:11px;background:#fff0f0;color:#b54343;border:1px solid #f5c2c2">Revoke</button>':'<span class=muted>'+(g.status==='REDEEMED'?'redeemed':'revoked')+'</span>')+'</td></tr>';});t+='</table>';var pg='<div style="margin-top:9px;display:flex;gap:5px;align-items:center;flex-wrap:wrap"><span class="muted" style="margin-right:4px">'+total+' link(s)</span>';if(!HALL&&pages>1){for(var i=0;i<pages;i++){pg+='<button class="pg" data-p="'+i+'" style="padding:3px 9px;font-size:12px;border:1px solid #cfd6e0;border-radius:6px;cursor:pointer;background:'+(i===HPAGE?'#5b6ef5':'#fff')+';color:'+(i===HPAGE?'#fff':'#3b46b5')+'">'+(i+1)+'</button>';}}pg+='<button id="hall" style="padding:3px 10px;font-size:12px;border:1px solid #cfd6e0;border-radius:6px;cursor:pointer;background:#eef0ff;color:#3b46b5;margin-left:6px">'+(HALL?'Paginate (20/page)':'Show all')+'</button></div>';h.innerHTML=t+pg;bindHist()}
function bindHist(){Array.prototype.forEach.call(document.querySelectorAll('.rev'),function(b){b.onclick=function(){var u=b.getAttribute('data-u');b.disabled=true;b.textContent='…';fetch('/giftbit-demo/revoke',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({uuid:u})}).then(function(r){return r.json()}).then(function(){loadHistory()}).catch(function(){loadHistory()})}});Array.prototype.forEach.call(document.querySelectorAll('.pg'),function(b){b.onclick=function(){HPAGE=parseInt(b.getAttribute('data-p'),10);renderHist()}});var a=document.getElementById('hall');if(a)a.onclick=function(){HALL=!HALL;HPAGE=0;renderHist()}}
document.getElementById('refresh').onclick=loadHistory;
function loadStats(){fetch('/giftbit-demo/stats').then(function(r){return r.json()}).then(renderStats).catch(function(){document.getElementById('stats').innerHTML='<div class="muted">Failed to load.</div>'})}
function renderStats(d){var el=document.getElementById('stats');var days=(d&&d.days)||[];var t=(d&&d.totals)||{};if(!days.length){el.innerHTML='<div class="muted">No data yet.</div>';return}var max=1;days.forEach(function(x){max=Math.max(max,x.created,x.redeemed)});var h='<div style="display:flex;align-items:flex-end;gap:10px;height:118px;padding:4px 2px;overflow-x:auto">';days.forEach(function(x){var ch=Math.round(x.created/max*96),rh=Math.round(x.redeemed/max*96);h+='<div style="text-align:center;min-width:40px"><div style="display:flex;gap:2px;align-items:flex-end;height:98px;justify-content:center"><div title="issued '+x.created+'" style="width:13px;height:'+ch+'px;background:#cfd6e0;border-radius:3px 3px 0 0"></div><div title="redeemed '+x.redeemed+'" style="width:13px;height:'+rh+'px;background:#1b873f;border-radius:3px 3px 0 0"></div></div><div class="muted" style="font-size:10px;margin-top:3px">'+String(x.date).slice(5)+'</div></div>';});h+='</div>';h+='<div style="font-size:12px;margin-top:6px"><span style="display:inline-block;width:10px;height:10px;background:#cfd6e0;border-radius:2px"></span> issued&nbsp;&nbsp;<span style="display:inline-block;width:10px;height:10px;background:#1b873f;border-radius:2px"></span> redeemed&nbsp;·&nbsp;total '+(t.gifts||0)+' issued / '+(t.redeemed||0)+' redeemed';if(t.fast_redemptions)h+='&nbsp;·&nbsp;<b style="color:#b54343">⚠ '+t.fast_redemptions+' instant (&lt;15 min)</b>';if(t.late_redemptions)h+='&nbsp;·&nbsp;<b style="color:#b07a00">'+t.late_redemptions+' late (&gt;72 h)</b>';h+='</div>';el.innerHTML=h}
loadHistory();
loadStats();
refreshHint();
</script></body></html>`
// ─────────────────────────────────────────────────────────────────────────────
// DÉMO « JAIL » (sandbox testbed, publique) — pour la validation par Giftbit.
// Garanties : (1) testbed UNIQUEMENT (refus si prod), (2) montant FIXE 5 $, (3) marques d'une
// liste blanche, (4) 1 destinataire, (5) le token reste SERVEUR (jamais envoyé au navigateur).
// ─────────────────────────────────────────────────────────────────────────────
const DEMO_BRAND_IDS = [386, 574, 572, 617, 619, 620, 621, 622, 568] // CAD, montant variable (≥5 $) — region=1
const DEMO_PRICE = 500
const DEMO_GUARD = () => isTestbed // jamais en prod
async function demoBrands () {
const r = await marketplace({ region: '1', limit: '50' })
const list = (r.body && r.body.marketplace_gifts) || []
return list.filter(g => DEMO_BRAND_IDS.includes(Number(g.id)))
.map(g => ({ id: g.id, name: g.name, image_url: g.image_url, currency: g.currencyisocode }))
}
async function demoCreate (o = {}) {
if (!DEMO_GUARD()) return { ok: false, error: 'Démo désactivée hors environnement testbed.' }
const ids = (Array.isArray(o.brand_ids) ? o.brand_ids : []).map(Number).filter(x => DEMO_BRAND_IDS.includes(x))
if (!ids.length) return { ok: false, error: 'Choisir au moins une marque de la liste.' }
const email = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(o.email || '')) ? o.email : 'demo@targo.ca'
const mode = ids.length > 1 ? 'template' : 'direct'
const cid = 'targo-demo-' + Date.now()
const created = await createCampaign({
id: cid, delivery_type: 'SHORTLINK', expiry: defaultExpiry(),
subject: 'TARGO × Giftbit — Integration demo', message: mode === 'template' ? 'Choose your gift card.' : 'Your gift card.',
marketplace_gifts: ids.map(id => ({ id, price_in_cents: DEMO_PRICE })),
contacts: [{ email, firstname: 'Demo', lastname: 'Recipient' }]
})
if (!created.ok) return { ok: false, error: 'Création refusée', detail: created.body }
// Le gift est asynchrone ET le shortlink se génère APRÈS la carte (~8 s) → on sonde jusqu'au shortlink (max ~12 s ; la page poll aussi en repli).
let gifts = []
for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 800)); const g = await listGifts({ campaign_id: cid }); gifts = (g.body && g.body.gifts) || []; if (gifts.length && gifts[0].shortlink) break } // réponse rapide ; la page poll /status pour le shortlink si pas encore prêt
return { ok: true, mode, campaign_id: cid, campaign_uuid: (created.body && created.body.campaign && created.body.campaign.uuid) || '', price_in_cents: DEMO_PRICE, brands: ids, gifts: gifts.map(g => ({ uuid: g.uuid, shortlink: g.shortlink, status: g.status, brand_code: g.brand_code })) }
}
// Historique des liens générés par la démo (campaign_id préfixé targo-demo-), trié du + récent au + ancien.
async function demoHistory () {
const r = await listGifts({ limit: 500 })
const gs = (r.body && r.body.gifts) || []
return gs.filter(g => String(g.campaign_id || '').startsWith('targo-demo-'))
.sort((a, b) => String(b.created_date || '').localeCompare(String(a.created_date || '')))
.map(g => ({ uuid: g.uuid, shortlink: g.shortlink, status: g.status, brand_code: g.brand_code, price_in_cents: g.price_in_cents, created_date: g.created_date, redeemed_date: g.redeemed_date || '', recipient_email: g.recipient_email }))
}
// Stat « activité par jour » : créés vs rédemés/jour + signaux d'abus (rédemptions « instantanées » < 15 min = bot-like ;
// rédemptions tardives = filet régulier après la courbe organique). Révèle un pirate low-profile.
function dayOf (s) { return String(s || '').slice(0, 10) }
function _ts (s) { const t = Date.parse(String(s || '').replace(' ', 'T') + 'Z'); return isFinite(t) ? t : null }
function dailyAgg (gifts) {
const map = {}; let redeemed = 0; let fast = 0; let lateDrip = 0
for (const g of gifts) {
const cd = dayOf(g.created_date); if (cd) { (map[cd] = map[cd] || { created: 0, redeemed: 0 }).created++ }
if (g.status === 'REDEEMED' && g.redeemed_date) {
const rd = dayOf(g.redeemed_date); (map[rd] = map[rd] || { created: 0, redeemed: 0 }).redeemed++; redeemed++
const t1 = _ts(g.created_date); const t2 = _ts(g.redeemed_date)
if (t1 != null && t2 != null) { const dh = (t2 - t1) / 3600000; if (dh < 0.25) fast++; if (dh > 72) lateDrip++ } // <15 min = instantané ; >72 h = tardif
}
}
const days = Object.keys(map).sort().map(d => ({ date: d, created: map[d].created, redeemed: map[d].redeemed }))
return { days, totals: { gifts: gifts.length, redeemed, fast_redemptions: fast, late_redemptions: lateDrip } }
}
// Révocation depuis la démo : SEULEMENT un gift démo (garde-fou) → DELETE → GIVER_CANCELLED (montre le traitement d'un lien compromis).
async function demoRevoke (uuid) {
if (!DEMO_GUARD()) return { ok: false, error: 'testbed only' }
const cur = await getGift(uuid); const g = cur.body && cur.body.gift
if (!g || !String(g.campaign_id || '').startsWith('targo-demo-')) return { ok: false, error: 'Only demo links can be revoked here.' }
if (g.status !== 'SENT_AND_REDEEMABLE') return { ok: false, error: 'Only unredeemed links can be revoked (current status: ' + g.status + ').' }
const r = await cancelGift(uuid)
return { ok: r.ok, status: (r.body && r.body.gift && r.body.gift.status) || 'GIVER_CANCELLED' }
}
async function handle (req, res, method, path, url) {
const qs = (url && url.searchParams) || new URLSearchParams()
// ── Démo publique (sandbox) : page HTML + données testbed. Sert AVANT le contrôle TOKEN pour que la page charge toujours. ──
if (path === '/giftbit-demo' || path === '/giftbit-demo/' || path === '/giftbit-demo.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(DEMO_HTML) }
if (path.startsWith('/giftbit-demo/')) {
if (!TOKEN) return json(res, 503, { ok: false, error: 'token absent' })
if (!isTestbed) return json(res, 403, { ok: false, error: 'Démo disponible en testbed seulement.' })
if (path === '/giftbit-demo/marketplace') return json(res, 200, { ok: true, testbed: true, brands: await demoBrands() })
if (path === '/giftbit-demo/create' && method === 'POST') { const b = await parseBody(req); return json(res, 200, await demoCreate(b)) }
if (path === '/giftbit-demo/status') { const r = await listGifts({ campaign_id: qs.get('campaign_id') || '' }); return json(res, 200, r.body || { ok: false }) }
if (path === '/giftbit-demo/history') return json(res, 200, { ok: true, gifts: await demoHistory() })
if (path === '/giftbit-demo/stats') return json(res, 200, { ok: true, ...dailyAgg(await demoHistory()) })
if (path === '/giftbit-demo/revoke' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); return json(res, 200, await demoRevoke(b.uuid)) }
return json(res, 404, { ok: false, error: 'route démo inconnue' })
}
if (!TOKEN) return json(res, 503, { ok: false, error: 'GIFTBIT_TOKEN absent de l\'env du hub' })
try {
if (path === '/giftbit/ping') { const r = await ping(); return json(res, 200, { ok: r.ok, testbed: isTestbed, ...(r.body || {}), _status: r.status }) }
if (path === '/giftbit/funds') { const r = await funds(); return json(res, 200, { ok: r.ok, testbed: isTestbed, ...(r.body || {}) }) }
if (path === '/giftbit/marketplace') { const r = await marketplace(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/brands') { const r = await brands(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/gifts' && method === 'GET') { const r = await listGifts(Object.fromEntries(qs)); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/stats' && method === 'GET') { const q = Object.fromEntries(qs); const r = await listGifts({ campaign_id: q.campaign_id || '', campaign_uuid: q.campaign_uuid || '', limit: q.limit || 1000 }); const gifts = (r.body && r.body.gifts) || []; return json(res, 200, { ok: true, count: gifts.length, ...dailyAgg(gifts) }) }
if (path.startsWith('/giftbit/gift/') && method === 'GET') { const r = await getGift(path.split('/').pop()); return json(res, 200, r.body || { ok: false, error: r.error }) }
if (path === '/giftbit/cancel' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); const r = await cancelGift(b.uuid); return json(res, 200, { ok: r.ok, ...(r.body || {}) }) }
if (path === '/giftbit/campaign' && method === 'POST') { const b = await parseBody(req); if (!b.id || !b.marketplace_gift_id || !b.price_in_cents || !Array.isArray(b.contacts) || !b.contacts.length) return json(res, 400, { ok: false, error: 'id, marketplace_gift_id, price_in_cents, contacts[] requis' }); const r = await createCampaign(b); return json(res, 200, { ok: r.ok, ...(r.body || {}), _status: r.status }) }
if (path === '/giftbit/replace' && method === 'POST') { const b = await parseBody(req); if (!b.uuid) return json(res, 400, { ok: false, error: 'uuid requis' }); const r = await replaceGift(b); return json(res, r.ok ? 200 : 500, r) }
return json(res, 404, { ok: false, error: 'route giftbit inconnue' })
} catch (e) { return json(res, 500, { ok: false, error: String((e && e.message) || e) }) }
}
module.exports = { handle, api, ping, funds, marketplace, brands, listGifts, getGift, cancelGift, createCampaign, replaceGift, isTestbed, BASE }