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.
229 lines
9.7 KiB
JavaScript
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) })
|