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:
parent
e7b937e2a3
commit
8410464a22
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user