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>
301 lines
31 KiB
JavaScript
301 lines
31 KiB
JavaScript
// 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 × Giftbit — 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 × Giftbit — Integration Demo</h1><div class="sub">Reward creation via Giftbit API — direct brand & multi-brand link (recipient choice)</div></header>
|
||
<div class="sandbox">🧪 <b>SANDBOX / TESTBED</b> — 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>• <b>Direct reward</b> — one specific brand, fixed at issuance.
|
||
<br>• <b>Reward link (multi-brand)</b> — 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> — one brand (fixed)</label>
|
||
<label><input type="radio" name="mode" value="template"> <b>Reward link</b> — 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…</div></div>
|
||
</div>
|
||
<div class="card"><h2><span class="step">3</span>Recipient & 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…</div></div>
|
||
<div class="muted" style="margin-top:6px">Live statuses from the testbed: <b>SENT_AND_REDEEMABLE</b> → <b>REDEEMED</b> (brand fills in once the recipient chooses) → <b>GIVER_CANCELLED</b> (revoked). “Revoke” 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…</div></div>
|
||
<div class="muted" style="margin-top:6px">Organic redemptions cluster right after delivery, then taper off. A steady daily trickle — or many <b>instant</b> redemptions (< 15 min after issue) — can indicate automated / low-profile abuse.</div>
|
||
</div>
|
||
<div class="card"><h2>Security & correct use</h2>
|
||
<div class="sec">
|
||
• <b>Token server-side only</b> — the Giftbit Bearer token lives in the hub environment; this browser page never receives it.<br>
|
||
• <b>Sandboxed</b> — this endpoint refuses to run against production; amount, brand list and recipient count are capped.<br>
|
||
• <b>Direct</b> create sends one <code>marketplace_gifts</code> entry; <b>multi-brand</b> sends several — Giftbit returns a shortlink where the recipient picks their brand (<code>brand_code</code> is null until chosen).<br>
|
||
• <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 — 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')+' — $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+' · status: '+(g.status||'')+' · 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 <span style="display:inline-block;width:10px;height:10px;background:#1b873f;border-radius:2px"></span> redeemed · total '+(t.gifts||0)+' issued / '+(t.redeemed||0)+' redeemed';if(t.fast_redemptions)h+=' · <b style="color:#b54343">⚠ '+t.fast_redemptions+' instant (<15 min)</b>';if(t.late_redemptions)h+=' · <b style="color:#b07a00">'+t.late_redemptions+' late (>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 }
|