diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index ad20705..7a968ca 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -34,6 +34,13 @@ :loading="resumingSend" @click="confirmResumeSend"> {{ counterFor('pending') }} destinataire(s) encore en attente — reprend sans renvoyer à ceux déjà rejoints + + + Renvoyer l'invitation aux destinataires qui n'ont pas encore rempli le formulaire RSVP + + + + + + + +
Relancer les non-inscrits
+ + +
+ + Calcul en cours… + + + + + + +
+
@@ -409,7 +448,7 @@ import DataTable from 'src/components/shared/DataTable.vue' import { useRoute, useRouter } from 'vue-router' import { useQuasar } from 'quasar' 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 ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue' @@ -645,6 +684,54 @@ const canResumeEventSend = computed(() => && campaign.value.status === 'sending' && 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 » à ${n} destinataire(s) qui n'ont pas encore répondu ?
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 — // almost always identical across recipients since they were sent in the diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue index 0e23976..0edf01e 100644 --- a/apps/ops/src/pages/EventsPage.vue +++ b/apps/ops/src/pages/EventsPage.vue @@ -393,18 +393,19 @@
Calcul en cours… @@ -427,7 +428,7 @@ - @@ -485,6 +486,19 @@ const reminderPreviewLoading = ref(false) const showReminderSample = ref(false) const sendingReminder = ref(false) 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 audienceSource = ref('list') 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 () { if (!reminderCampaignId.value) { reminderPreview.value = null; return } 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) } catch (e) { $q.notify({ type: 'negative', message: e.message }); reminderPreview.value = null } finally { reminderPreviewLoading.value = false } } function confirmReminderSend () { - const n = reminderPreview.value?.count || 0 + const n = reminderTargetCount.value if (!n) return const chan = sendChannel.value === 'gmail' ? 'Gmail' : 'Mailjet' + const segments = Object.entries(reminderSegments.value).filter(([, checked]) => checked).map(([key]) => key) $q.dialog({ title: 'Envoyer le rappel', message: `Envoyer un « Petit rappel » à ${n} destinataire(s) qui n'ont pas encore répondu, via ${chan} ?
Cette action est irréversible.`, @@ -902,7 +918,7 @@ function confirmReminderSend () { }).onOk(async () => { sendingReminder.value = true 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 }] }) showSend.value = false } catch (e) { $q.notify({ type: 'negative', message: 'Échec du lancement : ' + e.message }) } finally { sendingReminder.value = false } diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js index 9d1f6e1..7729c15 100644 --- a/services/targo-hub/lib/events.js +++ b/services/targo-hub/lib/events.js @@ -572,7 +572,12 @@ function listEventCampaigns (eventId) { 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) } -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 src = campaigns.loadCampaign(sourceCampaignId) 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.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 (!['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() if (email && registeredEmails.has(email)) return false if (r.customer_id && registeredCustomers.has(r.customer_id)) return false return true }) const breakdown = { - not_opened: eligible.filter(r => r.status === 'sent').length, - opened_not_clicked: eligible.filter(r => r.status === 'opened').length, - clicked_not_registered: eligible.filter(r => r.status === 'clicked').length, + not_opened: fullEligible.filter(r => r.status === 'sent').length, + opened_not_clicked: fullEligible.filter(r => r.status === 'opened').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 // reminderCandidates() ; le worker (sendEventCampaignAsync) reçoit isReminder:true → badge // « 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 »). -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 r = reminderCandidates(eventId, sourceCampaignId) + const r = reminderCandidates(eventId, sourceCampaignId, { segments }) if (r.error) return r if (!r.count) return { error: 'no_candidates' } 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' }) const campaignId = url.searchParams.get('campaign') || '' 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 }) 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) if (!b.campaign_id) return json(res, 400, { error: 'campaign_id_required' }) 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 }) 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 })