From 89057d016683d18d0f318a66a6d91b1dc19e89d3 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Mon, 1 Jun 2026 14:45:14 -0400 Subject: [PATCH] feat(campaigns/expiry): date picker for explicit cutoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator can now choose an exact date for the wrapper expiry (e.g. "valid until June 15") instead of computing days from today. Useful when communicating a specific deadline to recipients. Worker resolution order: 1. params.gift_expires_at (full ISO datetime, set by the date picker) — all recipients of this campaign get THIS exact date, regardless of when the worker fires the send. 2. Fallback: now() + gift_expiry_days (relative deadline, shifts forward by queue lag). UI in both wizard (new campaign) and edit-params dialog (draft): - Date picker at the top with cursor-pointer event icon + clear (x) - Preset toggle (15/30/60/90/180/Custom days) below — auto-disabled when explicit date is set so the operator picks ONE mode - Indicator "≈ N jours à partir d'aujourd'hui" when explicit date is active so the operator sees both representations UI carries the picker value as YYYY-MM-DD (gift_expires_at_display); launchSend / saveEditParams translate to ISO YYYY-MM-DDT23:59:59Z before PATCH/POST. Anchoring at end-of-day local means "until June 15" stays valid through all of June 15, not just the start. dateAfterToday validator blocks past dates in the picker. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- .../campaigns/pages/CampaignDetailPage.vue | 47 ++++++++++ .../campaigns/pages/CampaignNewPage.vue | 85 +++++++++++++++++-- services/targo-hub/lib/campaigns.js | 14 ++- 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index b66e81a..d196ccf 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -165,6 +165,31 @@ + + + +
Date.now() +} + // Template lists for the FR/EN dropdowns in the dialog. Refreshed lazily // when the dropdown opens so newly-created templates show up without a // page reload. @@ -409,6 +443,10 @@ async function retryFailedRow (row) { // dropdowns are populated when the dialog mounts. function openEditParams () { const p = campaign.value?.params || {} + // Convert the stored full ISO datetime back to YYYY-MM-DD for the picker. + const expiresAtDisplay = p.gift_expires_at + ? new Date(p.gift_expires_at).toISOString().slice(0, 10) + : '' editParams.value = { name: campaign.value?.name || '', subject: p.subject || '', @@ -418,6 +456,7 @@ function openEditParams () { expiry: p.expiry || '', template_fr: p.template_fr || 'gift-email-fr', template_en: p.template_en || 'gift-email-en', + gift_expires_at_display: expiresAtDisplay, } editParamsOpen.value = true loadTemplateLists() @@ -429,6 +468,13 @@ function openEditParams () { async function saveEditParams () { savingParams.value = true try { + // Translate the YYYY-MM-DD picker value into the full ISO datetime the + // hub stores. Anchor at end-of-day local so "until June 15" stays valid + // through all of June 15. Empty → fall back to gift_expiry_days on the + // hub side. PATCH merges into params, so explicitly send null to clear. + const giftExpiresAt = editParams.value.gift_expires_at_display + ? new Date(editParams.value.gift_expires_at_display + 'T23:59:59').toISOString() + : null await updateCampaign(id, { name: editParams.value.name, params: { @@ -439,6 +485,7 @@ async function saveEditParams () { expiry: editParams.value.expiry, template_fr: editParams.value.template_fr, template_en: editParams.value.template_en, + gift_expires_at: giftExpiresAt, }, }) await load() diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue index 71710e0..63e5d78 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue @@ -73,12 +73,38 @@
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. + Date après laquelle 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é. Tu peux choisir une date précise (tous les destinataires auront cette date affichée) ou un nombre de jours après l'envoi (relatif, glisse si l'envoi est différé).
+ + + +
- - + = {{ params.gift_expiry_days }} jour{{ params.gift_expiry_days > 1 ? 's' : '' }} + + ≈ {{ daysUntilExplicitDate }} jour{{ daysUntilExplicitDate > 1 ? 's' : '' }} à partir d'aujourd'hui +
@@ -594,12 +623,47 @@ const params = ref({ // freeing the underlying Giftbit gift_url for reassignment to another // customer in a new campaign. gift_expiry_days: 90, + // Optional explicit cutoff (ISO date). When set, overrides the days + // calculation — every recipient gets THIS exact date in their email, + // regardless of when the worker actually fires the send. Useful when + // you want "valid until June 15" rather than "30 days from now". + // The hub stores .gift_expires_at on each row at send time from this value. + gift_expires_at_display: '', // YYYY-MM-DD picked in the date picker // 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', template_en: 'gift-email-en', }) +// Date picker constraints + computed helpers for the expiry picker. +function dateAfterToday (date) { + // q-date passes ISO string YYYY/MM/DD — must be > today + const d = new Date(date.replace(/\//g, '-')) + d.setHours(23, 59, 59, 999) + return d.getTime() > Date.now() +} +const daysUntilExplicitDate = computed(() => { + if (!params.value.gift_expires_at_display) return 0 + const target = new Date(params.value.gift_expires_at_display + 'T23:59:59') + return Math.max(1, Math.ceil((target - new Date()) / 86400000)) +}) +function onExpiryDatePicked (dateStr) { + // dateStr from q-date with mask "YYYY-MM-DD" + params.value.gift_expires_at_display = dateStr || '' + // Sync gift_expiry_days for legacy callers, and so the recap shows ~N days + if (dateStr) { + const target = new Date(dateStr + 'T23:59:59') + params.value.gift_expiry_days = Math.max(1, Math.ceil((target - new Date()) / 86400000)) + } +} +function clearExpiryDate () { + params.value.gift_expires_at_display = '' + // Restore default 90 days if user cleared + if (!EXPIRY_PRESETS.includes(params.value.gift_expiry_days)) { + params.value.gift_expiry_days = 90 + } +} + // Template dropdowns. Lists EVERY editable gift-email-* template in both // dropdowns, but sorts so the language-matching ones (-fr / -en suffix) // appear at the top — operator instinct will pick those first while still @@ -1012,9 +1076,20 @@ function submitManualRow () { async function launchSend () { sending.value = true try { + // The hub expects params.gift_expires_at as a full ISO datetime when the + // user picked an explicit cutoff. The UI carries the YYYY-MM-DD shape + // in gift_expires_at_display — translate here, anchored at end-of-day + // local time so "until June 15" really means through all of June 15. + const apiParams = { ...params.value } + if (apiParams.gift_expires_at_display) { + apiParams.gift_expires_at = new Date(apiParams.gift_expires_at_display + 'T23:59:59').toISOString() + } else { + delete apiParams.gift_expires_at + } + delete apiParams.gift_expires_at_display // UI-only field, drop before save const saved = await createCampaign({ name: params.value.name, - params: { ...params.value }, + params: apiParams, recipients: recipients.value, }) await sendCampaign(saved.id) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 63902df..503c7de 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -1106,8 +1106,18 @@ async function sendCampaignAsync (id) { // 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() + // Expiry resolution order: + // 1. params.gift_expires_at (explicit ISO date set in the wizard + // via the date picker) — all recipients of THIS campaign get + // the same hard cutoff, regardless of when the worker fires. + // 2. Fallback: now() + gift_expiry_days (relative deadline, + // shifts forward by the queue lag). + if (p.gift_expires_at) { + r.gift_expires_at = new Date(p.gift_expires_at).toISOString() + } else { + 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 })