gigafibre-fsm/scripts/campaigns/create_giftbit_campaign.js
louispaulb e1283f30e8 feat(campaigns): add Giftbit API client + validate end-to-end with sandbox
Adds create_giftbit_campaign.js — Node CLI that POSTs to the Giftbit
API (testbed or production), creates a campaign with
delivery_type=SHORTLINK so Giftbit does NOT send their own English
template emails, polls /gifts?campaign_uuid=... until the redemption
shortlinks are generated, then writes a gifts CSV ready to feed into
send_gift_campaign.js.

Two non-obvious things learned while wiring it up:

1. The right endpoint to get the shortlinks is /gifts (not /links).
   /links/{uuid} returned 0 rows on our sandbox account; /gifts has
   a `shortlink` field on each gift once delivery_status transitions
   from QUEUED → LINKCREATED. Polled with 2s interval, up to 20 tries.

2. delivery_type=SHORTLINK is mandatory. Default is GIFTBIT_EMAIL,
   which fires their English template immediately — defeating the
   whole point of bridging through our French Mailjet template.
   Confirmed in the campaign GET response that delivery_type echoes
   back correctly when we send "SHORTLINK".

Validated end-to-end (entirely synthetic data — Alice/Bob/Charlie at
@example.com, no real customer info in the sandbox):
  ✓ Auth probe via /ping returns 200
  ✓ POST /campaign returns campaign UUID
  ✓ After ~12s, /gifts returns 3 gifts each with a working shortlink
  ✓ send_gift_campaign.js consumes the gifts CSV + the contacts CSV
  ✓ FR template renders: "Bonjour Alice", http://gtbt.co/7TKGFDBNVZq
    embedded in the CTA button href, address in the footer line

The --sandbox flag does double duty: routes the API to
api-testbed.giftbit.com AND replaces every recipient email with
louis@targo.ca so we can't accidentally hit real customer inboxes
with the non-redeemable test gifts.

README updated with the two-stage pipeline (create → send), explicit
warnings about the customer-matching gap (only 25% of source rows
resolve via legacy_delivery_id — the rest use a different ID space
from the source Map tool), and the sandbox-quirk where Giftbit
collapses recipient_name when emails are duplicated.

Token NOT committed — pulled from GIFTBIT_TOKEN env var per the
script's contract. In production we'll store it in the hub's
.env alongside SMTP_USER / SMTP_PASS.
2026-05-21 16:20:28 -04:00

229 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
'use strict'
/**
* create_giftbit_campaign.js — Create a Giftbit campaign via API and pull
* back the per-recipient gift URLs as a CSV ready for send_gift_campaign.js.
*
* Giftbit's default delivery_type sends emails THEMSELVES (in English).
* We don't want that — we want the gift URLs returned to us so we can
* send French personalized emails through our own Mailjet. This script
* uses delivery_type=SHORTLINK which is the API value for "give me the
* links, I'll deliver them".
*
* Usage:
* GIFTBIT_TOKEN=<your_token> \
* node create_giftbit_campaign.js \
* --contacts ./giftbit-contacts-A-first-email.csv \
* --amount-cents 5000 \
* --brand-codes amazonca,walmart,timhortonsca \
* --expiry 2026-12-31 \
* --subject "Cadeau Gigafibre" \
* --message "Merci d'être client" \
* [--sandbox] ← uses api-testbed.giftbit.com + louis@targo.ca for all
* [--id my-campaign-2026-q4]
*
* Output: giftbit-gifts-<campaign-id>.csv with one row per recipient:
* firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents
*
* Feed this CSV (+ the contacts CSV) to send_gift_campaign.js for the FR
* email blast through Mailjet.
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
// ── CLI parse ──────────────────────────────────────────────────────────────
function parseArgs (argv) {
const out = {}
for (let i = 2; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith('--')) {
const k = a.slice(2); const next = argv[i + 1]
if (!next || next.startsWith('--')) out[k] = true
else { out[k] = next; i++ }
}
}
return out
}
const args = parseArgs(process.argv)
const TOKEN = process.env.GIFTBIT_TOKEN
if (!TOKEN) { console.error('Set GIFTBIT_TOKEN env var.'); process.exit(1) }
const SANDBOX = !!args.sandbox
const HOST = SANDBOX ? 'api-testbed.giftbit.com' : 'api.giftbit.com'
const ROOT = '/papi/v1'
const REQUIRED = ['contacts', 'amount-cents', 'brand-codes', 'expiry', 'subject', 'message']
for (const k of REQUIRED) {
if (!args[k]) { console.error(`Missing --${k}`); process.exit(1) }
}
const CAMPAIGN_ID = args.id || ('gigafibre-' + new Date().toISOString().slice(0, 13).replace(/[-:T]/g, ''))
// ── CSV parser (same as send_gift_campaign.js, kept self-contained) ───────
function parseCsv (text) {
const sample = text.split(/\r?\n/, 1)[0] || ''
const delim = sample.includes(',') ? ',' : (sample.includes('\t') ? '\t' : '|')
const rows = []; let row = [], field = '', inQ = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (inQ) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++ }
else if (c === '"') inQ = false
else field += c
} else {
if (c === '"') inQ = true
else if (c === delim) { row.push(field); field = '' }
else if (c === '\n' || c === '\r') {
if (field !== '' || row.length) { row.push(field); rows.push(row); row = []; field = '' }
if (c === '\r' && text[i + 1] === '\n') i++
} else field += c
}
}
if (field !== '' || row.length) { row.push(field); rows.push(row) }
if (!rows.length) return []
const header = rows[0].map(h => h.trim())
return rows.slice(1).filter(r => r.some(c => c !== '')).map(r => {
const o = {}; header.forEach((h, i) => { o[h] = (r[i] || '').trim() }); return o
})
}
// ── HTTPS helper ───────────────────────────────────────────────────────────
function apiCall (method, urlPath, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null
const req = https.request({
host: HOST, path: ROOT + urlPath, method,
headers: {
'Authorization': 'Bearer ' + TOKEN,
'Accept': 'application/json',
...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}),
},
}, res => {
let chunks = ''
res.on('data', c => { chunks += c })
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(chunks) }) }
catch (e) { resolve({ status: res.statusCode, body: chunks }) }
})
})
req.on('error', reject)
if (data) req.write(data)
req.end()
})
}
function csvCell (s) {
s = String(s == null ? '' : s)
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main () {
console.log(`\n── Giftbit campaign create ${SANDBOX ? '(SANDBOX)' : '(PRODUCTION)'} ──`)
console.log(` host: ${HOST}`)
console.log(` id: ${CAMPAIGN_ID}`)
console.log(` amount: ${args['amount-cents']} cents ($${(+args['amount-cents'] / 100).toFixed(2)})`)
console.log(` brands: ${args['brand-codes']}`)
console.log(` expiry: ${args.expiry}`)
console.log(` contacts: ${args.contacts}`)
// Verify auth first — fail fast if token is bad
const ping = await apiCall('GET', '/ping')
if (ping.status !== 200) {
console.error(' ✗ Auth failed:', ping.body); process.exit(1)
}
console.log(` ✓ auth ok — ${ping.body.displayname} (${ping.body.username})`)
// Load and prepare contacts
const contactsRaw = parseCsv(fs.readFileSync(args.contacts, 'utf8'))
if (!contactsRaw.length) { console.error('Empty contacts CSV'); process.exit(1) }
// SANDBOX SAFETY: route every email to louis@targo.ca so we don't
// accidentally hit real customer inboxes with non-redeemable test gifts.
const contacts = contactsRaw.map((c, idx) => ({
firstname: c.firstname || '',
lastname: c.lastname || '',
email: SANDBOX ? 'louis@targo.ca' : c.email,
// Pass our internal identifier so the response can be joined back to
// the customer record in ERPNext. We use `id` because that's the
// Giftbit-standard field for contact reference.
id: c.account_id || c.email || `row-${idx + 1}`,
}))
console.log(` ${contacts.length} contacts loaded${SANDBOX ? ' — ALL routed to louis@targo.ca' : ''}`)
// Build campaign payload.
// delivery_type=SHORTLINK is critical: it tells Giftbit "create the gifts
// but DON'T email the recipients — give me back the URLs so I can deliver
// them myself". Without this (default = GIFTBIT_EMAIL) Giftbit sends
// their English template emails immediately, defeating the whole purpose
// of routing through our French Mailjet template.
const payload = {
id: CAMPAIGN_ID,
subject: args.subject,
message: args.message,
expiry: args.expiry,
price_in_cents: parseInt(args['amount-cents'], 10),
brand_codes: args['brand-codes'].split(',').map(s => s.trim()),
contacts,
delivery_type: 'SHORTLINK',
}
const create = await apiCall('POST', '/campaign', payload)
if (create.status !== 200) {
console.error(' ✗ create failed:', JSON.stringify(create.body, null, 2)); process.exit(1)
}
console.log(` ✓ campaign created: ${create.body.campaign?.uuid || CAMPAIGN_ID}`)
// Wait for gift shortlinks to be generated (Giftbit creates them
// asynchronously when delivery_type=SHORTLINK). Each /gifts row has
// a `shortlink` field (http://gtbt.co/XXX) once delivery_status
// transitions from QUEUED → LINKCREATED.
const campaignUuid = create.body.campaign?.uuid || CAMPAIGN_ID
console.log(` → polling /gifts?campaign_uuid=${campaignUuid} for shortlinks…`)
let gifts = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 2000))
const r = await apiCall('GET', `/gifts?campaign_uuid=${encodeURIComponent(campaignUuid)}&limit=500`)
if (r.status === 200) {
gifts = (r.body.gifts || []).filter(g => g.shortlink)
if (gifts.length >= contacts.length) break
process.stdout.write(` ${gifts.length}/${contacts.length}`)
}
}
console.log(`\n${gifts.length} gifts with shortlinks`)
if (!gifts.length) {
console.error(' no shortlinks — campaign may still be processing.')
console.error(' retry: curl -H "Authorization: Bearer $GIFTBIT_TOKEN" \\')
console.error(` ${SANDBOX ? 'https://api-testbed.giftbit.com' : 'https://api.giftbit.com'}/papi/v1/gifts?campaign_uuid=${campaignUuid}`)
process.exit(1)
}
// Match returned gifts to original contacts via recipient_email +
// ordering. Since Giftbit doesn't echo back our contact `id` field,
// we rely on the sequence Giftbit processed them in — usually
// matches the order we POSTed contacts. For production with unique
// emails per contact, recipient_email is a clean join key.
const out = path.resolve(`giftbit-gifts-${CAMPAIGN_ID}.csv`)
const w = fs.createWriteStream(out, { encoding: 'utf8' })
w.write('firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id\n')
const sortedGifts = gifts.sort((a, b) => (a.created_date || '').localeCompare(b.created_date || ''))
for (let i = 0; i < contacts.length && i < sortedGifts.length; i++) {
const g = sortedGifts[i]; const c = contacts[i]
w.write([
csvCell(c.firstname), csvCell(c.lastname), csvCell(c.email),
csvCell(g.shortlink),
csvCell(g.uuid),
csvCell(g.price_in_cents),
csvCell(c.id),
].join(',') + '\n')
}
w.end()
console.log(`\n${out}`)
console.log(`\nNext: pass this CSV to send_gift_campaign.js to blast the FR emails.`)
}
main().catch(e => { console.error('Fatal:', e); process.exit(1) })