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:
parent
37896421c3
commit
e1283f30e8
|
|
@ -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.
|
||||
|
|
|
|||
228
scripts/campaigns/create_giftbit_campaign.js
Normal file
228
scripts/campaigns/create_giftbit_campaign.js
Normal 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) })
|
||||
Loading…
Reference in New Issue
Block a user