diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index b7c2d08..564e3cf 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -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 click triggers a save. diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue index 8a8e27a..e6fc7c4 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue @@ -66,11 +66,43 @@ - - 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. - - + +
+
+ Expiration interne du lien (avant réassignation possible) + + Délai après lequel notre lien intermédiaire /g/<token> cesse de rediriger. Le lien Giftbit sous-jacent reste valide chez Giftbit — utile pour réassigner un cadeau non utilisé à un autre client. + +
+
+ + + + = {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }} + +
+
+ +
+ + +
{{ gifts.length }}
+
Total
+
+
+ + +
{{ counts.active }}
+
Actifs
+
+
+ + +
{{ counts.redeemed }}
+
Cliqués
+
+
+ + +
{{ counts.expired }}
+
Expirés
+
+
+ + +
{{ counts.revoked }}
+
Révoqués
+
+
+ + +
{{ counts.reassignable }}
+
Réassignables
+
+
+
+ + + + +
+ + + + + + +
{{ filtered.length }} résultat(s)
+
+
+
+ + + + + + + + + + + + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index 3bb20a4..b261b8d 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -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 }, ], }, diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 7308d2d..9cdb499 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -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/ 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() })