feat(campaigns): gift redirect wrapper — own expiry + reusable links
Each campaign recipient now gets a short opaque token (10 base64url chars, ~60 bits entropy). The email contains https://msg.gigafibre.ca/g/<token> which 302-redirects to the underlying Giftbit shortlink — but ONLY if the recipient hasn't passed our own expires_at and we haven't revoked the token. This gives us two new operational capabilities: 1. End-date control independent of Giftbit. The wizard now has a "Expiration interne (jours)" field (default 90) that sets our own deadline. Useful when the Giftbit gift is valid 12 months but the campaign offer should expire in 30 days. 2. Reuse of unredeemed gifts. After our expiry, the old wrapper stops working but the Giftbit URL is still valid on their side. Pasting that same gift_url into a new campaign (via the manual-add dialog) generates a NEW token pointing to the same Giftbit gift — the original recipient's old wrapper URL says "expired", the new recipient gets a fresh window. Per-recipient new fields: - gift_token short ID used in the wrapper URL - gift_expires_at ISO timestamp of our cutoff - gift_revoked manual kill-switch (false by default) - gift_redirected_count clicks that successfully reached Giftbit - gift_first_redirected_at first successful redirect timestamp Routing: - GET /g/:token — public, validates and 302s (or expired-page) - Mailjet click event handler updated to recognise wrapper URLs alongside legacy gft.link/giftbit.com URLs. - /view (browser fallback for in-email rendering) also wraps the gift link so expiry/revoke is honoured consistently. Bootstrap rebuilds the in-memory token→recipient index by scanning all campaign JSONs on startup — no separate index file to keep in sync. CSV report adds gift_token, gift_expires_at, gift_revoked, gift_redirected_count, gift_first_redirected_at. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1c5241df69
commit
c0ca5feb6f
|
|
@ -66,7 +66,11 @@
|
|||
<q-input v-model="params.subject" label="Sujet du courriel" outlined dense class="col-12 col-md-6" />
|
||||
<q-input v-model="params.from" label="Expéditeur (From)" outlined dense class="col-12 col-md-6" placeholder="TARGO <support@targointernet.com>" />
|
||||
<q-input v-model="params.expiry" label="Expiration (texte affiché)" outlined dense class="col-12 col-md-6" placeholder="31 décembre 2026" />
|
||||
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
|
||||
<q-input v-model.number="params.gift_expiry_days" type="number" min="1" max="365"
|
||||
label="Expiration interne (jours)" outlined dense class="col-6 col-md-3" placeholder="90">
|
||||
<q-tooltip>Délai après lequel le lien intermédiaire /g/<token> expire et ne redirige plus. Le lien Giftbit sous-jacent reste valide chez Giftbit jusqu'à leur propre date — utile pour réassigner un cadeau non utilisé à un autre client.</q-tooltip>
|
||||
</q-input>
|
||||
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-6 col-md-3" />
|
||||
<q-select v-model="params.multi" :options="multiOptions" emit-value map-options label="Emails multiples (couples)" outlined dense class="col-12 col-md-6" />
|
||||
<!-- Per-language template override. Defaults to gift-email-fr /
|
||||
gift-email-en. Lets the operator pick seasonal or A/B
|
||||
|
|
@ -495,6 +499,11 @@ const params = ref({
|
|||
expiry: '',
|
||||
throttle_ms: 600,
|
||||
multi: 'first',
|
||||
// Internal wrapper-URL expiry. Independent of Giftbit's own expiry —
|
||||
// after this many days the /g/<token> link returns the expired page,
|
||||
// freeing the underlying Giftbit gift_url for reassignment to another
|
||||
// customer in a new campaign.
|
||||
gift_expiry_days: 90,
|
||||
// Per-language template selection. Defaults match the canonical templates;
|
||||
// operator can switch to a variant (e.g. seasonal) per campaign.
|
||||
template_fr: 'gift-email-fr',
|
||||
|
|
|
|||
|
|
@ -721,6 +721,95 @@ function resolveTemplatePath (p, lang) {
|
|||
return templateForLanguage(lang)
|
||||
}
|
||||
|
||||
// ── Gift redirect wrapper ───────────────────────────────────────────────────
|
||||
// We don't put the raw Giftbit shortlink in outgoing emails any more. Instead
|
||||
// each recipient gets a short opaque token, and the email contains
|
||||
// https://msg.gigafibre.ca/g/<token>
|
||||
// which 302-redirects to the underlying Giftbit URL — but ONLY if our own
|
||||
// expiry hasn't passed and we haven't revoked it. This gives us:
|
||||
// 1. End-date control independent of Giftbit's (e.g. expire after 30d
|
||||
// even if Giftbit's underlying gift is valid for 12 months)
|
||||
// 2. Reuse of unredeemed gifts: if recipient A doesn't click, we generate
|
||||
// a NEW token for recipient B pointing to the same Giftbit URL. A's
|
||||
// old wrapper URL stops working (expired/revoked) while B's new one
|
||||
// directs to the same Giftbit gift.
|
||||
//
|
||||
// In-memory index `tokenIndex` is the fast lookup; the source of truth lives
|
||||
// inside each campaign's recipient row (gift_token, gift_url, gift_expires_at,
|
||||
// gift_revoked, gift_redirected_count, gift_first_redirected_at). The index
|
||||
// is rebuilt at startup by scanning all campaign JSONs.
|
||||
|
||||
const tokenIndex = new Map() // token → { campaign_id, row }
|
||||
|
||||
function generateGiftToken () {
|
||||
// 10 base64url chars ≈ 60 bits entropy — collision-safe for our volumes
|
||||
// (millions of tokens × billion years before a clash). Avoids ambiguity
|
||||
// characters (0/O, 1/l/I) accidentally — base64url already does.
|
||||
return crypto.randomBytes(8).toString('base64url').slice(0, 10)
|
||||
}
|
||||
|
||||
function rebuildTokenIndex () {
|
||||
tokenIndex.clear()
|
||||
let n = 0
|
||||
for (const meta of listCampaigns()) {
|
||||
const c = loadCampaign(meta.id)
|
||||
if (!c?.recipients) continue
|
||||
for (let i = 0; i < c.recipients.length; i++) {
|
||||
const tok = c.recipients[i].gift_token
|
||||
if (tok) { tokenIndex.set(tok, { campaign_id: c.id, row: i }); n++ }
|
||||
}
|
||||
}
|
||||
log(`gift token index rebuilt: ${n} tokens across ${listCampaigns().length} campaigns`)
|
||||
}
|
||||
|
||||
function lookupGiftToken (token) {
|
||||
const ref = tokenIndex.get(token)
|
||||
if (!ref) return null
|
||||
const c = loadCampaign(ref.campaign_id)
|
||||
if (!c?.recipients?.[ref.row]) return null
|
||||
return { campaign: c, row: ref.row, recipient: c.recipients[ref.row] }
|
||||
}
|
||||
|
||||
// Renders the wrapper URL for a recipient. Falls back to the raw gift_url
|
||||
// when no token is set (backwards-compat with campaigns sent before this
|
||||
// feature shipped — those emails already left and contain the raw URL).
|
||||
function wrapperUrl (token) {
|
||||
return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/g/${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
// Static HTML returned when a token is expired/revoked/unknown. Branded but
|
||||
// minimal so a 60s loading time from a corporate proxy doesn't reveal
|
||||
// internal structure.
|
||||
function giftExpiredPage (reason) {
|
||||
const messages = {
|
||||
expired: { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' },
|
||||
revoked: { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' },
|
||||
notfound: { fr: 'Lien introuvable.', en: 'Link not found.' },
|
||||
}
|
||||
const m = messages[reason] || messages.notfound
|
||||
return `<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
||||
<title>TARGO — ${m.fr}</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<style>
|
||||
body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#F5FAF7;color:#1B2E24;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#fff;max-width:480px;margin:24px;padding:48px 32px;border-radius:16px;border:1px solid #e5e7eb;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.logo{font-size:28px;font-weight:800;color:#00C853;margin-bottom:24px;letter-spacing:-.5px}
|
||||
.icon{font-size:48px;margin-bottom:16px}
|
||||
h1{font-size:22px;margin:0 0 8px;font-weight:700}
|
||||
p{color:#64748B;margin:0;line-height:1.5}
|
||||
.en{margin-top:16px;font-size:14px;color:#94a3b8}
|
||||
a{color:#00C853;text-decoration:none;font-weight:600}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<div class="logo">TARGO</div>
|
||||
<div class="icon">🎁</div>
|
||||
<h1>${m.fr}</h1>
|
||||
<p>Si tu penses qu'il s'agit d'une erreur, écris-nous à <a href="mailto:support@targo.ca">support@targo.ca</a>.</p>
|
||||
<p class="en">${m.en}</p>
|
||||
</div></body></html>`
|
||||
}
|
||||
|
||||
function templatePath (name) {
|
||||
if (!isValidTemplateName(name)) {
|
||||
throw new Error(`template name invalid or not allowed: ${name}`)
|
||||
|
|
@ -849,12 +938,24 @@ async function sendCampaignAsync (id) {
|
|||
? `${cents / 100} $`
|
||||
: `${(cents / 100).toFixed(2)} $`
|
||||
}
|
||||
// Generate the wrapper token if absent (idempotent across retries).
|
||||
// Computed BEFORE the gift_url var so the email gets the wrapped URL.
|
||||
if (!r.gift_token && r.gift_url) {
|
||||
r.gift_token = generateGiftToken()
|
||||
const expiryDays = parseInt(p.gift_expiry_days || 90, 10)
|
||||
r.gift_expires_at = new Date(Date.now() + expiryDays * 86400 * 1000).toISOString()
|
||||
r.gift_revoked = false
|
||||
r.gift_redirected_count = 0
|
||||
tokenIndex.set(r.gift_token, { campaign_id: id, row: i })
|
||||
}
|
||||
const vars = {
|
||||
firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
|
||||
lastname: r.lastname || '',
|
||||
email: r.email,
|
||||
description: r.civic_address || '',
|
||||
gift_url: r.gift_url,
|
||||
// Wrapper URL when we have a token; fall back to the raw Giftbit URL
|
||||
// (backwards-compat for recipients sent before this feature shipped).
|
||||
gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
|
||||
amount: displayAmount,
|
||||
expiry: p.expiry || '',
|
||||
commitment_months: p.commitment_months || '3',
|
||||
|
|
@ -968,6 +1069,10 @@ function applyWebhookEvent (ev) {
|
|||
function isGiftClick (r, ev) {
|
||||
const clickedUrl = String(ev.url || ev.URL || '')
|
||||
if (!clickedUrl) return false
|
||||
// Wrapper URL match (current behaviour — emails sent after the gift
|
||||
// redirect rollout contain our /g/<token> link, not the raw Giftbit one).
|
||||
if (r.gift_token && clickedUrl.includes(`/g/${r.gift_token}`)) return true
|
||||
// Legacy emails sent before the wrapper shipped still have the raw URL.
|
||||
if (r.gift_url && clickedUrl.startsWith(r.gift_url)) return true
|
||||
return /(?:^|\/\/)(?:[\w-]+\.)?(?:gft\.link|giftbit\.com)/i.test(clickedUrl)
|
||||
}
|
||||
|
|
@ -1593,7 +1698,10 @@ async function handle (req, res, method, path) {
|
|||
lastname: r.lastname || '',
|
||||
email: r.email,
|
||||
description: r.civic_address || '',
|
||||
gift_url: r.gift_url,
|
||||
// Same wrapper logic as the worker so the in-browser fallback view
|
||||
// links to the same URL the email did. Clicking from /view goes
|
||||
// through our redirect, lets us honour expiry/revoke consistently.
|
||||
gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
|
||||
amount: displayAmount,
|
||||
expiry: p.expiry || '',
|
||||
commitment_months: p.commitment_months || '3',
|
||||
|
|
@ -1628,6 +1736,7 @@ async function handle (req, res, method, path) {
|
|||
const headers = [
|
||||
'row', 'firstname', 'lastname', 'email', 'phone', 'language', 'customer_id',
|
||||
'civic_address', 'city', 'postal_code', 'gift_value_cents', 'gift_url', 'giftbit_uuid',
|
||||
'gift_token', 'gift_expires_at', 'gift_revoked', 'gift_redirected_count', 'gift_first_redirected_at',
|
||||
'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at',
|
||||
'gift_link_clicked', 'gift_clicked_at',
|
||||
'mailjet_uuid', 'error',
|
||||
|
|
@ -1642,6 +1751,8 @@ async function handle (req, res, method, path) {
|
|||
lines.push([
|
||||
r.row_index, r.firstname, r.lastname, r.email, r.phone, r.language, r.customer_id,
|
||||
r.civic_address, r.city, r.postal_code, r.gift_value_cents, r.gift_url, r.giftbit_uuid,
|
||||
r.gift_token, r.gift_expires_at, r.gift_revoked ? 'true' : 'false',
|
||||
r.gift_redirected_count || 0, r.gift_first_redirected_at,
|
||||
r.status, r.excluded ? 'true' : 'false', r.sent_at, r.opened_at, r.clicked_at,
|
||||
r.gift_link_clicked ? 'true' : 'false', r.gift_clicked_at,
|
||||
r.mailjet_uuid, r.error,
|
||||
|
|
@ -1715,8 +1826,51 @@ async function handle (req, res, method, path) {
|
|||
return json(res, 404, { error: 'campaigns endpoint not found' })
|
||||
}
|
||||
|
||||
// ── Gift redirect handler ────────────────────────────────────────────────────
|
||||
// Public unauthenticated endpoint hit by recipients' email clicks.
|
||||
// /g/:token → 302 to underlying Giftbit URL, OR branded expired page.
|
||||
async function handleGiftRedirect (req, res, urlPath) {
|
||||
const m = urlPath.match(/^\/g\/([A-Za-z0-9_-]{4,32})$/)
|
||||
const respondExpired = (reason) => {
|
||||
res.writeHead(reason === 'notfound' ? 404 : 410, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
})
|
||||
res.end(giftExpiredPage(reason))
|
||||
}
|
||||
if (!m) return respondExpired('notfound')
|
||||
const token = m[1]
|
||||
const hit = lookupGiftToken(token)
|
||||
if (!hit) return respondExpired('notfound')
|
||||
const r = hit.recipient
|
||||
if (r.gift_revoked) return respondExpired('revoked')
|
||||
if (r.gift_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired')
|
||||
if (!r.gift_url) return respondExpired('notfound')
|
||||
|
||||
// Successful redirect — record analytics, persist async (no await; the
|
||||
// recipient is already on their way to Giftbit's page).
|
||||
r.gift_redirected_count = (r.gift_redirected_count || 0) + 1
|
||||
if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString()
|
||||
try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) }
|
||||
// Broadcast so the live campaign detail page updates the click counter
|
||||
sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r })
|
||||
|
||||
res.writeHead(302, {
|
||||
Location: r.gift_url,
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Robots-Tag': 'noindex, nofollow',
|
||||
})
|
||||
res.end()
|
||||
}
|
||||
|
||||
// Rebuild the token index from disk on module load so a hub restart
|
||||
// doesn't break existing wrapper URLs in already-sent emails.
|
||||
rebuildTokenIndex()
|
||||
|
||||
module.exports = {
|
||||
handle,
|
||||
handleGiftRedirect,
|
||||
// Exposed for testing
|
||||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||||
|
|
|
|||
|
|
@ -120,6 +120,9 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
|
||||
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
|
||||
// Gift redirect wrapper — short public URLs in campaign emails that
|
||||
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
||||
if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path)
|
||||
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
|
||||
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
|
||||
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user