Events: segment checkboxes for the reminder ("not opened"/"opened"/"clicked")

The reminder previously always targeted everyone not yet registered as one
fixed group. Staff can now pick which engagement segments to include:

- reminderCandidates()/reminderSend() (events.js) accept an optional
  `segments` filter (subset of sent/opened/clicked, matching the original
  invitation's per-recipient status). `breakdown` always reports the FULL
  unfiltered counts so the UI can show each checkbox's true count
  regardless of what's checked; `candidates`/`count` reflect the filter —
  what actually gets sent.
- New routes accept it: GET reminder-preview?segments=a,b and POST
  reminder-send {segments:[...]}.
- EventsPage.vue Rappel tab: the flat breakdown chips became checkboxes
  (all checked by default = prior behavior), with a live target count and
  a client-side-filtered recipient preview.
- CampaignDetailPage.vue: new "Relancer les non-inscrits" button for any
  event-type send — same checkbox/count/confirm flow in a compact dialog,
  without leaving the campaign page. Targets that campaign directly (no
  source picker needed, unlike the Events-page flow which can target any
  past send for the event).

Verified live: segment-filtered preview against the real production
campaign (2,295 total non-registered -> 708 when limited to opened+clicked,
correctly excluding the 1,587 who never opened).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-24 12:18:56 -04:00
parent 23d6ea77c6
commit 608a525038
3 changed files with 138 additions and 25 deletions

View File

@ -34,6 +34,13 @@
:loading="resumingSend" @click="confirmResumeSend"> :loading="resumingSend" @click="confirmResumeSend">
<q-tooltip>{{ counterFor('pending') }} destinataire(s) encore en attente reprend sans renvoyer à ceux déjà rejoints</q-tooltip> <q-tooltip>{{ counterFor('pending') }} destinataire(s) encore en attente reprend sans renvoyer à ceux déjà rejoints</q-tooltip>
</q-btn> </q-btn>
<!-- Relance rapide, sans quitter la page campagne : cible les invités de CET envoi qui n'ont
pas encore soumis leur RSVP (voir aussi l'onglet Rappel de la page Événements). -->
<q-btn v-if="canReminderFromCampaign" flat dense icon="notifications" color="warning"
label="Relancer les non-inscrits" class="q-mr-sm"
:loading="reminderPreviewLoading" @click="openReminderFromCampaign">
<q-tooltip>Renvoyer l'invitation aux destinataires qui n'ont pas encore rempli le formulaire RSVP</q-tooltip>
</q-btn>
<!-- Re-send path: clone a locked (sent) campaign into a fresh draft where <!-- Re-send path: clone a locked (sent) campaign into a fresh draft where
the sender + subjects are editable again. --> the sender + subjects are editable again. -->
<q-btn v-if="campaign && campaign.status !== 'draft'" flat dense icon="content_copy" color="primary" <q-btn v-if="campaign && campaign.status !== 'draft'" flat dense icon="content_copy" color="primary"
@ -400,6 +407,38 @@
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Relance des non-inscrits même logique que l'onglet Rappel d'Événements, sans quitter la page -->
<q-dialog v-model="reminderOpen">
<q-card style="min-width:380px;max-width:520px;width:100%">
<q-card-section class="row items-center q-pb-none">
<q-icon name="notifications" color="warning" size="sm" class="q-mr-sm" />
<div class="text-h6">Relancer les non-inscrits</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<q-banner v-if="reminderPreviewLoading" dense rounded class="bg-grey-2 text-grey-8"><q-spinner size="16px" class="q-mr-xs" />Calcul en cours</q-banner>
<template v-else-if="reminderPreview">
<div class="text-caption text-grey-7 q-mb-sm">Cible : invités de cet envoi qui n'ont pas soumis de RSVP.</div>
<div class="text-weight-medium text-grey-8 q-mb-xs">Cibler :</div>
<div class="column">
<q-checkbox v-model="reminderSegments.sent" dense>Pas ouvert · <b class="q-ml-xs">{{ reminderPreview.breakdown.not_opened }}</b></q-checkbox>
<q-checkbox v-model="reminderSegments.opened" dense>Ouvert, pas cliqué · <b class="q-ml-xs">{{ reminderPreview.breakdown.opened_not_clicked }}</b></q-checkbox>
<q-checkbox v-model="reminderSegments.clicked" dense>Cliqué, pas inscrit · <b class="q-ml-xs">{{ reminderPreview.breakdown.clicked_not_registered }}</b></q-checkbox>
</div>
<q-banner dense rounded class="bg-orange-1 text-orange-10 q-mt-sm">
<b>{{ reminderTargetCount }}</b> destinataire(s) seront relancé(s) (sur {{ reminderPreview.source.total }} envoyé(s) au départ).
</q-banner>
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="warning" icon="notifications" :disable="!reminderTargetCount || sendingReminder" :loading="sendingReminder"
:label="reminderTargetCount ? `Envoyer à ${reminderTargetCount}` : 'Aucun destinataire'" @click="confirmReminderFromCampaign" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page> </q-page>
</template> </template>
@ -409,7 +448,7 @@ import DataTable from 'src/components/shared/DataTable.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate, reconcileWithMailjet } from 'src/api/campaigns' import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate, reconcileWithMailjet } from 'src/api/campaigns'
import { resumeEventCampaign } from 'src/api/events' import { resumeEventCampaign, previewReminder, sendReminder } from 'src/api/events'
import SenderField from 'src/modules/campaigns/components/SenderField.vue' import SenderField from 'src/modules/campaigns/components/SenderField.vue'
import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue' import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue'
@ -645,6 +684,54 @@ const canResumeEventSend = computed(() =>
&& campaign.value.status === 'sending' && campaign.value.status === 'sending'
&& counterFor('pending') > 0, && counterFor('pending') > 0,
) )
// Relance des non-inscrits, directement depuis la page campagne (même mécanique que l'onglet Rappel
// d'Événements) visible pour tout envoi d'événement déjà lancé, tant qu'il ne s'agit pas lui-même
// d'une relance (on relance toujours depuis l'envoi ORIGINAL, pas en cascade).
const canReminderFromCampaign = computed(() =>
campaign.value
&& campaign.value.params?.type === 'event'
&& !campaign.value.reminder_of
&& ['sending', 'completed'].includes(campaign.value.status),
)
const reminderOpen = ref(false)
const reminderPreview = ref(null)
const reminderPreviewLoading = ref(false)
const sendingReminder = ref(false)
const reminderSegments = ref({ sent: true, opened: true, clicked: true })
const reminderTargetCount = computed(() => {
const b = reminderPreview.value?.breakdown; if (!b) return 0
const s = reminderSegments.value
return (s.sent ? b.not_opened : 0) + (s.opened ? b.opened_not_clicked : 0) + (s.clicked ? b.clicked_not_registered : 0)
})
async function openReminderFromCampaign () {
reminderOpen.value = true
reminderPreviewLoading.value = true
reminderSegments.value = { sent: true, opened: true, clicked: true }
try { reminderPreview.value = await previewReminder(campaign.value.params.event_id, id) }
catch (e) { $q.notify({ type: 'negative', message: e.message }); reminderPreview.value = null }
finally { reminderPreviewLoading.value = false }
}
function confirmReminderFromCampaign () {
const n = reminderTargetCount.value
if (!n) return
const segments = Object.entries(reminderSegments.value).filter(([, checked]) => checked).map(([key]) => key)
$q.dialog({
title: 'Envoyer le rappel',
message: `Envoyer un « Petit rappel » à <b>${n}</b> destinataire(s) qui n'ont pas encore répondu ?<br>Cette action est irréversible.`,
html: true,
cancel: { label: 'Annuler', flat: true, noCaps: true },
ok: { label: `Envoyer à ${n}`, color: 'warning', unelevated: true, noCaps: true },
persistent: true,
}).onOk(async () => {
sendingReminder.value = true
try {
const r = await sendReminder(campaign.value.params.event_id, { campaign_id: id, channel: campaign.value.params.channel || 'mailjet', segments })
$q.notify({ type: 'positive', message: `Rappel lancé à ${r.count} destinataire(s)`, icon: 'notifications', timeout: 6000 })
reminderOpen.value = false
router.push(`/campaigns/${r.campaign_id}`)
} catch (e) { $q.notify({ type: 'negative', message: 'Échec du lancement : ' + e.message }) } finally { sendingReminder.value = false }
})
}
// Pick a representative expiry to show in the confirmation dialog // Pick a representative expiry to show in the confirmation dialog
// almost always identical across recipients since they were sent in the // almost always identical across recipients since they were sent in the

View File

@ -393,18 +393,19 @@
<div v-if="reminderCampaignId"> <div v-if="reminderCampaignId">
<q-banner v-if="reminderPreviewLoading" dense rounded class="bg-grey-2 text-grey-8"><q-spinner size="16px" class="q-mr-xs" />Calcul en cours</q-banner> <q-banner v-if="reminderPreviewLoading" dense rounded class="bg-grey-2 text-grey-8"><q-spinner size="16px" class="q-mr-xs" />Calcul en cours</q-banner>
<template v-else-if="reminderPreview"> <template v-else-if="reminderPreview">
<q-banner dense rounded class="bg-orange-1 text-orange-10"> <div class="text-weight-medium text-grey-8 q-mb-xs">Cibler :</div>
<b>{{ reminderPreview.count }}</b> destinataire(s) éligible(s) à la relance (sur {{ reminderPreview.source.total }} envoyé(s) au départ). <div class="column">
<div class="row q-col-gutter-xs q-mt-xs"> <q-checkbox v-model="reminderSegments.sent" dense>Pas ouvert · <b class="q-ml-xs">{{ reminderPreview.breakdown.not_opened }}</b></q-checkbox>
<q-chip dense square color="grey-3" text-color="grey-9">Pas ouvert · {{ reminderPreview.breakdown.not_opened }}</q-chip> <q-checkbox v-model="reminderSegments.opened" dense>Ouvert, pas cliqué · <b class="q-ml-xs">{{ reminderPreview.breakdown.opened_not_clicked }}</b></q-checkbox>
<q-chip dense square color="blue-1" text-color="blue-10">Ouvert, pas cliqué · {{ reminderPreview.breakdown.opened_not_clicked }}</q-chip> <q-checkbox v-model="reminderSegments.clicked" dense>Cliqué, pas inscrit · <b class="q-ml-xs">{{ reminderPreview.breakdown.clicked_not_registered }}</b></q-checkbox>
<q-chip dense square color="deep-orange-1" text-color="deep-orange-9">Cliqué, pas inscrit · {{ reminderPreview.breakdown.clicked_not_registered }}</q-chip>
</div> </div>
<q-banner dense rounded class="bg-orange-1 text-orange-10 q-mt-sm">
<b>{{ reminderTargetCount }}</b> destinataire(s) seront relancé(s) (sur {{ reminderPreview.source.total }} envoyé(s) au départ).
</q-banner> </q-banner>
<div v-if="reminderPreview.count" class="q-mt-xs"> <div v-if="reminderTargetCount" class="q-mt-xs">
<a class="cursor-pointer text-caption text-primary" @click="showReminderSample = !showReminderSample">{{ showReminderSample ? '▾ Masquer' : '▸ Voir' }} les destinataires</a> <a class="cursor-pointer text-caption text-primary" @click="showReminderSample = !showReminderSample">{{ showReminderSample ? '▾ Masquer' : '▸ Voir' }} les destinataires</a>
<q-list v-if="showReminderSample" dense separator bordered class="rounded-borders q-mt-xs" style="max-height:220px;overflow:auto"> <q-list v-if="showReminderSample" dense separator bordered class="rounded-borders q-mt-xs" style="max-height:220px;overflow:auto">
<q-item v-for="s in reminderPreview.sample" :key="s.email" dense> <q-item v-for="s in reminderFilteredSample" :key="s.email" dense>
<q-item-section> <q-item-section>
<q-item-label class="text-caption">{{ s.name }} <q-badge dense :color="s.language === 'en' ? 'deep-orange-6' : 'blue-6'" :label="(s.language || 'fr').toUpperCase()" class="q-ml-xs" /></q-item-label> <q-item-label class="text-caption">{{ s.name }} <q-badge dense :color="s.language === 'en' ? 'deep-orange-6' : 'blue-6'" :label="(s.language || 'fr').toUpperCase()" class="q-ml-xs" /></q-item-label>
<q-item-label caption> <q-item-label caption>
@ -414,7 +415,7 @@
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item v-if="reminderPreview.count > reminderPreview.sample.length" dense><q-item-section class="text-caption text-grey-6">et {{ reminderPreview.count - reminderPreview.sample.length }} autres (affichage plafonné à 50)</q-item-section></q-item> <q-item v-if="reminderTargetCount > reminderFilteredSample.length" dense><q-item-section class="text-caption text-grey-6">et {{ reminderTargetCount - reminderFilteredSample.length }} autres (affichage plafonné à 50)</q-item-section></q-item>
</q-list> </q-list>
</div> </div>
</template> </template>
@ -427,7 +428,7 @@
<q-btn v-if="sendMode === 'test'" unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" /> <q-btn v-if="sendMode === 'test'" unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" />
<q-btn v-else-if="sendMode === 'mass'" unelevated no-caps color="negative" icon="send" :disable="!mainList.count || sendingMass" :loading="sendingMass" <q-btn v-else-if="sendMode === 'mass'" unelevated no-caps color="negative" icon="send" :disable="!mainList.count || sendingMass" :loading="sendingMass"
:label="mainList.count ? `Envoyer à ${mainList.count} destinataire(s)` : 'Liste vide'" @click="confirmMassSend" /> :label="mainList.count ? `Envoyer à ${mainList.count} destinataire(s)` : 'Liste vide'" @click="confirmMassSend" />
<q-btn v-else unelevated no-caps color="warning" icon="notifications" :disable="!reminderPreview?.count || sendingReminder" :loading="sendingReminder" <q-btn v-else unelevated no-caps color="warning" icon="notifications" :disable="!reminderTargetCount || sendingReminder" :loading="sendingReminder"
:label="reminderPreview?.count ? `Envoyer le rappel à ${reminderPreview.count}` : 'Aucun destinataire'" @click="confirmReminderSend" /> :label="reminderPreview?.count ? `Envoyer le rappel à ${reminderPreview.count}` : 'Aucun destinataire'" @click="confirmReminderSend" />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
@ -485,6 +486,19 @@ const reminderPreviewLoading = ref(false)
const showReminderSample = ref(false) const showReminderSample = ref(false)
const sendingReminder = ref(false) const sendingReminder = ref(false)
const reminderBadgeLabel = 'Petit rappel / Reminder' const reminderBadgeLabel = 'Petit rappel / Reminder'
// Cases à cocher par segment clés = statuts d'origine ('sent'=pas ouvert, 'opened'=ouvert pas cliqué,
// 'clicked'=cliqué pas inscrit). Toutes cochées par défaut (comportement d'origine = cibler tout le monde).
const reminderSegments = ref({ sent: true, opened: true, clicked: true })
const reminderTargetCount = computed(() => {
const b = reminderPreview.value?.breakdown; if (!b) return 0
const s = reminderSegments.value
return (s.sent ? b.not_opened : 0) + (s.opened ? b.opened_not_clicked : 0) + (s.clicked ? b.clicked_not_registered : 0)
})
const reminderFilteredSample = computed(() => {
const sample = reminderPreview.value?.sample || []
const s = reminderSegments.value
return sample.filter(x => s[x.status])
})
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }]) const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
const audienceSource = ref('list') const audienceSource = ref('list')
const audFilters = ref({ statut: 'active', customer_type: '', city: '', name_like: '', active_sub: false, min_monthly: 0 }) const audFilters = ref({ statut: 'active', customer_type: '', city: '', name_like: '', active_sub: false, min_monthly: 0 })
@ -884,14 +898,16 @@ async function loadEventCampaigns () {
async function loadReminderPreview () { async function loadReminderPreview () {
if (!reminderCampaignId.value) { reminderPreview.value = null; return } if (!reminderCampaignId.value) { reminderPreview.value = null; return }
reminderPreviewLoading.value = true; showReminderSample.value = false reminderPreviewLoading.value = true; showReminderSample.value = false
reminderSegments.value = { sent: true, opened: true, clicked: true } // repart de « tout le monde » à chaque nouvel envoi source choisi
try { reminderPreview.value = await previewReminder(eventId.value, reminderCampaignId.value) } try { reminderPreview.value = await previewReminder(eventId.value, reminderCampaignId.value) }
catch (e) { $q.notify({ type: 'negative', message: e.message }); reminderPreview.value = null } catch (e) { $q.notify({ type: 'negative', message: e.message }); reminderPreview.value = null }
finally { reminderPreviewLoading.value = false } finally { reminderPreviewLoading.value = false }
} }
function confirmReminderSend () { function confirmReminderSend () {
const n = reminderPreview.value?.count || 0 const n = reminderTargetCount.value
if (!n) return if (!n) return
const chan = sendChannel.value === 'gmail' ? 'Gmail' : 'Mailjet' const chan = sendChannel.value === 'gmail' ? 'Gmail' : 'Mailjet'
const segments = Object.entries(reminderSegments.value).filter(([, checked]) => checked).map(([key]) => key)
$q.dialog({ $q.dialog({
title: 'Envoyer le rappel', title: 'Envoyer le rappel',
message: `Envoyer un « Petit rappel » à <b>${n}</b> destinataire(s) qui n'ont pas encore répondu, via <b>${chan}</b> ?<br>Cette action est irréversible.`, message: `Envoyer un « Petit rappel » à <b>${n}</b> destinataire(s) qui n'ont pas encore répondu, via <b>${chan}</b> ?<br>Cette action est irréversible.`,
@ -902,7 +918,7 @@ function confirmReminderSend () {
}).onOk(async () => { }).onOk(async () => {
sendingReminder.value = true sendingReminder.value = true
try { try {
const r = await sendReminder(eventId.value, { campaign_id: reminderCampaignId.value, channel: sendChannel.value }) const r = await sendReminder(eventId.value, { campaign_id: reminderCampaignId.value, channel: sendChannel.value, segments })
$q.notify({ type: 'positive', message: `Rappel lancé à ${r.count} destinataire(s)`, caption: 'Suivi dans Campagnes', icon: 'notifications', timeout: 7000, actions: [{ label: 'Ouvrir Campagnes', color: 'white', handler: goCampaigns }] }) $q.notify({ type: 'positive', message: `Rappel lancé à ${r.count} destinataire(s)`, caption: 'Suivi dans Campagnes', icon: 'notifications', timeout: 7000, actions: [{ label: 'Ouvrir Campagnes', color: 'white', handler: goCampaigns }] })
showSend.value = false showSend.value = false
} catch (e) { $q.notify({ type: 'negative', message: 'Échec du lancement : ' + e.message }) } finally { sendingReminder.value = false } } catch (e) { $q.notify({ type: 'negative', message: 'Échec du lancement : ' + e.message }) } finally { sendingReminder.value = false }

View File

@ -572,7 +572,12 @@ function listEventCampaigns (eventId) {
return campaigns.listCampaigns() return campaigns.listCampaigns()
.filter(c => c.event_id === eventId && c.type === 'event' && !c.reminder_of) // exclut les relances (on relance TOUJOURS depuis l'envoi original) .filter(c => c.event_id === eventId && c.type === 'event' && !c.reminder_of) // exclut les relances (on relance TOUJOURS depuis l'envoi original)
} }
function reminderCandidates (eventId, sourceCampaignId) { // `segments` (optionnel) restreint la CIBLE aux statuts d'origine choisis par le staff (case à cocher
// par catégorie) : 'sent' (pas ouvert), 'opened' (ouvert, pas cliqué), 'clicked' (cliqué, pas inscrit).
// `breakdown` reflète TOUJOURS le total NON filtré (pour afficher le compte de chaque case, qu'elle
// soit cochée ou non) ; `candidates`/`count` reflètent le filtre appliqué (la cible réelle de l'envoi).
const REMINDER_SEGMENTS = ['sent', 'opened', 'clicked']
function reminderCandidates (eventId, sourceCampaignId, { segments } = {}) {
const campaigns = require('./campaigns') const campaigns = require('./campaigns')
const src = campaigns.loadCampaign(sourceCampaignId) const src = campaigns.loadCampaign(sourceCampaignId)
if (!src) return { error: 'campaign_not_found' } if (!src) return { error: 'campaign_not_found' }
@ -583,28 +588,30 @@ function reminderCandidates (eventId, sourceCampaignId) {
if (r.email) registeredEmails.add(String(r.email).toLowerCase()) if (r.email) registeredEmails.add(String(r.email).toLowerCase())
if (r.customer_id) registeredCustomers.add(r.customer_id) if (r.customer_id) registeredCustomers.add(r.customer_id)
} }
const eligible = (src.recipients || []).filter(r => { const fullEligible = (src.recipients || []).filter(r => {
if (r.excluded) return false if (r.excluded) return false
if (!['sent', 'opened', 'clicked'].includes(r.status)) return false // seulement les invitations réellement livrées (pas pending/failed) if (!REMINDER_SEGMENTS.includes(r.status)) return false // seulement les invitations réellement livrées (pas pending/failed)
const email = String(r.email || '').toLowerCase() const email = String(r.email || '').toLowerCase()
if (email && registeredEmails.has(email)) return false if (email && registeredEmails.has(email)) return false
if (r.customer_id && registeredCustomers.has(r.customer_id)) return false if (r.customer_id && registeredCustomers.has(r.customer_id)) return false
return true return true
}) })
const breakdown = { const breakdown = {
not_opened: eligible.filter(r => r.status === 'sent').length, not_opened: fullEligible.filter(r => r.status === 'sent').length,
opened_not_clicked: eligible.filter(r => r.status === 'opened').length, opened_not_clicked: fullEligible.filter(r => r.status === 'opened').length,
clicked_not_registered: eligible.filter(r => r.status === 'clicked').length, clicked_not_registered: fullEligible.filter(r => r.status === 'clicked').length,
} }
return { source: { id: src.id, name: src.name, total: (src.recipients || []).length }, candidates: eligible, count: eligible.length, breakdown } const wanted = Array.isArray(segments) && segments.length ? new Set(segments.filter(s => REMINDER_SEGMENTS.includes(s))) : null
const candidates = wanted ? fullEligible.filter(r => wanted.has(r.status)) : fullEligible
return { source: { id: src.id, name: src.name, total: (src.recipients || []).length }, candidates, count: candidates.length, breakdown }
} }
// Crée + lance une campagne de RELANCE (statut sending direct, comme massSend) ciblant // Crée + lance une campagne de RELANCE (statut sending direct, comme massSend) ciblant
// reminderCandidates() ; le worker (sendEventCampaignAsync) reçoit isReminder:true → badge // reminderCandidates() ; le worker (sendEventCampaignAsync) reçoit isReminder:true → badge
// « Petit rappel »/« Reminder » + sujet préfixé. Recalcule les candidats à l'envoi (fraîcheur : // « Petit rappel »/« Reminder » + sujet préfixé. Recalcule les candidats à l'envoi (fraîcheur :
// quelqu'un a pu s'inscrire entre l'aperçu et le clic sur « Envoyer »). // quelqu'un a pu s'inscrire entre l'aperçu et le clic sur « Envoyer »).
function reminderSend (eventId, sourceCampaignId, { channel = 'mailjet', from = '' } = {}) { function reminderSend (eventId, sourceCampaignId, { channel = 'mailjet', from = '', segments } = {}) {
const event = getEvent(eventId); if (!event) return { error: 'event_not_found' } const event = getEvent(eventId); if (!event) return { error: 'event_not_found' }
const r = reminderCandidates(eventId, sourceCampaignId) const r = reminderCandidates(eventId, sourceCampaignId, { segments })
if (r.error) return r if (r.error) return r
if (!r.count) return { error: 'no_candidates' } if (!r.count) return { error: 'no_candidates' }
const campaigns = require('./campaigns') const campaigns = require('./campaigns')
@ -1209,7 +1216,9 @@ async function handle (req, res, method, path, url) {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' }) if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const campaignId = url.searchParams.get('campaign') || '' const campaignId = url.searchParams.get('campaign') || ''
if (!campaignId) return json(res, 400, { error: 'campaign_required' }) if (!campaignId) return json(res, 400, { error: 'campaign_required' })
const r = reminderCandidates(mm[1], campaignId) const segParam = url.searchParams.get('segments')
const segments = segParam ? segParam.split(',').map(s => s.trim()).filter(Boolean) : undefined
const r = reminderCandidates(mm[1], campaignId, { segments })
if (r.error) return json(res, 400, { error: r.error }) if (r.error) return json(res, 400, { error: r.error })
return json(res, 200, { source: r.source, count: r.count, breakdown: r.breakdown, sample: r.candidates.slice(0, 50).map(x => ({ email: x.email, name: ((x.firstname || '') + ' ' + (x.lastname || '')).trim() || x.email, language: x.language, status: x.status, opened_at: x.opened_at || null, clicked_at: x.clicked_at || null })) }) return json(res, 200, { source: r.source, count: r.count, breakdown: r.breakdown, sample: r.candidates.slice(0, 50).map(x => ({ email: x.email, name: ((x.firstname || '') + ' ' + (x.lastname || '')).trim() || x.email, language: x.language, status: x.status, opened_at: x.opened_at || null, clicked_at: x.clicked_at || null })) })
} }
@ -1229,7 +1238,8 @@ async function handle (req, res, method, path, url) {
const b = await parseBody(req) const b = await parseBody(req)
if (!b.campaign_id) return json(res, 400, { error: 'campaign_id_required' }) if (!b.campaign_id) return json(res, 400, { error: 'campaign_id_required' })
const channel = b.channel === 'gmail' ? 'gmail' : 'mailjet' const channel = b.channel === 'gmail' ? 'gmail' : 'mailjet'
const r = reminderSend(eventId, b.campaign_id, { channel, from: b.from }) const segments = Array.isArray(b.segments) ? b.segments : undefined
const r = reminderSend(eventId, b.campaign_id, { channel, from: b.from, segments })
if (r.error) return json(res, 400, { error: r.error }) if (r.error) return json(res, 400, { error: r.error })
log(`Event REMINDER — ${eventId} · ${channel} · ${r.count} destinataire(s) → campagne ${r.campaign_id} (source ${b.campaign_id})`) log(`Event REMINDER — ${eventId} · ${channel} · ${r.count} destinataire(s) → campagne ${r.campaign_id} (source ${b.campaign_id})`)
return json(res, 202, { ok: true, campaign_id: r.campaign_id, count: r.count }) return json(res, 202, { ok: true, campaign_id: r.campaign_id, count: r.count })