feat(campaigns): gifts inventory page + expiry presets

Wizard: gift_expiry_days now lives behind a preset toggle
(15/30/60/90/180 + Custom) instead of a naked number input. Operator
clicks a chip; the value flows back into the existing campaign param.

Inventory page (/campaigns/gifts):
- Cross-campaign view of every wrapper token with status taxonomy
  (active / redeemed / expired / revoked / pending). Each card on
  the counters strip is a click-to-filter shortcut.
- "Réassignables" highlighted in amber when > 0 — these are gifts
  whose wrapper expired or was revoked but the Giftbit URL is still
  unredeemed, ready for a fresh recipient.
- Search across name/email/url/token; per-status and per-campaign
  filter dropdowns.
- One-click copy on the Giftbit URL with a tailored toast that walks
  the operator through the reassignment workflow (paste into manual-
  add dialog of a new campaign).
- Revoke action with confirmation; explicit about what survives
  (the Giftbit URL stays valid on their side) vs what changes (our
  wrapper stops redirecting).

Backend:
- GET /campaigns/gifts flattens every recipient with a gift across
  every campaign — single-shot, no pagination yet (we're under 10k
  gifts total).
- POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true
  and broadcasts the recipient-update SSE event.

🤖 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-05-22 10:21:05 -04:00
parent c0ca5feb6f
commit d529019106
6 changed files with 443 additions and 7 deletions

View File

@ -77,6 +77,23 @@ export function deleteCampaign (id) {
})
}
// Inventory of every wrapper token across all campaigns, with status
// (active / expired / revoked / redeemed / pending). Used by the
// gifts inventory page to surface reassignable Giftbit shortlinks.
export function listGifts () {
return hubFetch('/campaigns/gifts').then(r => r.gifts || [])
}
// Kill switch — manually expire a single recipient's wrapper token so
// the underlying Giftbit URL becomes reassignable before the natural
// expiry date.
export function revokeGift (campaignId, rowIndex) {
return hubFetch(
`/campaigns/${encodeURIComponent(campaignId)}/recipients/${rowIndex}/revoke`,
{ method: 'POST' },
)
}
// Build the URL the browser hits to download the per-recipient CSV report
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
// proper Content-Disposition so an <a download> click triggers a save.

View File

@ -66,11 +66,43 @@
<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.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/&lt;token&gt; 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" />
<!-- Internal-only expiry on our /g/<token> wrapper. Preset
buttons cover the typical cases; "Custom" reveals a
number input for any value between 1-365 days. -->
<div class="col-12 col-md-6">
<div class="text-caption text-grey-7 q-mb-xs">
Expiration interne du lien (avant réassignation possible)
<q-icon name="info" size="14px" class="q-ml-xs">
<q-tooltip max-width="320px">Délai après lequel notre lien intermédiaire <code>/g/&lt;token&gt;</code> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit utile pour réassigner un cadeau non utilisé à un autre client.</q-tooltip>
</q-icon>
</div>
<div class="row items-center q-gutter-xs">
<q-btn-toggle
:model-value="expiryPreset"
@update:model-value="setExpiryPreset"
:options="[
{ label: '15j', value: 15 },
{ label: '30j', value: 30 },
{ label: '60j', value: 60 },
{ label: '90j', value: 90 },
{ label: '180j', value: 180 },
{ label: 'Custom', value: 'custom' },
]"
unelevated dense color="grey-3" text-color="grey-9"
toggle-color="primary" toggle-text-color="white"
no-caps
/>
<q-input v-if="expiryPreset === 'custom'"
v-model.number="params.gift_expiry_days"
type="number" min="1" max="365"
dense outlined style="width: 120px"
suffix="jours" />
<span v-else class="text-caption text-grey-6 q-ml-sm">
= {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }}
</span>
</div>
</div>
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
<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
@ -567,6 +599,23 @@ const multiOptions = [
{ label: 'Ignorer les couples', value: 'skip' },
]
// expiryPreset reflects the current params.gift_expiry_days as one of the
// preset buttons, or 'custom' when the value is non-standard. Two-way:
// clicking a preset writes back to params.gift_expiry_days.
const EXPIRY_PRESETS = [15, 30, 60, 90, 180]
const expiryPreset = computed(() => {
const v = Number(params.value.gift_expiry_days)
return EXPIRY_PRESETS.includes(v) ? v : 'custom'
})
function setExpiryPreset (val) {
if (val === 'custom') {
// Don't reset let the operator continue typing in the custom input.
// If they were on a preset and switched to custom, keep that value as starting point.
return
}
params.value.gift_expiry_days = val
}
const parsing = ref(false)
const sending = ref(false)
const recipients = ref([])

View File

@ -3,6 +3,9 @@
<div class="row items-center q-mb-md">
<div class="text-h5">Campagnes</div>
<q-space />
<q-btn flat color="primary" icon="redeem" label="Liens cadeaux" :to="'/campaigns/gifts'" class="q-mr-sm">
<q-tooltip>Inventaire de tous les liens cadeaux (actifs, expirés, réassignables)</q-tooltip>
</q-btn>
<q-btn flat color="primary" icon="palette" label="Éditer le template" :to="'/campaigns/templates/gift-email-fr'" class="q-mr-sm" />
<q-btn unelevated color="primary" icon="add" label="Nouvelle campagne" :to="'/campaigns/new'" />
</div>

View File

@ -0,0 +1,294 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm">
<q-tooltip>Retour aux campagnes</q-tooltip>
</q-btn>
<div class="text-h5">Inventaire des liens cadeaux</div>
<q-space />
<q-btn flat dense icon="refresh" label="Rafraîchir" @click="load" :loading="loading" />
</div>
<!-- Status counters strip a glance at the inventory health -->
<div class="row q-col-gutter-sm q-mb-md">
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = null" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5">{{ gifts.length }}</div>
<div class="text-caption text-grey-7">Total</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'active'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-positive">{{ counts.active }}</div>
<div class="text-caption text-grey-7">Actifs</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'redeemed'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-deep-purple-7">{{ counts.redeemed }}</div>
<div class="text-caption text-grey-7">Cliqués</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'expired'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-orange-9">{{ counts.expired }}</div>
<div class="text-caption text-grey-7">Expirés</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'revoked'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-grey-7">{{ counts.revoked }}</div>
<div class="text-caption text-grey-7">Révoqués</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'reassignable'" style="cursor:pointer"
:class="{ 'bg-amber-1': counts.reassignable > 0 }">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-amber-9">{{ counts.reassignable }}</div>
<div class="text-caption text-grey-7">Réassignables</div>
</q-card-section>
</q-card>
</div>
<!-- Filter bar -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-py-sm">
<div class="row items-center q-col-gutter-sm">
<q-input v-model="search" placeholder="Rechercher (nom, courriel, lien…)" outlined dense
class="col-12 col-md-4" clearable>
<template v-slot:prepend><q-icon name="search" /></template>
</q-input>
<q-select v-model="filterStatus" :options="statusOptions" emit-value map-options
label="Statut" outlined dense clearable class="col-6 col-md-3" />
<q-select v-model="filterCampaign" :options="campaignOptions" emit-value map-options
label="Campagne" outlined dense clearable class="col-6 col-md-3" />
<q-space />
<div class="text-caption text-grey-7 q-mr-sm">{{ filtered.length }} résultat(s)</div>
</div>
</q-card-section>
</q-card>
<q-table
:rows="filtered" :columns="columns" row-key="gift_token"
flat bordered dense :pagination="{ rowsPerPage: 50, sortBy: 'expires', descending: false }"
:rows-per-page-options="[25, 50, 100, 0]"
:loading="loading"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white"
:icon="statusIcon(props.row.status)" :label="statusLabel(props.row.status)" />
</q-td>
</template>
<template v-slot:body-cell-recipient="props">
<q-td :props="props">
<div>{{ props.row.firstname }} {{ props.row.lastname }}</div>
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
</q-td>
</template>
<template v-slot:body-cell-campaign="props">
<q-td :props="props">
<a :href="`/ops/#/campaigns/${props.row.campaign_id}`" class="text-primary" style="text-decoration:none">
{{ props.row.campaign_name || props.row.campaign_id }}
<q-icon name="open_in_new" size="14px" />
</a>
</q-td>
</template>
<template v-slot:body-cell-gift_url="props">
<q-td :props="props">
<span style="font-family:monospace; font-size:0.78rem">{{ shorten(props.row.gift_url) }}</span>
<q-btn flat dense round size="xs" icon="content_copy" @click="copy(props.row.gift_url)" class="q-ml-xs">
<q-tooltip>Copier l'URL Giftbit (à coller dans une nouvelle campagne pour réassigner)</q-tooltip>
</q-btn>
</q-td>
</template>
<template v-slot:body-cell-expires="props">
<q-td :props="props">
<span v-if="props.row.gift_expires_at" :class="{ 'text-orange-9': props.row.status === 'expired' }">
{{ formatDate(props.row.gift_expires_at) }}
</span>
<span v-else class="text-grey-5"></span>
</q-td>
</template>
<template v-slot:body-cell-clicks="props">
<q-td :props="props" class="text-center">
<span v-if="props.row.gift_redirected_count">
{{ props.row.gift_redirected_count }}
<q-tooltip v-if="props.row.gift_first_redirected_at">
1er clic: {{ formatDate(props.row.gift_first_redirected_at) }}
</q-tooltip>
</span>
<span v-else class="text-grey-5">0</span>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn v-if="props.row.status === 'active'" flat dense color="negative" icon="block" size="sm"
@click="confirmRevoke(props.row)">
<q-tooltip>Révoquer ce lien (libère le cadeau Giftbit pour réassignation)</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'expired' || props.row.status === 'revoked'"
flat dense color="amber-9" icon="autorenew" size="sm"
@click="copyForReassign(props.row.gift_url)">
<q-tooltip>Copier l'URL pour réassigner à un autre destinataire</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listGifts, revokeGift } from 'src/api/campaigns'
const $q = useQuasar()
const gifts = ref([])
const loading = ref(true)
const search = ref('')
const filterStatus = ref(null)
const filterCampaign = ref(null)
const columns = [
{ name: 'status', label: 'Statut', field: 'status', align: 'left', sortable: true },
{ name: 'recipient', label: 'Destinataire', field: r => `${r.firstname} ${r.lastname}`.trim(), align: 'left', sortable: true },
{ name: 'campaign', label: 'Campagne', field: 'campaign_name', align: 'left', sortable: true },
{ name: 'gift_url', label: 'Lien Giftbit', field: 'gift_url', align: 'left' },
{ name: 'expires', label: 'Expire le', field: 'gift_expires_at', align: 'left', sortable: true },
{ name: 'clicks', label: 'Clics', field: 'gift_redirected_count', align: 'center', sortable: true },
{ name: 'actions', label: '', field: '', align: 'right' },
]
const statusOptions = [
{ label: '🟢 Actif', value: 'active' },
{ label: '🟣 Cliqué', value: 'redeemed' },
{ label: '🟠 Expiré', value: 'expired' },
{ label: '⚫ Révoqué', value: 'revoked' },
{ label: '🟡 Réassignable (expiré + révoqué)', value: 'reassignable' },
{ label: '⏳ Non envoyé', value: 'pending' },
]
const campaignOptions = computed(() => {
const seen = new Map()
for (const g of gifts.value) {
if (!seen.has(g.campaign_id)) seen.set(g.campaign_id, g.campaign_name || g.campaign_id)
}
return [...seen.entries()].map(([value, label]) => ({ value, label }))
})
const counts = computed(() => {
const c = { active: 0, redeemed: 0, expired: 0, revoked: 0, pending: 0, reassignable: 0 }
for (const g of gifts.value) {
c[g.status] = (c[g.status] || 0) + 1
// Reassignable = expired or revoked AND not yet redeemed (i.e. the
// underlying Giftbit gift is presumably still unredeemed).
if ((g.status === 'expired' || g.status === 'revoked') && !g.gift_link_clicked) c.reassignable++
}
return c
})
const filtered = computed(() => {
let rows = gifts.value
if (filterStatus.value === 'reassignable') {
rows = rows.filter(g => (g.status === 'expired' || g.status === 'revoked') && !g.gift_link_clicked)
} else if (filterStatus.value) {
rows = rows.filter(g => g.status === filterStatus.value)
}
if (filterCampaign.value) rows = rows.filter(g => g.campaign_id === filterCampaign.value)
if (search.value) {
const q = search.value.toLowerCase()
rows = rows.filter(g =>
(g.firstname || '').toLowerCase().includes(q) ||
(g.lastname || '').toLowerCase().includes(q) ||
(g.email || '').toLowerCase().includes(q) ||
(g.gift_url || '').toLowerCase().includes(q) ||
(g.gift_token || '').toLowerCase().includes(q),
)
}
return rows
})
function statusColor (s) {
return {
active: 'positive',
redeemed: 'deep-purple-7',
expired: 'orange-9',
revoked: 'grey-7',
pending: 'grey-5',
}[s] || 'grey-5'
}
function statusIcon (s) {
return {
active: 'check_circle',
redeemed: 'redeem',
expired: 'schedule',
revoked: 'block',
pending: 'hourglass_empty',
}[s] || ''
}
function statusLabel (s) {
return {
active: 'Actif',
redeemed: 'Cliqué',
expired: 'Expiré',
revoked: 'Révoqué',
pending: 'En attente',
}[s] || s
}
function shorten (u) {
if (!u) return ''
return u.replace(/^https?:\/\//, '').slice(0, 36) + (u.length > 40 ? '…' : '')
}
function formatDate (iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' })
}
async function copy (text, customMsg) {
try {
await navigator.clipboard.writeText(text)
$q.notify({ type: 'positive', message: customMsg || 'Copié dans le presse-papier', timeout: 2500 })
} catch {
$q.notify({ type: 'negative', message: 'Copie impossible — sélectionne et copie manuellement' })
}
}
function copyForReassign (url) {
// Separate helper so the toast message can live in JS where quote-escaping
// is trivial (the Vue attribute parser doesn't accept embedded quotes).
copy(url, `Lien Giftbit copié — colle-le dans "Ajouter manuellement" d'une nouvelle campagne pour réassigner ce cadeau`)
}
function confirmRevoke (row) {
$q.dialog({
title: 'Révoquer ce lien ?',
message: `Le destinataire <strong>${row.firstname} ${row.lastname}</strong> ne pourra plus utiliser son lien après cette action. L'URL Giftbit (<code>${shorten(row.gift_url)}</code>) restera valide chez Giftbit et pourra être réassignée à un autre destinataire via une nouvelle campagne.`,
html: true,
cancel: { label: 'Annuler', flat: true },
ok: { label: 'Révoquer', color: 'negative', unelevated: true },
persistent: true,
}).onOk(async () => {
try {
await revokeGift(row.campaign_id, row.row_index)
row.status = 'revoked'
row.gift_revoked = true
$q.notify({ type: 'positive', message: 'Lien révoqué' })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
}
})
}
async function load () {
loading.value = true
try {
gifts.value = await listGifts()
} catch (e) {
$q.notify({ type: 'negative', message: 'Chargement échoué: ' + e.message })
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@ -41,9 +41,11 @@ const routes = [
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
{ path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') },
// Template editor route must be ABOVE /campaigns/:id otherwise the
// ':id' wildcard captures 'templates/...' and shows the detail page.
// Template editor + gifts inventory routes must be ABOVE /campaigns/:id
// otherwise the ':id' wildcard captures the literal paths and shows
// the detail page instead.
{ path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true },
{ path: 'campaigns/gifts', component: () => import('src/modules/campaigns/pages/GiftsInventoryPage.vue') },
{ path: 'campaigns/:id', component: () => import('src/modules/campaigns/pages/CampaignDetailPage.vue'), props: true },
],
},

View File

@ -1215,6 +1215,77 @@ async function handle (req, res, method, path) {
return json(res, 200, saved)
}
// GET /campaigns/gifts — flattened inventory of every gift across every
// campaign so operators can see which Giftbit shortlinks are still
// unredeemed and reassignable. Cross-campaign view; lighter than the
// full campaign JSONs (no template params, no recipient PII beyond name+email).
// ORDER MATTERS: this route must come BEFORE the /campaigns/:id wildcard.
if (path === '/campaigns/gifts' && method === 'GET') {
const now = Date.now()
const rows = []
for (const meta of listCampaigns()) {
const c = loadCampaign(meta.id)
if (!c?.recipients) continue
for (let i = 0; i < c.recipients.length; i++) {
const r = c.recipients[i]
if (!r.gift_url) continue
const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now
// Status taxonomy for the inventory UI:
// redeemed — recipient already clicked through to Giftbit (we
// don't know if they actually redeemed; would need
// the Giftbit /gifts/{uuid} poll, task #25)
// revoked — manually killed by an operator
// expired — our own gift_expires_at has passed; reassignable
// active — still live, may be clicked any moment
// pending — not yet sent (no gift_token generated yet)
let status = 'pending'
if (r.gift_token) {
status = 'active'
if (r.gift_revoked) status = 'revoked'
else if (expired) status = 'expired'
if (r.gift_link_clicked) status = 'redeemed'
}
rows.push({
campaign_id: c.id,
campaign_name: c.name,
row_index: i,
firstname: r.firstname, lastname: r.lastname, email: r.email,
gift_token: r.gift_token || null,
gift_url: r.gift_url,
giftbit_uuid: r.giftbit_uuid,
gift_expires_at: r.gift_expires_at || null,
gift_revoked: !!r.gift_revoked,
gift_redirected_count: r.gift_redirected_count || 0,
gift_first_redirected_at: r.gift_first_redirected_at || null,
gift_link_clicked: !!r.gift_link_clicked,
gift_clicked_at: r.gift_clicked_at || null,
status,
})
}
}
return json(res, 200, { gifts: rows })
}
// POST /campaigns/:id/recipients/:row/revoke — kill switch for a single
// wrapper token. Sets gift_revoked=true so /g/<token> returns the
// "désactivé" page. Used when an operator wants to free a Giftbit URL
// for reassignment before its natural expiry.
const revokeMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/revoke$/)
if (revokeMatch && method === 'POST') {
const c = loadCampaign(revokeMatch[1])
if (!c) return json(res, 404, { error: 'not found' })
const i = parseInt(revokeMatch[2], 10)
const r = (c.recipients || [])[i]
if (!r) return json(res, 404, { error: 'recipient not found' })
if (!r.gift_token) return json(res, 400, { error: 'no token to revoke' })
r.gift_revoked = true
r.gift_revoked_at = new Date().toISOString()
saveCampaign(c)
log(`gift token ${r.gift_token} revoked (campaign ${c.id} row ${i})`)
sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
return json(res, 200, { revoked: true, gift_token: r.gift_token })
}
// GET /campaigns — list summaries
if (path === '/campaigns' && method === 'GET') {
return json(res, 200, { campaigns: listCampaigns() })