From 8d9e190c2115c120395b188f4ede458b7f25bca7 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 21 May 2026 20:31:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(ops/campaigns):=20explicit=20contact?= =?UTF-8?q?=E2=86=94shortlink=20pairing=20review=20before=20approve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the new-campaign wizard previously dropped unpaired contacts silently (Math.min(contacts, gifts) iteration) — if you uploaded 5 contacts and 3 gift links, you got 3 recipients in the table with no visible signal that 2 contacts were left out. Step 1 only showed "contacts skipped: N" in a small banner, easy to miss. Surface the imbalance explicitly so the user can decide before sending: Backend (POST /campaigns/parse): - Return unpaired_contacts[] and unused_gifts[] arrays (with row_index for source-CSV cross-reference), in addition to the existing recipients[]. Old leftover_gifts / leftover_contacts counters kept for backward compat. UI (CampaignNewPage Step 2): - New columns in the recipients table: • # (row index from the source CSVs) • Lien-cadeau (truncated shortlink, clickable to verify) These let the user eyeball the contact↔link pairing line by line. - New counter strip: Paires / À envoyer / Client lié / Sans client / Sans lien / Liens surplus - "Sans lien" and "Liens surplus" counters appear only when relevant. - Explicit warning banner explaining what unpaired/unused means (acquire more links and re-upload, or proceed knowing N won't get). - Expansion panel listing each unpaired contact with their row_index + details, so the user can verify which specific contacts will be excluded before approving. - Expansion panel listing each unused gift URL (extra capacity). - "Approuver" button now shows the exact send count: "Approuver — N à envoyer". Disabled when 0. Step 3 recap also reflects sendableCount. Co-Authored-By: Claude Opus 4.7 --- .../campaigns/pages/CampaignNewPage.vue | 155 ++++++++++++++---- services/targo-hub/lib/campaigns.js | 32 +++- 2 files changed, 151 insertions(+), 36 deletions(-) diff --git a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue index 22e241d..df61092 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue @@ -73,48 +73,86 @@ -
- - -
{{ recipients.length }}
-
Total destinataires
+ +
+ + +
{{ recipients.length }}
+
Paires contact ↔ lien
- - -
{{ matchedCount }}
-
Client ERPNext lié
+ + +
{{ sendableCount }}
+
À envoyer
- - -
{{ unmatchedCount }}
-
Non liés (review)
+ + +
{{ matchedCount }}
+
Client lié
- - -
{{ excludedCount }}
-
Exclus
+ + +
{{ unmatchedCount }}
+
Sans client
+
+
+ + +
{{ unpairedContacts.length }}
+
Sans lien
+
+
+ + +
{{ unusedGifts.length }}
+
Liens en surplus
- + + - Désalignement détecté: {{ parseInfo.leftover_gifts }} carte(s) sans contact, - {{ parseInfo.leftover_contacts }} contact(s) sans carte. Vérifier l'ordre des CSV. +
+ {{ unpairedContacts.length }} contact(s) sans lien-cadeau — + ils n'apparaissent PAS dans la liste d'envoi ci-dessous et ne recevront rien. + Pour les inclure, acquérir {{ unpairedContacts.length }} liens supplémentaires + chez Giftbit et re-uploader le fichier. +
+
+ {{ unusedGifts.length }} lien(s) Giftbit non utilisés — + il y a plus de cartes-cadeaux que de contacts. Le surplus sera perdu si + la campagne est envoyée tel quel. +
+ +
+ Association contact ↔ lien-cadeau + — vérifier avant d'approuver +
+ + @@ -129,10 +167,40 @@ + + + + + + + + + + + + #{{ g.row_index }} + + {{ g.gift_url }} + + + + + - + @@ -143,7 +211,7 @@
Récapitulatif
Nom{{ params.name }} - Destinataires actifs{{ recipients.length - excludedCount }} (sur {{ recipients.length }} totaux) + Destinataires actifs{{ sendableCount }} (sur {{ recipients.length }} paires ; {{ excludedCount }} exclus, {{ unpairedContacts.length }} sans lien) Montant affiché{{ params.amount }} Engagement{{ params.commitment_months }} mois Sujet{{ params.subject }} @@ -202,28 +270,50 @@ const multiOptions = [ const parsing = ref(false) const sending = ref(false) const recipients = ref([]) -const parseInfo = ref({ leftover_gifts: 0, leftover_contacts: 0 }) +const unpairedContacts = ref([]) +const unusedGifts = ref([]) +// row_index (#1, #2, ...) is the source-CSV position — invaluable for the +// user to cross-reference what they see here against the file they uploaded. +// gift_url is rendered as a clickable short label so contact↔link pairing +// can be eyeballed at a glance and the user can click through to verify +// the shortlink works. const recipientColumns = [ + { name: 'row_index', label: '#', field: 'row_index', align: 'left' }, { name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' }, { name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' }, { name: 'email', label: 'Email', field: 'email', align: 'left' }, { name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' }, - { name: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' }, + { name: 'gift_url', label: 'Lien-cadeau', field: 'gift_url', align: 'left' }, { name: 'match', label: 'Match client', field: 'match_method', align: 'left' }, { name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' }, { name: 'actions', label: '', field: '', align: 'right' }, ] +const unpairedColumns = [ + { name: 'row_index', label: '#', field: 'row_index', align: 'left' }, + { name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' }, + { name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' }, + { name: 'email', label: 'Email', field: 'email', align: 'left' }, + { name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' }, + { name: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' }, +] + const matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length) const unmatchedCount = computed(() => recipients.value.filter(r => !r.customer_id).length) const excludedCount = computed(() => recipients.value.filter(r => r.excluded).length) +// Net number of emails that will actually be fired off (paired AND not excluded) +const sendableCount = computed(() => recipients.value.filter(r => !r.excluded && r.gift_url).length) const estimatedMinutes = computed(() => { - const n = recipients.value.length - excludedCount.value const per = (params.value.throttle_ms || 600) / 1000 - return Math.max(1, Math.round((n * per) / 60)) + return Math.max(1, Math.round((sendableCount.value * per) / 60)) }) +function shortenUrl (u) { + if (!u) return '' + return u.replace(/^https?:\/\//, '').slice(0, 28) + (u.length > 35 ? '…' : '') +} + function readFile (file) { return new Promise((resolve, reject) => { const r = new FileReader() @@ -278,8 +368,9 @@ async function goPreview () { parsing.value = true try { const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi }) - recipients.value = r.recipients || [] - parseInfo.value = { leftover_gifts: r.leftover_gifts || 0, leftover_contacts: r.leftover_contacts || 0 } + recipients.value = r.recipients || [] + unpairedContacts.value = r.unpaired_contacts || [] + unusedGifts.value = r.unused_gifts || [] step.value = 2 } catch (e) { $q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message }) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 3c64640..473e0f9 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -612,6 +612,13 @@ function applyWebhookEvent (ev) { // ── HTTP routing ───────────────────────────────────────────────────────────── async function handle (req, res, method, path) { // POST /campaigns/parse — preview matched send list (no save) + // Returns: + // - recipients[]: contacts paired with a gift_url (ready to send) + // - unpaired_contacts[]: contacts with no matching gift (won't be sent — + // these are surfaced so the user can decide whether to acquire more + // gift links and re-upload, or proceed anyway) + // - unused_gifts[]: gift URLs with no matching contact (lost capacity — + // surfaced so the user knows the imbalance) if (path === '/campaigns/parse' && method === 'POST') { const body = await parseBody(req) const { map_csv, giftbit_csv, multi } = body || {} @@ -621,16 +628,17 @@ async function handle (req, res, method, path) { const { contacts, skipped } = parseMapCsv(map_csv, multi || 'first') const gifts = parseGiftbitCsv(giftbit_csv) - // Match contacts ↔ gifts (by row order, fallback to email) + // Match contacts ↔ gifts (by row order, fallback to email when Giftbit + // echoed back our recipient email in their export) const recipients = [] const n = Math.min(contacts.length, gifts.length) for (let i = 0; i < n; i++) { const c = contacts[i]; const g = gifts[i] - // If Giftbit echoed back our email, prefer that match for robustness const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email) const gift = giftByEmail || g const match = await matchCustomer(c) recipients.push({ + row_index: i + 1, ...c, gift_url: gift.gift_url, giftbit_uuid: gift.giftbit_uuid, @@ -640,10 +648,26 @@ async function handle (req, res, method, path) { excluded: false, }) } + + // Capture the imbalance: leftover contacts have no gift, leftover gifts + // have no contact. Returning them as arrays (not just counts) lets the + // UI render them so the user makes an informed decision. + const unpaired_contacts = contacts.slice(n).map((c, idx) => ({ + row_index: n + idx + 1, + ...c, + })) + const unused_gifts = gifts.slice(n).map((g, idx) => ({ + row_index: n + idx + 1, + ...g, + })) + return json(res, 200, { recipients, - leftover_gifts: gifts.length - n, - leftover_contacts: contacts.length - n, + unpaired_contacts, + unused_gifts, + // Kept for backward compat with any older callers + leftover_gifts: unused_gifts.length, + leftover_contacts: unpaired_contacts.length, skipped, }) }