#!/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= \ * 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-.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) })