Events: reminder ("Petit rappel") for non-registered invitees
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
11ca7258f3
commit
d961c57f9e
|
|
@ -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 }) }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -258,7 +258,8 @@
|
|||
|
||||
<q-card-section class="q-pb-none">
|
||||
<q-btn-toggle v-model="sendMode" spread no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||
:options="[{ label: 'Test à moi', value: 'test' }, { label: 'Envoi de masse', value: 'mass' }]" />
|
||||
:options="[{ label: 'Test à moi', value: 'test' }, { label: 'Envoi de masse', value: 'mass' }, { label: 'Rappel', value: 'reminder' }]"
|
||||
@update:model-value="onSendModeChange" />
|
||||
</q-card-section>
|
||||
|
||||
<!-- TEST -->
|
||||
|
|
@ -267,10 +268,11 @@
|
|||
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
||||
<q-btn-toggle v-model="testLang" spread no-caps dense :options="[{ label: 'Français', value: 'fr' }, { label: 'English', value: 'en' }]" />
|
||||
<q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" autofocus />
|
||||
<q-toggle v-model="testReminder" label="Aperçu « Petit rappel » (badge + sujet préfixé)" />
|
||||
</q-card-section>
|
||||
|
||||
<!-- MASSE -->
|
||||
<q-card-section v-else class="q-gutter-sm">
|
||||
<q-card-section v-else-if="sendMode === 'mass'" class="q-gutter-sm">
|
||||
<div class="text-caption text-grey-7">
|
||||
<q-icon name="mail" size="14px" /> Modèle : <b>{{ ev.email_template || 'Festif intégré (TARGO)' }}</b> — se change/conçoit dans « Modifier ».
|
||||
</div>
|
||||
|
|
@ -381,11 +383,52 @@
|
|||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<!-- RAPPEL : relance des invités d'un envoi passé qui n'ont pas encore répondu -->
|
||||
<q-card-section v-else class="q-gutter-sm">
|
||||
<div class="text-caption text-grey-7"><q-icon name="notifications" size="14px" /> Relance « <b>{{ reminderBadgeLabel }}</b> » aux invités d'un envoi passé qui n'ont <b>pas cliqué</b> ou <b>pas encore inscrit</b>.</div>
|
||||
<q-select v-model="reminderCampaignId" :options="eventCampaignOptions" emit-value map-options dense outlined
|
||||
label="Envoi source" :loading="loadingEventCampaigns" @update:model-value="loadReminderPreview" />
|
||||
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
||||
|
||||
<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>
|
||||
<template v-else-if="reminderPreview">
|
||||
<q-banner dense rounded class="bg-orange-1 text-orange-10">
|
||||
<b>{{ reminderPreview.count }}</b> destinataire(s) éligible(s) à la relance (sur {{ reminderPreview.source.total }} envoyé(s) au départ).
|
||||
<div class="row q-col-gutter-xs q-mt-xs">
|
||||
<q-chip dense square color="grey-3" text-color="grey-9">Pas ouvert · {{ reminderPreview.breakdown.not_opened }}</q-chip>
|
||||
<q-chip dense square color="blue-1" text-color="blue-10">Ouvert, pas cliqué · {{ reminderPreview.breakdown.opened_not_clicked }}</q-chip>
|
||||
<q-chip dense square color="deep-orange-1" text-color="deep-orange-9">Cliqué, pas inscrit · {{ reminderPreview.breakdown.clicked_not_registered }}</q-chip>
|
||||
</div>
|
||||
</q-banner>
|
||||
<div v-if="reminderPreview.count" class="q-mt-xs">
|
||||
<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-item v-for="s in reminderPreview.sample" :key="s.email" dense>
|
||||
<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 caption>
|
||||
{{ s.email }} · {{ s.status }}
|
||||
<span v-if="s.opened_at"> · ouvert {{ fmtDate(s.opened_at) }}</span>
|
||||
<span v-if="s.clicked_at"> · cliqué {{ fmtDate(s.clicked_at) }}</span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</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-list>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="text-caption text-grey-6">Choisissez un envoi source ci-dessus pour voir qui serait relancé.</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat no-caps label="Fermer" v-close-popup />
|
||||
<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 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" />
|
||||
<q-btn v-else unelevated no-caps color="warning" icon="notifications" :disable="!reminderPreview?.count || sendingReminder" :loading="sendingReminder"
|
||||
:label="reminderPreview?.count ? `Envoyer le rappel à ${reminderPreview.count}` : 'Aucun destinataire'" @click="confirmReminderSend" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
@ -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 » à <b>${n}</b> destinataire(s) qui n'ont pas encore répondu, via <b>${chan}</b> ?<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(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)
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<id>:<index>` → webhook existant, report.csv, /campaigns list/détail, SSE).
|
||||
newCampaignId, loadCampaign, saveCampaign,
|
||||
newCampaignId, loadCampaign, saveCampaign, listCampaigns,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
|
||||
+ (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>')
|
||||
+ `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0 22px"></td></tr>`
|
||||
+ (isReminder ? `<tr><td align="center" style="padding:0 30px 6px"><span style="display:inline-block;background:#f59e0b;color:#fff;font-weight:800;font-size:11px;letter-spacing:1px;text-transform:uppercase;padding:5px 14px;border-radius:999px">🔔 ${esc(t.reminder_badge)}</span></td></tr>` : '')
|
||||
+ `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>`
|
||||
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>`
|
||||
+ `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
|
||||
|
|
@ -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/<id>/invite-preview?lang=fr|en[&template=<name>] → HTML rendu (aperçu inbox)
|
||||
// Staff : GET /events/<id>/invite-preview?lang=fr|en[&template=<name>][&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 · =<nom> → 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/<id>/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/<id>/reminder-preview?campaign=<id> → 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/<id>/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/<id>/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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user