From d961c57f9e65209e8b2e80e9adf096d0696b3236 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 24 Jul 2026 09:56:15 -0400 Subject: [PATCH] Events: reminder ("Petit rappel") for non-registered invitees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New reminder engine (events.js): reminderCandidates(eventId, sourceCampaignId) targets recipients from a past invitation send who haven't submitted an RSVP yet — covering both "never clicked" and "clicked but didn't register" — while excluding pending/failed/excluded rows. Returns a status breakdown (not opened / opened not clicked / clicked not registered) for preview before sending. reminderSend() creates+starts a new tracked campaign (reminder_of linking back to the source), reusing the same send worker with an isReminder flag. - isReminder threads through inviteSubject (prefixes "Petit rappel — "/ "Reminder — ") and inviteEmailFestive (adds a 🔔 badge above the title); exposed on invite-preview and test-send too so the look can be checked before a real reminder blast. - New routes: GET /events/:id/campaigns (past sends for the picker), GET /events/:id/reminder-preview (count + breakdown + sample, no side effects), POST /events/:id/reminder-send. - campaigns.js: listCampaigns() summary gains event_id/type/reminder_of (same read, no extra cost) and is now exported, so events.js can list an event's past campaigns without loading each one in full. - EventsPage.vue: new "Rappel" tab in the send dialog — pick a source send, see the live count/breakdown/recipient list, confirm-to-send. Test tab gains a toggle to preview the reminder look via a real test-send. - CampaignDetailPage.vue: "Ouvert le"/"Cliqué le" columns on the recipient table (shared by gift and event campaigns). Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/api/events.js | 5 + .../campaigns/pages/CampaignDetailPage.vue | 3 + apps/ops/src/pages/EventsPage.vue | 102 ++++++++++++- services/targo-hub/lib/campaigns.js | 6 +- services/targo-hub/lib/events.js | 141 +++++++++++++++--- 5 files changed, 231 insertions(+), 26 deletions(-) diff --git a/apps/ops/src/api/events.js b/apps/ops/src/api/events.js index a77354f..205031f 100644 --- a/apps/ops/src/api/events.js +++ b/apps/ops/src/api/events.js @@ -55,3 +55,8 @@ export async function deleteEvent (eventId) { return hubFetch(`/events/${encodeU // Pièces jointes du courriel d'invitation (image/PDF, base64). export async function uploadAttachment (eventId, { filename, mime, data, lang }) { return hubFetch(`/events/${encodeURIComponent(eventId)}/attachments`, { method: 'POST', body: { filename, mime, data, lang } }) } export async function deleteAttachment (eventId, stored) { return hubFetch(`/events/${encodeURIComponent(eventId)}/attachments/${encodeURIComponent(stored)}`, { method: 'DELETE' }) } + +// Rappel (« Petit rappel »/« Reminder ») aux invités PAS ENCORE inscrits d'un envoi passé. +export async function listEventCampaigns (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/campaigns`) } +export async function previewReminder (eventId, campaignId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/reminder-preview?campaign=${encodeURIComponent(campaignId)}`) } +export async function sendReminder (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/reminder-send`, { method: 'POST', body }) } diff --git a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue index c3ac8d8..e14ff58 100644 --- a/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue +++ b/apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue @@ -503,8 +503,11 @@ const columns = [ { name: 'customer', label: 'Client lié', field: 'customer_name', align: 'left' }, { name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' }, { name: 'status', label: 'Statut', field: 'status', align: 'left' }, + { name: 'opened_at', label: 'Ouvert le', field: 'opened_at', align: 'left', format: fmtWhen }, + { name: 'clicked_at', label: 'Cliqué le', field: 'clicked_at', align: 'left', format: fmtWhen }, { name: 'error', label: 'Erreur', field: 'error', align: 'left' }, ] +function fmtWhen (v) { return v ? new Date(v).toLocaleString('fr-CA', { timeZone: 'America/Montreal' }) : '—' } function counterFor (s) { return campaign.value?.counters?.[s] || 0 } function statusColor (s) { diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue index 94c79df..0e23976 100644 --- a/apps/ops/src/pages/EventsPage.vue +++ b/apps/ops/src/pages/EventsPage.vue @@ -258,7 +258,8 @@ + :options="[{ label: 'Test à moi', value: 'test' }, { label: 'Envoi de masse', value: 'mass' }, { label: 'Rappel', value: 'reminder' }]" + @update:model-value="onSendModeChange" /> @@ -267,10 +268,11 @@ + - +
Modèle : {{ ev.email_template || 'Festif intégré (TARGO)' }} — se change/conçoit dans « Modifier ».
@@ -381,11 +383,52 @@
+ + +
Relance « {{ reminderBadgeLabel }} » aux invités d'un envoi passé qui n'ont pas cliqué ou pas encore inscrit.
+ + + +
+ Calcul en cours… + +
+
Choisissez un envoi source ci-dessus pour voir qui serait relancé.
+
+ - + @@ -406,7 +449,7 @@ import { listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates, getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment, getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy, - getInvitePreview, + getInvitePreview, listEventCampaigns, previewReminder, sendReminder, } from 'src/api/events' const $q = useQuasar() @@ -429,9 +472,19 @@ const showSend = ref(false) const sendChannel = ref('mailjet') const testEmail = ref('') const testLang = ref('fr') +const testReminder = ref(false) const sending = ref(false) // Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer. const sendMode = ref('test') +// Rappel (relance des non-inscrits d'un envoi passé) +const eventCampaigns = ref([]) +const loadingEventCampaigns = ref(false) +const reminderCampaignId = ref('') +const reminderPreview = ref(null) +const reminderPreviewLoading = ref(false) +const showReminderSample = ref(false) +const sendingReminder = ref(false) +const reminderBadgeLabel = 'Petit rappel / Reminder' 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 }) @@ -448,6 +501,7 @@ const listBusy = ref(false) const showListSample = ref(false) const mainListSources = computed(() => Object.entries(mainList.value.by_source || {}).map(([label, n]) => ({ label, n })).sort((a, b) => b.n - a.n)) +const eventCampaignOptions = computed(() => eventCampaigns.value.map(c => ({ value: c.id, label: `${c.name} — ${fmtDate(c.created_at)} (${c.counters?.sent || 0}+${(c.counters?.opened || 0) + (c.counters?.clicked || 0)}/${c.total})` }))) const eventOptions = computed(() => events.value.map(e => ({ value: e.id, label: e.name || e.id }))) const publicUrl = computed(() => ev.value.public_url || '') const isFull = computed(() => ev.value.capacity > 0 && headcount.value >= ev.value.capacity) @@ -811,12 +865,50 @@ async function sendTestNow () { if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return } sending.value = true try { - const r = await sendInvite(eventId.value, { test: true, test_emails: [em], channel: sendChannel.value, lang: testLang.value }) + const r = await sendInvite(eventId.value, { test: true, test_emails: [em], channel: sendChannel.value, lang: testLang.value, reminder: testReminder.value }) if (r.ok) $q.notify({ type: 'positive', message: `Courriel test envoyé à ${em}`, icon: 'send' }) else $q.notify({ type: 'negative', message: 'Échec : ' + ((r.results && r.results[0] && r.results[0].error) || 'inconnu') }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { sending.value = false } } +// ── Rappel : relance des invités d'un envoi passé qui n'ont pas encore répondu ── +function onSendModeChange (mode) { if (mode === 'reminder' && !eventCampaigns.value.length) loadEventCampaigns() } +async function loadEventCampaigns () { + loadingEventCampaigns.value = true + try { + const d = await listEventCampaigns(eventId.value) + eventCampaigns.value = d.campaigns || [] + if (eventCampaigns.value.length === 1) { reminderCampaignId.value = eventCampaigns.value[0].id; await loadReminderPreview() } + } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { loadingEventCampaigns.value = false } +} +async function loadReminderPreview () { + if (!reminderCampaignId.value) { reminderPreview.value = null; return } + reminderPreviewLoading.value = true; showReminderSample.value = false + 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 + if (!n) return + const chan = sendChannel.value === 'gmail' ? 'Gmail' : 'Mailjet' + $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.`, + 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(eventId.value, { campaign_id: reminderCampaignId.value, channel: sendChannel.value }) + $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 } + }) +} + onMounted(load) diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index d41642a..5d0926b 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -777,7 +777,9 @@ function listCampaigns () { const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')) return { id: c.id, name: c.name, created_at: c.created_at, status: c.status, counters: c.counters, - total: (c.recipients || []).length } + total: (c.recipients || []).length, + // Additifs (coût nul, déjà parsé) : filtrage par événement + exclusion des relances côté appelant. + event_id: (c.params || {}).event_id || '', type: (c.params || {}).type || '', reminder_of: c.reminder_of || '' } } catch { return null } }) .filter(Boolean) @@ -2800,5 +2802,5 @@ module.exports = { renderTemplate, renderNamedTemplate, // Enregistrement de campagne réutilisable (lib/events.js crée une campagne pour le blast d'invitations : // suivi Mailjet via X-MJ-CustomID `:` → webhook existant, report.csv, /campaigns list/détail, SSE). - newCampaignId, loadCampaign, saveCampaign, + newCampaignId, loadCampaign, saveCampaign, listCampaigns, } diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js index 834a91b..81adb3b 100644 --- a/services/targo-hub/lib/events.js +++ b/services/targo-hub/lib/events.js @@ -76,6 +76,8 @@ const LABELS = { foot: 'TARGO — votre fournisseur Internet local', full_title: 'Inscriptions complètes', full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.", + reminder_badge: 'Petit rappel', + reminder_subject_prefix: 'Petit rappel — ', }, en: { f_name: 'Your name', @@ -100,6 +102,8 @@ const LABELS = { foot: 'TARGO — your local Internet provider', full_title: 'Registration full', full_msg: 'Unfortunately, the event booking is full.', + reminder_badge: 'Reminder', + reminder_subject_prefix: 'Reminder — ', }, } @@ -311,31 +315,36 @@ function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24, lang = const l = lang ? `&lang=${encodeURIComponent(normLang(lang))}` : '' return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}${l}` } -function inviteSubject (eventId, lang) { +// isReminder → préfixe « Petit rappel — »/« Reminder — » (relance des non-inscrits). +function inviteSubject (eventId, lang, isReminder = false) { const e = getEvent(eventId); if (!e) return '' - return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉` + lang = normLang(lang) + const base = lang === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉` + return isReminder ? LABELS[lang].reminder_subject_prefix + base : base } // Variables Mustache offertes aux templates éditables d'invitation d'événement. -function inviteTemplateVars (e, lang, name, rsvpUrl) { +function inviteTemplateVars (e, lang, name, rsvpUrl, isReminder = false) { const t = e[lang] return { firstname: firstName(name), name: name || '', rsvp_url: rsvpUrl, gift_url: rsvpUrl, // gift_url = alias (templates cadeau réutilisables) event_name: t.name, tagline: t.tagline, when: t.when, address: t.address, body: t.body, closing: t.closing, year: String(new Date().getFullYear()), lang, + is_reminder: isReminder, reminder_label: isReminder ? LABELS[lang].reminder_badge : '', // {{#is_reminder}}…{{/is_reminder}} dans un template custom } } // Invitation : modèle éditable (Unlayer) si configuré, sinon gabarit festif intégré. -function inviteEmail (eventId, lang, name, rsvpUrl) { +// isReminder → relance envoyée aux invités PAS ENCORE inscrits (cf reminderCandidates) : badge + sujet « Petit rappel ». +function inviteEmail (eventId, lang, name, rsvpUrl, isReminder = false) { const e = getEvent(eventId); if (!e) return '' lang = normLang(lang) if (e.email_template) { - const html = require('./campaigns').renderNamedTemplate(e.email_template, inviteTemplateVars(e, lang, name, rsvpUrl)) + const html = require('./campaigns').renderNamedTemplate(e.email_template, inviteTemplateVars(e, lang, name, rsvpUrl, isReminder)) if (html) return html // template introuvable → repli festif } - return inviteEmailFestive(e, lang, name, rsvpUrl) + return inviteEmailFestive(e, lang, name, rsvpUrl, isReminder) } // Gabarit festif intégré (charte TARGO garantie) — utilisé par défaut et en repli. -function inviteEmailFestive (e, lang, name, rsvpUrl) { +function inviteEmailFestive (e, lang, name, rsvpUrl, isReminder = false) { lang = normLang(lang); const t = e[lang] const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚' const badge = [e.badge_top, e.badge_bottom].filter(Boolean).join(' ') @@ -354,6 +363,7 @@ function inviteEmailFestive (e, lang, name, rsvpUrl) { + `
🎈🎉🎈
` + (badge ? `
🎉 ${esc(badge)} 🎉

` : '
') + `TARGO` + + (isReminder ? `🔔 ${esc(t.reminder_badge)}` : '') + `

${esc(t.name)} 🎉

` + `

${esc(t.tagline)}

` + `

${esc(t.greet(firstName(name)) || '')}

` @@ -444,14 +454,14 @@ async function sendInviteMessage ({ channel, to, subject, html, from, attachment } // ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ── -async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) { +async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '', reminder = false }) { const event = getEvent(eventId) const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both') const results = [] for (const em of emails) { const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24, lang) - const html = inviteEmail(eventId, lang, name, link) - const subject = '[TEST] ' + inviteSubject(eventId, lang) + const html = inviteEmail(eventId, lang, name, link, !!reminder) + const subject = '[TEST] ' + inviteSubject(eventId, lang, !!reminder) try { const r = await sendInviteMessage({ channel, to: em, subject, html, from, attachments: atts }); results.push({ email: em, ok: r.ok, error: r.error }) } catch (e) { results.push({ email: em, ok: false, error: e.message }) } } @@ -484,7 +494,8 @@ function massSend (eventId, { channel = 'mailjet', from = '' } = {}) { return { campaign_id: id, count: campaign.recipients.length } } -async function sendEventCampaignAsync (id, eventId, channel, from) { +async function sendEventCampaignAsync (id, eventId, channel, from, opts = {}) { + const isReminder = !!opts.isReminder const campaigns = require('./campaigns') const c = campaigns.loadCampaign(id); if (!c) return const event = getEvent(eventId); if (!event) return @@ -501,8 +512,8 @@ async function sendEventCampaignAsync (id, eventId, channel, from) { const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim() const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang) r.rsvp_url = rsvpUrl - const html = inviteEmail(eventId, lang, name, rsvpUrl) - const subject = inviteSubject(eventId, lang) + const html = inviteEmail(eventId, lang, name, rsvpUrl, isReminder) + const subject = inviteSubject(eventId, lang, isReminder) const customId = id + ':' + i try { const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId }) @@ -520,6 +531,68 @@ async function sendEventCampaignAsync (id, eventId, channel, from) { bcast('campaign-done', { id, counters: c.counters }) } +// ── Relance (« Petit rappel ») aux invités PAS ENCORE inscrits ───────────── +// Critère retenu = PAS INSCRIT (aucune réponse RSVP correspondante), qui couvre à la fois +// « n'a pas cliqué » (n'a jamais atteint le formulaire) ET « a cliqué mais n'a pas complété » — +// les deux cas mentionnés par le staff. La ventilation clic/ouverture est fournie séparément +// pour transparence (aperçu avant envoi), mais ne restreint PAS l'éligibilité. +function listEventCampaigns (eventId) { + const campaigns = require('./campaigns') + 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) { + const campaigns = require('./campaigns') + const src = campaigns.loadCampaign(sourceCampaignId) + if (!src) return { error: 'campaign_not_found' } + if ((src.params || {}).event_id !== eventId) return { error: 'campaign_event_mismatch' } + const resp = loadResp(eventId) + const registeredEmails = new Set(); const registeredCustomers = new Set() + for (const r of Object.values(resp)) { + if (r.email) registeredEmails.add(String(r.email).toLowerCase()) + if (r.customer_id) registeredCustomers.add(r.customer_id) + } + const eligible = (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) + 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, + } + return { source: { id: src.id, name: src.name, total: (src.recipients || []).length }, candidates: eligible, count: eligible.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 = '' } = {}) { + const event = getEvent(eventId); if (!event) return { error: 'event_not_found' } + const r = reminderCandidates(eventId, sourceCampaignId) + if (r.error) return r + if (!r.count) return { error: 'no_candidates' } + const campaigns = require('./campaigns') + const id = campaigns.newCampaignId() + '-rem' + const ch = channel === 'gmail' ? 'gmail' : 'mailjet' + const campaign = { + id, + name: `Rappel — ${event.fr.name}`, + created_at: new Date().toISOString(), + status: 'sending', + reminder_of: sourceCampaignId, + params: { channel: ch, from: from || '', type: 'event', event_id: eventId, template: event.email_template || 'festive', reminder_of: sourceCampaignId }, + recipients: r.candidates.map(x => ({ email: x.email, firstname: x.firstname || '', lastname: x.lastname || '', language: normLang(x.language), customer_id: x.customer_id || '', source: 'Rappel : ' + r.source.name, status: 'pending' })), + } + campaigns.saveCampaign(campaign) + setImmediate(() => sendEventCampaignAsync(id, eventId, ch, from, { isReminder: true }).catch(e => log('event reminder async: ' + e.message))) + return { campaign_id: id, count: campaign.recipients.length } +} + // ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ────── function splitName (full) { const parts = String(full || '').trim().split(/\s+/).filter(Boolean) @@ -1020,20 +1093,21 @@ async function handle (req, res, method, path, url) { return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] }) } - // Staff : GET /events//invite-preview?lang=fr|en[&template=] → HTML rendu (aperçu inbox) + // Staff : GET /events//invite-preview?lang=fr|en[&template=][&reminder=1] → HTML rendu (aperçu inbox) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/invite-preview$/) if (mm && method === 'GET') { const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' }) const lang = normLang(url.searchParams.get('lang') || 'fr') + const isReminder = url.searchParams.get('reminder') === '1' const sampleName = lang === 'en' ? 'Jean Smith' : 'Jean Tremblay' const sampleUrl = `${pub()}/rsvp/${event.id}` // ?template absent → modèle configuré de l'événement · ='' → forcer le festif · = → ce template (aperçu avant enregistrement) const override = url.searchParams.get('template') let html - if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl) - else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl) + if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl, isReminder) + else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl, isReminder) else { - html = require('./campaigns').renderNamedTemplate(override, inviteTemplateVars(event, lang, sampleName, sampleUrl)) + html = require('./campaigns').renderNamedTemplate(override, inviteTemplateVars(event, lang, sampleName, sampleUrl, isReminder)) if (!html) return json(res, 400, { error: 'template_not_found' }) } return json(res, 200, { html }) @@ -1077,7 +1151,7 @@ async function handle (req, res, method, path, url) { if (b.test) { const emails = [...new Set((b.test_emails || []).map(e => String(e).trim()).filter(e => EMAIL_RE.test(e)))].slice(0, 10) if (!emails.length) return json(res, 400, { error: 'no_test_emails' }) - const results = await sendTest({ eventId, emails, channel, from: b.from, lang: b.lang || 'fr', name: b.name }) + const results = await sendTest({ eventId, emails, channel, from: b.from, lang: b.lang || 'fr', name: b.name, reminder: !!b.reminder }) log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`) return json(res, 200, { ok: results.every(r => r.ok), test: true, results }) } @@ -1090,6 +1164,35 @@ async function handle (req, res, method, path, url) { } return json(res, 400, { error: 'bad_request' }) } + // Staff : GET /events//campaigns → liste des envois PASSÉS (envois originaux, sans les relances) + // pour peupler le sélecteur « campagne source » du Rappel. + mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/campaigns$/) + if (mm && method === 'GET') { + if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' }) + return json(res, 200, { campaigns: listEventCampaigns(mm[1]) }) + } + // Staff : GET /events//reminder-preview?campaign= → APERÇU (compte + ventilation + échantillon, ne persiste rien) + mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/reminder-preview$/) + if (mm && method === 'GET') { + 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) + 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 })) }) + } + // Staff : POST /events//reminder-send {campaign_id, channel, from} → lance la relance réelle (recompte les candidats à l'envoi) + mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/reminder-send$/) + if (mm && method === 'POST') { + const eventId = mm[1]; if (!getEvent(eventId)) return json(res, 404, { error: 'event_not_found' }) + 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 }) + 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 }) + } // Staff : POST /events//audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/) if (mm && method === 'POST') { @@ -1133,4 +1236,4 @@ async function handle (req, res, method, path, url) { } catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) } } -module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, massSend, sendEventCampaignAsync, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary } +module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, massSend, sendEventCampaignAsync, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary, listEventCampaigns, reminderCandidates, reminderSend }