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.
This commit is contained in:
louispaulb 2026-05-21 16:20:28 -04:00
parent 37896421c3
commit e1283f30e8
2 changed files with 329 additions and 15 deletions

View File

@ -3,31 +3,89 @@
One-shot tool to send Giftbit gift cards to a list of contacts with a branded
French email, bypassing Giftbit's English-only built-in delivery.
## How it works
## How it works (two-stage pipeline)
1. You generate the gifts in Giftbit (UI or API) and export/receive a CSV
containing one `gift_url` per recipient (one of the standard Giftbit
column names: `gift_url`, `gift_link`, `url`, `link`, `redemption_url`).
2. You produce a CSV of contacts with columns
`firstname, lastname, email, description`. The repo has a Python helper
for this — see how `giftbit-contacts-A-first-email.csv` was generated.
3. This script matches the two CSVs (by row order, the default) and sends
one personalized French email per recipient via Mailjet SMTP.
4. A `results-<timestamp>.csv` is written next to the script with per-row
`status` (`sent` / `failed` / `dry-run`), error message, and timestamp.
The campaign is split into two scripts you run in sequence:
The Giftbit redemption landing page (where the recipient picks a brand) is
controlled by Giftbit — set the campaign language to `fr-CA` in their UI
or via their API so the page itself is French.
```
contacts_from_legacy.py # (one-time) extract clean contacts from legacy CSV
contacts.csv
create_giftbit_campaign.js # POST to Giftbit API → SHORTLINK gifts back
gifts.csv + contacts.csv
send_gift_campaign.js # personalized FR emails via Mailjet
results-<timestamp>.csv # per-row status for follow-up
```
**Critical**: the create script passes `delivery_type=SHORTLINK` to Giftbit
so they generate the redemption links but DO NOT send their own English
emails. We then deliver French personalized mail through Mailjet, the
same SMTP wired up for ERPNext invoices.
The Giftbit redemption landing page (where the recipient picks a brand)
is controlled by Giftbit — when creating the campaign through their
dashboard for the first time, set the language to `fr-CA` so the page
shows in French. The API exposes a `language` field too but it wasn't
fully exposed on our sandbox account; verify with the campaign you
created in the Giftbit dashboard.
## Setup
```bash
cd scripts/campaigns
npm init -y # one-time, creates package.json
npm install nodemailer # the only dependency
npm install nodemailer # only dependency (create_giftbit_campaign.js
# uses Node built-ins, no http library needed)
```
## Stage 1 — create the Giftbit campaign
```bash
# Sandbox test (all recipients are rerouted to louis@targo.ca for safety):
export GIFTBIT_TOKEN="<your testbed token>"
node create_giftbit_campaign.js \
--contacts ./test-contacts.csv \
--amount-cents 5000 \
--brand-codes amazonca,timhortonsca,walmart \
--expiry 2026-12-31 \
--subject "Cadeau Gigafibre" \
--message "Merci d'être client" \
--sandbox \
--id "test-q4-2026"
# Production (real recipient emails, real gifts charged from your balance):
export GIFTBIT_TOKEN="<your prod token>"
node create_giftbit_campaign.js \
--contacts ./contacts.csv \
--amount-cents 5000 \
--brand-codes amazonca,timhortonsca,walmart \
--expiry 2026-12-31 \
--subject "Cadeau Gigafibre" \
--message "Merci d'être client" \
--id "q4-2026-loyalty"
```
Output: `giftbit-gifts-<id>.csv` with columns:
```
firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id
Alice,Tremblay,louis@targo.ca,http://gtbt.co/7TKGFDBNVZq,bdb28566...,500,TEST-001
```
`internal_id` is your contact's `account_id` column passed through to
join the response back to ERPNext customer records.
The `--sandbox` flag does TWO things:
- Points the API at `api-testbed.giftbit.com` instead of `api.giftbit.com`
- Replaces every recipient email with `louis@targo.ca` as a safety net
so the test gifts (non-redeemable in sandbox) don't actually land in
any real customer inbox
## Stage 2 — send the personalized French emails
## Dry run (no emails sent, HTML written for preview)
```bash
@ -140,3 +198,31 @@ Check `results-<timestamp>.csv`:
- No ops UI — pure CLI. If we end up running gift campaigns regularly,
wrap this in a `services/targo-hub/lib/gift-campaign.js` endpoint and
add a page in ops. For now, one-shot CLI is sufficient.
## Known issues to resolve before production
1. **Customer matching from the source CSV is only 25%** — the `id
emplacement` column in `selectionAdressesMap*.csv` is NOT a
`legacy_delivery_id`. Of 216 source rows, only 54 resolve to a
Service Location via that column. The other 162 use a different ID
space (50000+ range, while migrated SLs are 1-17307). Before going
to production, we need to either:
- Match by address (street + civic + postal_code) to find the
correct Service Location → Customer
- Or have the Map tool include the actual Service Location `name`
(`LOC-XXXXX`) in its export
The current `account_id` column in our contacts CSV is approximated;
for accurate Customer audit-trail we need this fixed.
2. **The Giftbit testbed token** in our hub `.env` is sandbox-only.
Production access requires Giftbit-side KYC + account funding +
API approval. While waiting, all testing happens with the testbed
token and the `--sandbox` flag — gift URLs work in their test
webapp but represent no real money.
3. **`recipient_name` collapses when emails are duplicated.** In
sandbox we send all 3 test gifts to `louis@targo.ca`, and Giftbit's
API returns the same `recipient_name` for all of them (apparently
they dedup by email). In production with distinct emails per
contact, each gift has the right name. This is a sandbox-only
quirk, not a script bug.

View File

@ -0,0 +1,228 @@
#!/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) })