feat(campaigns/reminder): cascade clicks to parent + family banner

A reminder campaign is a deep-copy of its parent's non-clicked
recipients with NEW gift_tokens. Clicks on the reminder were flagging
the CHILD recipient's gift_link_clicked but the parent campaign's
counters never updated — operators had to check two campaigns to see
the cumulative click rate.

Hub:
- New cascadeClickToParent() helper — when a recipient with
  parent_campaign_id is flagged as gift_link_clicked, mirror the flag
  + timestamp onto parent.recipients[parent_row_index] and broadcast
  a recipient-update SSE event so the parent's open page refreshes
  live. Adds a gift_clicked_via_reminder breadcrumb (the child
  campaign id) so the parent UI can show "↩ via la relance XXX".
  Idempotent — already-clicked rows are no-op.
- Three cascade call sites: applyWebhookEvent fast path (CustomID),
  applyWebhookEvent fallback (msgId scan), handleGiftRedirect wrapper.
- handleGiftRedirect also now sets gift_link_clicked=true on first
  successful redirect (Mailjet webhook can lag or drop; the wrapper
  redirect is the most reliable click signal we have).
- GET /campaigns/:id now attaches a "reminders" array with summary
  counters for every reminder child of the campaign.

Ops UI:
- "Cette campagne est une relance" banner on child detail pages with
  a back-link to the parent.
- "N relance(s) envoyée(s)" banner on parent detail pages with
  clickable chips showing each child's gift_clicked/total ratio.
- Recipient table: 🔁 icon next to the gift-click indicator when the
  click came via reminder, plus a "↩ via la relance XXX" line in the
  tooltip so the operator can trace the engagement channel.

One-time backfill applied on prod to mirror clicks that happened
between reminder send and this deploy (1 click cascaded —
cmp-20260522-2d4605 gift_clicked 27 → 28).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-01 15:24:52 -04:00
parent e7b937e2a3
commit 8410464a22
2 changed files with 97 additions and 3 deletions

View File

@ -35,6 +35,35 @@
</div> </div>
<!-- Counters bar --> <!-- Counters bar -->
<!-- Family banner: this campaign is either a parent (has reminders) or
a child reminder (linked to a parent). One badge each direction so
the operator can hop between them quickly without leaving the
single-glance click view. -->
<q-banner v-if="campaign?.reminder_of"
class="bg-blue-1 text-blue-9 q-mb-md" rounded dense>
<template v-slot:avatar><q-icon name="reply" /></template>
Cette campagne est une <strong>relance</strong> de
<a :href="`/ops/#/campaigns/${campaign.reminder_of}`" class="text-primary">
campagne parent
</a>. Les clics ici sont automatiquement remontés au parent.
</q-banner>
<q-banner v-if="campaign?.reminders?.length"
class="bg-deep-purple-1 text-deep-purple-9 q-mb-md" rounded dense>
<template v-slot:avatar><q-icon name="schedule" /></template>
<div>
<strong>{{ campaign.reminders.length }} relance(s)</strong> envoyée(s) à partir de cette campagne
les clics du rappel sont déjà comptabilisés dans
<strong>"Cadeau cliqué"</strong> ci-dessous (avec un 🔁 sur les rangées concernées).
</div>
<div class="q-mt-xs">
<q-chip v-for="rem in campaign.reminders" :key="rem.id" dense outline color="deep-purple-7" clickable
:to="`/campaigns/${rem.id}`" class="q-mr-xs">
{{ rem.name || rem.id }}
<q-badge color="deep-purple-7" class="q-ml-xs">{{ rem.gift_clicked }}/{{ rem.total }}</q-badge>
</q-chip>
</div>
</q-banner>
<div class="row q-col-gutter-sm q-mb-md" v-if="campaign"> <div class="row q-col-gutter-sm q-mb-md" v-if="campaign">
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center"> <q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
<div class="text-h5">{{ campaign.counters?.total || campaign.recipients?.length || 0 }}</div> <div class="text-h5">{{ campaign.counters?.total || campaign.recipients?.length || 0 }}</div>
@ -93,7 +122,13 @@
<q-td :props="props"> <q-td :props="props">
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" /> <q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
<q-icon v-if="props.row.gift_link_clicked" name="redeem" color="deep-purple-7" size="18px" class="q-ml-xs"> <q-icon v-if="props.row.gift_link_clicked" name="redeem" color="deep-purple-7" size="18px" class="q-ml-xs">
<q-tooltip>Cadeau cliqué {{ props.row.gift_clicked_at ? `le ${new Date(props.row.gift_clicked_at).toLocaleString('fr-CA')}` : '' }}</q-tooltip> <q-tooltip>
Cadeau cliqué {{ props.row.gift_clicked_at ? `le ${new Date(props.row.gift_clicked_at).toLocaleString('fr-CA', { timeZone: 'America/Montreal' })}` : '' }}
<span v-if="props.row.gift_clicked_via_reminder"><br> via la relance <strong>{{ props.row.gift_clicked_via_reminder }}</strong></span>
</q-tooltip>
</q-icon>
<q-icon v-if="props.row.gift_clicked_via_reminder" name="autorenew" color="orange-9" size="14px" class="q-ml-xs">
<q-tooltip>Ce clic a été remonté depuis la campagne de relance, pas le 1er courriel</q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
</template> </template>

View File

@ -1262,6 +1262,31 @@ function mailjetEventToStatus (event) {
} }
} }
// Reminder campaigns are deep-copies of their parent recipients with new
// gift_tokens. A click on a reminder updates the CHILD's gift_link_clicked
// but the parent campaign would still show "non-cliqué" for that recipient.
// This helper mirrors the click flag back to the parent so the parent's
// detail page and counters reflect cumulative engagement across the
// reminder chain. Idempotent — if the parent is already flagged, no-op.
function cascadeClickToParent (childCampaign, childRow) {
if (!childRow.parent_campaign_id || childRow.parent_row_index == null) return false
let parent
try { parent = loadCampaign(childRow.parent_campaign_id) }
catch { return false }
if (!parent?.recipients?.[childRow.parent_row_index]) return false
const parentR = parent.recipients[childRow.parent_row_index]
if (parentR.gift_link_clicked) return false // already counted
parentR.gift_link_clicked = true
parentR.gift_clicked_at = childRow.gift_clicked_at || new Date().toISOString()
// Breadcrumb so the parent UI can show "cliqué via la relance XXX"
parentR.gift_clicked_via_reminder = childCampaign.id
saveCampaign(parent)
sse.broadcast(`campaign:${parent.id}`, 'recipient-update',
{ i: childRow.parent_row_index, recipient: parentR })
log(`cascade: ${childCampaign.id} row ${childRow.parent_row_index} click → parent ${parent.id}`)
return true
}
function applyWebhookEvent (ev) { function applyWebhookEvent (ev) {
const newStatus = mailjetEventToStatus(ev.event) const newStatus = mailjetEventToStatus(ev.event)
if (!newStatus) return false if (!newStatus) return false
@ -1310,6 +1335,8 @@ function applyWebhookEvent (ev) {
} }
saveCampaign(c) saveCampaign(c)
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i: idx, recipient: r }) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i: idx, recipient: r })
// Reminder click → mirror to parent so cumulative engagement is visible.
if (r.gift_link_clicked) cascadeClickToParent(c, r)
return true return true
} }
} }
@ -1336,6 +1363,7 @@ function applyWebhookEvent (ev) {
} }
saveCampaign(c) saveCampaign(c)
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r }) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
if (r.gift_link_clicked) cascadeClickToParent(c, r)
return true return true
} }
} }
@ -2090,12 +2118,32 @@ async function handle (req, res, method, path) {
return res.end(csv) return res.end(csv)
} }
// GET /campaigns/:id — full detail // GET /campaigns/:id — full detail (+ reminder children if any)
const detailMatch = path.match(/^\/campaigns\/([^/]+)$/) const detailMatch = path.match(/^\/campaigns\/([^/]+)$/)
if (detailMatch && method === 'GET') { if (detailMatch && method === 'GET') {
const c = loadCampaign(detailMatch[1]) const c = loadCampaign(detailMatch[1])
if (!c) return json(res, 404, { error: 'not found' }) if (!c) return json(res, 404, { error: 'not found' })
return json(res, 200, c) // Attach summaries of reminder children pointing at this campaign so
// the UI can render a "famille de campagnes" banner. Children fully
// load their recipients but we only return aggregate counters here
// to keep the response light.
const reminders = []
for (const meta of listCampaigns()) {
if (meta.id === c.id) continue
const child = loadCampaign(meta.id)
if (child?.reminder_of === c.id || child?.parent_campaign_id === c.id) {
reminders.push({
id: child.id,
name: child.name,
status: child.status,
created_at: child.created_at,
counters: child.counters,
total: (child.recipients || []).length,
gift_clicked: child.counters?.gift_clicked || 0,
})
}
}
return json(res, 200, { ...c, reminders })
} }
// PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email) // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email)
@ -2280,9 +2328,20 @@ async function handleGiftRedirect (req, res, urlPath) {
// recipient is already on their way to Giftbit's page). // recipient is already on their way to Giftbit's page).
r.gift_redirected_count = (r.gift_redirected_count || 0) + 1 r.gift_redirected_count = (r.gift_redirected_count || 0) + 1
if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString() if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString()
// The wrapper redirect is the most reliable click signal we have (it
// ALWAYS fires on a successful gift click — Mailjet's webhook can lag
// or get dropped). Treat the first redirect as a confirmed gift click
// on the recipient row, even if Mailjet's webhook hasn't arrived yet.
if (!r.gift_link_clicked) {
r.gift_link_clicked = true
r.gift_clicked_at = r.gift_first_redirected_at
}
try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) } try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) }
// Broadcast so the live campaign detail page updates the click counter // Broadcast so the live campaign detail page updates the click counter
sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r }) sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r })
// Reminder click → cascade to parent so the originating campaign's
// counters reflect the cumulative engagement at a single glance.
cascadeClickToParent(hit.campaign, r)
res.writeHead(302, { res.writeHead(302, {
Location: r.gift_url, Location: r.gift_url,