diff --git a/scripts/campaigns/README.md b/scripts/campaigns/README.md index c44f8c9..e2da8a7 100644 --- a/scripts/campaigns/README.md +++ b/scripts/campaigns/README.md @@ -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-.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-.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="" +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="" +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-.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-.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. diff --git a/scripts/campaigns/create_giftbit_campaign.js b/scripts/campaigns/create_giftbit_campaign.js new file mode 100644 index 0000000..ad0a6f3 --- /dev/null +++ b/scripts/campaigns/create_giftbit_campaign.js @@ -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= \ + * 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) })