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:
parent
23d6ea77c6
commit
608a525038
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user