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).
|
// 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 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' }) }
|
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: 'customer', label: 'Client lié', field: 'customer_name', align: 'left' },
|
||||||
{ name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' },
|
{ name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' },
|
||||||
{ name: 'status', label: 'Statut', field: 'status', 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' },
|
{ 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 counterFor (s) { return campaign.value?.counters?.[s] || 0 }
|
||||||
function statusColor (s) {
|
function statusColor (s) {
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,8 @@
|
||||||
|
|
||||||
<q-card-section class="q-pb-none">
|
<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"
|
<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>
|
</q-card-section>
|
||||||
|
|
||||||
<!-- TEST -->
|
<!-- 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="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-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-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>
|
</q-card-section>
|
||||||
|
|
||||||
<!-- MASSE -->
|
<!-- 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">
|
<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 ».
|
<q-icon name="mail" size="14px" /> Modèle : <b>{{ ev.email_template || 'Festif intégré (TARGO)' }}</b> — se change/conçoit dans « Modifier ».
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,11 +383,52 @@
|
||||||
</q-banner>
|
</q-banner>
|
||||||
</q-card-section>
|
</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-card-actions align="right">
|
||||||
<q-btn flat no-caps label="Fermer" v-close-popup />
|
<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-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" />
|
: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-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
@ -406,7 +449,7 @@ import {
|
||||||
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
|
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
|
||||||
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
|
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
|
||||||
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
|
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
|
||||||
getInvitePreview,
|
getInvitePreview, listEventCampaigns, previewReminder, sendReminder,
|
||||||
} from 'src/api/events'
|
} from 'src/api/events'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
@ -429,9 +472,19 @@ const showSend = ref(false)
|
||||||
const sendChannel = ref('mailjet')
|
const sendChannel = ref('mailjet')
|
||||||
const testEmail = ref('')
|
const testEmail = ref('')
|
||||||
const testLang = ref('fr')
|
const testLang = ref('fr')
|
||||||
|
const testReminder = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
||||||
const sendMode = ref('test')
|
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 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 })
|
||||||
|
|
@ -448,6 +501,7 @@ const listBusy = ref(false)
|
||||||
const showListSample = 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 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 eventOptions = computed(() => events.value.map(e => ({ value: e.id, label: e.name || e.id })))
|
||||||
const publicUrl = computed(() => ev.value.public_url || '')
|
const publicUrl = computed(() => ev.value.public_url || '')
|
||||||
const isFull = computed(() => ev.value.capacity > 0 && headcount.value >= ev.value.capacity)
|
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 }
|
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
|
||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
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' })
|
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') })
|
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 }
|
} 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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -777,7 +777,9 @@ function listCampaigns () {
|
||||||
const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'))
|
const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'))
|
||||||
return { id: c.id, name: c.name, created_at: c.created_at,
|
return { id: c.id, name: c.name, created_at: c.created_at,
|
||||||
status: c.status, counters: c.counters,
|
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 }
|
} catch { return null }
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
@ -2800,5 +2802,5 @@ module.exports = {
|
||||||
renderTemplate, renderNamedTemplate,
|
renderTemplate, renderNamedTemplate,
|
||||||
// Enregistrement de campagne réutilisable (lib/events.js crée une campagne pour le blast d'invitations :
|
// 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).
|
// 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',
|
foot: 'TARGO — votre fournisseur Internet local',
|
||||||
full_title: 'Inscriptions complètes',
|
full_title: 'Inscriptions complètes',
|
||||||
full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.",
|
full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.",
|
||||||
|
reminder_badge: 'Petit rappel',
|
||||||
|
reminder_subject_prefix: 'Petit rappel — ',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
f_name: 'Your name',
|
f_name: 'Your name',
|
||||||
|
|
@ -100,6 +102,8 @@ const LABELS = {
|
||||||
foot: 'TARGO — your local Internet provider',
|
foot: 'TARGO — your local Internet provider',
|
||||||
full_title: 'Registration full',
|
full_title: 'Registration full',
|
||||||
full_msg: 'Unfortunately, the event booking is 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))}` : ''
|
const l = lang ? `&lang=${encodeURIComponent(normLang(lang))}` : ''
|
||||||
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}${l}`
|
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 ''
|
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.
|
// 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]
|
const t = e[lang]
|
||||||
return {
|
return {
|
||||||
firstname: firstName(name), name: name || '', rsvp_url: rsvpUrl, gift_url: rsvpUrl, // gift_url = alias (templates cadeau réutilisables)
|
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,
|
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,
|
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é.
|
// 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 ''
|
const e = getEvent(eventId); if (!e) return ''
|
||||||
lang = normLang(lang)
|
lang = normLang(lang)
|
||||||
if (e.email_template) {
|
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
|
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.
|
// 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]
|
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 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(' ')
|
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>`
|
+ `<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>')
|
+ (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>`
|
+ `<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>`
|
+ `<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>`
|
+ `<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>`
|
+ `<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) ──
|
// ── 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 event = getEvent(eventId)
|
||||||
const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both')
|
const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both')
|
||||||
const results = []
|
const results = []
|
||||||
for (const em of emails) {
|
for (const em of emails) {
|
||||||
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24, lang)
|
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24, lang)
|
||||||
const html = inviteEmail(eventId, lang, name, link)
|
const html = inviteEmail(eventId, lang, name, link, !!reminder)
|
||||||
const subject = '[TEST] ' + inviteSubject(eventId, lang)
|
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 }) }
|
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 }) }
|
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 }
|
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 campaigns = require('./campaigns')
|
||||||
const c = campaigns.loadCampaign(id); if (!c) return
|
const c = campaigns.loadCampaign(id); if (!c) return
|
||||||
const event = getEvent(eventId); if (!event) 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 name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim()
|
||||||
const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang)
|
const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang)
|
||||||
r.rsvp_url = rsvpUrl
|
r.rsvp_url = rsvpUrl
|
||||||
const html = inviteEmail(eventId, lang, name, rsvpUrl)
|
const html = inviteEmail(eventId, lang, name, rsvpUrl, isReminder)
|
||||||
const subject = inviteSubject(eventId, lang)
|
const subject = inviteSubject(eventId, lang, isReminder)
|
||||||
const customId = id + ':' + i
|
const customId = id + ':' + i
|
||||||
try {
|
try {
|
||||||
const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId })
|
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 })
|
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 ──────
|
// ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ──────
|
||||||
function splitName (full) {
|
function splitName (full) {
|
||||||
const parts = String(full || '').trim().split(/\s+/).filter(Boolean)
|
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]] })
|
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$/)
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/invite-preview$/)
|
||||||
if (mm && method === 'GET') {
|
if (mm && method === 'GET') {
|
||||||
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
||||||
const lang = normLang(url.searchParams.get('lang') || 'fr')
|
const lang = normLang(url.searchParams.get('lang') || 'fr')
|
||||||
|
const isReminder = url.searchParams.get('reminder') === '1'
|
||||||
const sampleName = lang === 'en' ? 'Jean Smith' : 'Jean Tremblay'
|
const sampleName = lang === 'en' ? 'Jean Smith' : 'Jean Tremblay'
|
||||||
const sampleUrl = `${pub()}/rsvp/${event.id}`
|
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)
|
// ?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')
|
const override = url.searchParams.get('template')
|
||||||
let html
|
let html
|
||||||
if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl)
|
if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl, isReminder)
|
||||||
else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl)
|
else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl, isReminder)
|
||||||
else {
|
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' })
|
if (!html) return json(res, 400, { error: 'template_not_found' })
|
||||||
}
|
}
|
||||||
return json(res, 200, { html })
|
return json(res, 200, { html })
|
||||||
|
|
@ -1077,7 +1151,7 @@ async function handle (req, res, method, path, url) {
|
||||||
if (b.test) {
|
if (b.test) {
|
||||||
const emails = [...new Set((b.test_emails || []).map(e => String(e).trim()).filter(e => EMAIL_RE.test(e)))].slice(0, 10)
|
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' })
|
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`)
|
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 })
|
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' })
|
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)
|
// 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$/)
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/)
|
||||||
if (mm && method === 'POST') {
|
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 }) }
|
} 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