Events: editable (Unlayer) email template, "Tous" includes résiliés, realtime picker

- Email template: an event can use an editable campaign-style template (Unlayer) for
  its invitation instead of the built-in festive builder. Config field email_template
  ('' = festive default/fallback). inviteEmail() renders the named template via
  campaigns.renderNamedTemplate with {{firstname}}/{{rsvp_url}} (+ event vars), else
  festive. campaigns.js: add 'event-' template prefix + export renderNamedTemplate.
  New GET /events/:id/invite-preview (festive | configured | override) for live preview.
  Editor gains a template selector, "design in editor" link, and an inbox preview dialog.
- Audience "Tous (incl. résiliés)": filters.include_resiliated skips the résilié guard
  for win-back; "Clients actifs" still excludes them.
- Manual picker: realtime fuzzy search into a results list (add per row), separate pool,
  then add-batch — replaces the combobox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 11:05:31 -04:00
parent 3aa59adec3
commit d26722f048
4 changed files with 167 additions and 40 deletions

View File

@ -27,6 +27,13 @@ export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${enc
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) } export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) } export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) }
// Aperçu HTML de l'invitation (gabarit festif OU template éditable). template: undefined=modèle configuré, ''=festif, '<nom>'=ce template.
export async function getInvitePreview (eventId, { lang = 'fr', template } = {}) {
const qs = new URLSearchParams({ lang })
if (template !== undefined) qs.set('template', template)
return hubFetch(`/events/${encodeURIComponent(eventId)}/invite-preview?${qs.toString()}`)
}
// Recherche FLOUE de clients (pg_trgm, typo/accent-tolérante) — réutilise l'endpoint app-wide. // Recherche FLOUE de clients (pg_trgm, typo/accent-tolérante) — réutilise l'endpoint app-wide.
export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) } export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) }

View File

@ -180,6 +180,22 @@
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
<!-- Modèle de courriel d'invitation festif intégré OU template éditable (Unlayer, comme les campagnes) -->
<q-separator class="q-my-md" />
<div class="text-weight-medium text-grey-8 q-mb-xs"><q-icon name="mail" size="18px" /> Modèle du courriel d'invitation</div>
<q-select v-model="form.email_template" :options="emailTemplateOptions" emit-value map-options dense outlined
label="Modèle" hint="« Festif intégré » = charte TARGO verrouillée. Un template éditable se conçoit dans l'éditeur (glisser-déposer)." />
<div class="row q-gutter-sm q-mt-xs">
<template v-if="formMode === 'edit'">
<q-btn outline no-caps size="sm" color="primary" icon="visibility" label="Aperçu" :loading="previewLoading" @click="openInvitePreview" />
<q-btn v-if="form.email_template" flat no-caps size="sm" color="primary" icon="brush" label="Concevoir dans l'éditeur"
type="a" :href="'/ops/#/campaigns/templates/' + form.email_template" target="_blank">
<q-tooltip>Ouvre l'éditeur glisser-déposer (nouvel onglet). Variables : firstname, rsvp_url.</q-tooltip>
</q-btn>
</template>
<div v-else class="text-caption text-grey-6">Aperçu et conception disponibles après la création.</div>
</div>
<!-- Pièces jointes (courriel) seulement en édition (l'événement doit exister) --> <!-- Pièces jointes (courriel) seulement en édition (l'événement doit exister) -->
<template v-if="formMode === 'edit'"> <template v-if="formMode === 'edit'">
<q-separator class="q-my-md" /> <q-separator class="q-my-md" />
@ -213,6 +229,22 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Aperçu de l'invitation (rendu HTML, échantillon de variables) -->
<q-dialog v-model="invitePreviewOpen" maximized>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>Aperçu de l'invitation</q-toolbar-title>
<q-btn-toggle v-model="invitePreviewLang" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ label: 'FR', value: 'fr' }, { label: 'EN', value: 'en' }]" @update:model-value="loadInvitePreview" class="q-mr-sm" />
<q-btn flat dense round icon="close" v-close-popup />
</q-toolbar>
<q-card-section style="height:calc(100vh - 60px);overflow:hidden">
<iframe :srcdoc="invitePreviewHtml" style="width:100%;height:100%;border:1px solid #e5e7eb;background:#fff" />
</q-card-section>
</q-card>
</q-dialog>
<!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) --> <!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) -->
<q-dialog v-model="showSend"> <q-dialog v-model="showSend">
<q-card style="min-width:340px;max-width:560px;width:100%"> <q-card style="min-width:340px;max-width:560px;width:100%">
@ -236,7 +268,9 @@
<!-- MASSE --> <!-- MASSE -->
<q-card-section v-else class="q-gutter-sm"> <q-card-section v-else class="q-gutter-sm">
<q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" /> <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>
<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' }]" />
<div class="text-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div> <div class="text-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div>
<div v-if="attachments.length" class="text-caption text-grey-7"><q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) gérées dans « Modifier ».</div> <div v-if="attachments.length" class="text-caption text-grey-7"><q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) gérées dans « Modifier ».</div>
@ -273,7 +307,7 @@
<div v-if="audienceSource === 'list'" class="q-gutter-sm"> <div v-if="audienceSource === 'list'" class="q-gutter-sm">
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" /> <q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous (incl. résiliés)', value: '' }]" dense outlined emit-value map-options label="Statut" />
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" /> <q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" />
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
@ -293,24 +327,30 @@
</div> </div>
<div v-else-if="audienceSource === 'manual'" class="q-gutter-sm"> <div v-else-if="audienceSource === 'manual'" class="q-gutter-sm">
<q-select <q-input v-model="poolQuery" dense outlined clearable debounce="300" :loading="poolSearching"
v-model="poolPick" dense outlined use-input hide-selected fill-input input-debounce="300" label="Rechercher un client (nom, n°, courriel)…" @update:model-value="onPoolSearch">
label="Rechercher un client (nom, n°, courriel)…"
:options="poolResults" :loading="poolSearching" option-label="label" option-value="customer_id"
@filter="onPoolFilter" @update:model-value="addToPool" hide-dropdown-icon>
<template #prepend><q-icon name="person_search" /></template> <template #prepend><q-icon name="person_search" /></template>
<template #no-option><q-item><q-item-section class="text-grey-6">{{ poolSearching ? 'Recherche…' : 'Tapez pour rechercher' }}</q-item-section></q-item></template> </q-input>
<template #option="scope"> <!-- Résultats en temps réel : cliquer « + » ajoute au lot, la liste reste affichée -->
<q-item v-bind="scope.itemProps"> <q-list v-if="poolResults.length" bordered separator class="rounded-borders" style="max-height:200px;overflow:auto">
<q-item-section> <q-item v-for="m in poolResults" :key="m.customer_id" dense>
<q-item-label>{{ scope.opt.customer_name || scope.opt.customer_id }}<q-badge v-if="scope.opt.matched" dense color="grey-4" text-color="grey-8" :label="scope.opt.matched" class="q-ml-xs" /></q-item-label> <q-item-section>
<q-item-label caption>{{ scope.opt.customer_id }}<span v-if="scope.opt.email"> · {{ scope.opt.email }}</span><span v-if="!scope.opt.email" class="text-orange-8"> · sans courriel</span></q-item-label> <q-item-label>{{ m.customer_name }}<q-badge v-if="m.matched" dense color="grey-4" text-color="grey-8" :label="m.matched" class="q-ml-xs" /></q-item-label>
</q-item-section> <q-item-label caption>{{ m.customer_id }}<span v-if="m.email"> · {{ m.email }}</span><span v-else class="text-orange-8"> · sans courriel</span></q-item-label>
</q-item> </q-item-section>
</template> <q-item-section side>
</q-select> <q-btn flat dense round size="sm" :icon="inPool(m) ? 'check' : 'add'" :color="inPool(m) ? 'green-7' : 'primary'" :disable="inPool(m)" @click="addToPool(m)">
<div v-if="pool.length" class="q-gutter-xs"> <q-tooltip>{{ inPool(m) ? 'Déjà dans le lot' : 'Ajouter au lot' }}</q-tooltip>
<q-list bordered separator class="rounded-borders" style="max-height:170px;overflow:auto"> </q-btn>
</q-item-section>
</q-item>
</q-list>
<div v-else-if="(poolQuery || '').trim().length >= 2 && !poolSearching" class="text-caption text-grey-6">Aucun résultat pour « {{ poolQuery }} ».</div>
<div v-else-if="!pool.length" class="text-caption text-grey-6">Tapez au moins 2 caractères. Recherche floue (tolère les fautes et accents).</div>
<div v-if="pool.length" class="q-gutter-xs q-pt-xs">
<div class="text-caption text-weight-medium text-grey-8">Lot sélectionné : {{ pool.length }}</div>
<q-list bordered separator class="rounded-borders" style="max-height:150px;overflow:auto">
<q-item v-for="p in pool" :key="p.customer_id" dense> <q-item v-for="p in pool" :key="p.customer_id" dense>
<q-item-section> <q-item-section>
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label> <q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
@ -321,7 +361,6 @@
</q-list> </q-list>
<q-btn unelevated no-caps color="primary" icon="playlist_add" :label="`Ajouter les ${pool.length} sélectionné(s) à la liste`" :loading="listBusy" @click="addManualToList" /> <q-btn unelevated no-caps color="primary" icon="playlist_add" :label="`Ajouter les ${pool.length} sélectionné(s) à la liste`" :loading="listBusy" @click="addManualToList" />
</div> </div>
<div v-else class="text-caption text-grey-6">Cherchez un client et cliquez pour le sélectionner, puis ajoutez-les à la liste d'envoi.</div>
</div> </div>
<div v-else class="q-gutter-sm"> <div v-else class="q-gutter-sm">
@ -361,6 +400,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,
} from 'src/api/events' } from 'src/api/events'
const $q = useQuasar() const $q = useQuasar()
@ -386,15 +426,14 @@ const testLang = ref('fr')
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')
const template = ref('')
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 })
const csvFile = ref(null) const csvFile = ref(null)
const audCsvText = ref('') const audCsvText = ref('')
// Pool manuel : clients sélectionnés avant ajout à la liste // Pool manuel : recherche temps réel résultats sélection avant ajout à la liste
const pool = ref([]) const pool = ref([])
const poolPick = ref(null) const poolQuery = ref('')
const poolResults = ref([]) const poolResults = ref([])
const poolSearching = ref(false) const poolSearching = ref(false)
// Liste principale (additive, persistée côté hub) // Liste principale (additive, persistée côté hub)
@ -501,13 +540,26 @@ function attLangLabel (l) { return l === 'fr' ? 'FR' : l === 'en' ? 'EN' : 'Les
function attLangColor (l) { return l === 'fr' ? 'blue-7' : l === 'en' ? 'deep-orange-7' : 'grey-6' } function attLangColor (l) { return l === 'fr' ? 'blue-7' : l === 'en' ? 'deep-orange-7' : 'grey-6' }
const emptyLang = () => ({ name: '', tagline: '', when: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] }) const emptyLang = () => ({ name: '', tagline: '', when: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, fr: emptyLang(), en: emptyLang() }) const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, email_template: '', fr: emptyLang(), en: emptyLang() })
const form = ref(emptyForm()) const form = ref(emptyForm())
// Options du sélecteur de modèle : festif intégré + templates éditables (/campaigns/templates)
const emailTemplateOptions = computed(() => {
const opts = templates.value.map(t => ({ value: t.value, label: t.value === '' ? 'Modèle festif intégré (TARGO)' : t.label }))
if (!opts.some(o => o.value === '')) opts.unshift({ value: '', label: 'Modèle festif intégré (TARGO)' })
return opts
})
// Aperçu de l'invitation
const invitePreviewOpen = ref(false)
const invitePreviewLang = ref('fr')
const invitePreviewHtml = ref('')
const previewLoading = ref(false)
function openCreate () { function openCreate () {
form.value = emptyForm() form.value = emptyForm()
formMode.value = 'create' formMode.value = 'create'
formLang.value = 'fr' formLang.value = 'fr'
if (templates.value.length <= 1) loadTemplates()
showForm.value = true showForm.value = true
} }
async function openEdit () { async function openEdit () {
@ -522,6 +574,7 @@ async function openEdit () {
f.badge_top = c.badge_top || '' f.badge_top = c.badge_top || ''
f.badge_bottom = c.badge_bottom || '' f.badge_bottom = c.badge_bottom || ''
f.capacity = c.capacity || 0 f.capacity = c.capacity || 0
f.email_template = c.email_template || ''
for (const lg of ['fr', 'en']) { for (const lg of ['fr', 'en']) {
const src = c[lg] || {} const src = c[lg] || {}
f[lg] = { f[lg] = {
@ -534,9 +587,18 @@ async function openEdit () {
form.value = f form.value = f
formMode.value = 'edit' formMode.value = 'edit'
formLang.value = 'fr' formLang.value = 'fr'
if (templates.value.length <= 1) loadTemplates()
showForm.value = true showForm.value = true
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
} }
async function loadInvitePreview () {
previewLoading.value = true
try {
const r = await getInvitePreview(form.value.id, { lang: invitePreviewLang.value, template: form.value.email_template })
invitePreviewHtml.value = r.html || ''
} catch (e) { $q.notify({ type: 'negative', message: 'Aperçu impossible : ' + e.message }); invitePreviewHtml.value = '' } finally { previewLoading.value = false }
}
async function openInvitePreview () { invitePreviewOpen.value = true; await loadInvitePreview() }
function cleanLang (c) { function cleanLang (c) {
return { return {
@ -557,6 +619,7 @@ async function saveForm () {
badge_top: form.value.badge_top, badge_top: form.value.badge_top,
badge_bottom: form.value.badge_bottom, badge_bottom: form.value.badge_bottom,
capacity: form.value.capacity, capacity: form.value.capacity,
email_template: form.value.email_template || '',
fr: cleanLang(form.value.fr), fr: cleanLang(form.value.fr),
...(enHasContent ? { en: cleanLang(form.value.en) } : {}), ...(enHasContent ? { en: cleanLang(form.value.en) } : {}),
} }
@ -634,24 +697,24 @@ function onCsvFile (file) {
} }
function onActiveSubToggle (v) { if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs function onActiveSubToggle (v) { if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
// Pool manuel : recherche + sélection avant ajout // Pool manuel : recherche floue temps réel résultats en liste sélection
function onPoolFilter (val, update) { function onPoolSearch (val) {
const q = (val || '').trim() const q = (val || '').trim()
if (q.length < 2) { update(() => { poolResults.value = [] }); return } if (q.length < 2) { poolResults.value = []; return }
poolSearching.value = true poolSearching.value = true
// Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »). // Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »).
searchCustomersFuzzy(q) searchCustomersFuzzy(q)
.then(d => update(() => { .then(d => {
poolResults.value = (d.matches || []) poolResults.value = (d.matches || [])
.filter(m => m.name && m.kind !== 'équipe') // clients seulement .filter(m => m.name && m.kind !== 'équipe') // clients seulement
.map(m => ({ customer_id: m.name, customer_name: m.customer_name || m.name, email: (m.email || '').split(/[;,]/)[0].trim(), language: m.language || '', matched: m.matched || '', label: (m.customer_name || m.name) })) .map(m => ({ customer_id: m.name, customer_name: m.customer_name || m.name, email: (m.email || '').split(/[;,]/)[0].trim(), language: m.language || '', matched: m.matched || '' }))
})) })
.catch(() => update(() => { poolResults.value = [] })) .catch(() => { poolResults.value = [] })
.finally(() => { poolSearching.value = false }) .finally(() => { poolSearching.value = false })
} }
function inPool (m) { return pool.value.some(p => p.customer_id === m.customer_id) }
function addToPool (sel) { function addToPool (sel) {
if (sel && sel.customer_id && !pool.value.some(p => p.customer_id === sel.customer_id)) pool.value.push(sel) if (sel && sel.customer_id && !pool.value.some(p => p.customer_id === sel.customer_id)) pool.value.push(sel)
poolPick.value = null; poolResults.value = []
} }
function removeFromPool (p) { pool.value = pool.value.filter(x => x.customer_id !== p.customer_id) } function removeFromPool (p) { pool.value = pool.value.filter(x => x.customer_id !== p.customer_id) }
@ -675,11 +738,14 @@ function filterLabel () {
if (f.name_like) p.push('Nom~' + f.name_like) if (f.name_like) p.push('Nom~' + f.name_like)
if (f.active_sub) p.push(f.min_monthly > 0 ? `Abo ≥${f.min_monthly}$` : 'Abo actif') if (f.active_sub) p.push(f.min_monthly > 0 ? `Abo ≥${f.min_monthly}$` : 'Abo actif')
if (f.statut === 'active' && !p.length) p.push('Clients actifs') if (f.statut === 'active' && !p.length) p.push('Clients actifs')
else if (f.statut !== 'active') p.push('incl. résiliés')
return p.join(' · ') || 'Filtre' return p.join(' · ') || 'Filtre'
} }
async function addFilterToList () { async function addFilterToList () {
listBusy.value = true listBusy.value = true
try { applyListResult(await audienceListAdd(eventId.value, { mode: 'filter', filters: audFilters.value, source_label: filterLabel() }), 'add') } // « Tous » (statut vide) autorise les ex-clients résiliés ; « Clients actifs » les exclut.
const filters = { ...audFilters.value, include_resiliated: audFilters.value.statut !== 'active' }
try { applyListResult(await audienceListAdd(eventId.value, { mode: 'filter', filters, source_label: filterLabel() }), 'add') }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
} }
async function addManualToList () { async function addManualToList () {

View File

@ -825,6 +825,16 @@ function renderTemplate (tpl, vars) {
// Templates are bundled inside the hub at services/targo-hub/templates/. // Templates are bundled inside the hub at services/targo-hub/templates/.
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case. // Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates') const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
// Rend un template éditable (Unlayer/HTML) par NOM avec des variables Mustache.
// Réutilisé par lib/events.js (invitation d'événement). Renvoie null si nom invalide/absent
// (l'appelant retombe sur son gabarit intégré). Anti-LFI : nom validé par l'allow-list.
function renderNamedTemplate (name, vars = {}) {
if (!isValidTemplateName(name)) return null
const file = path.join(TEMPLATES_DIR, name + '.html')
if (!fs.existsSync(file)) return null
try { return renderTemplate(fs.readFileSync(file, 'utf8'), vars) } catch { return null }
}
const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html') const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
// Allow-list templates that can be edited via the API. Naming convention: // Allow-list templates that can be edited via the API. Naming convention:
@ -836,7 +846,7 @@ const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
// + disk-scan approach so the user can create new templates from the editor // + disk-scan approach so the user can create new templates from the editor
// UI without us re-deploying the hub. The prefix list bounds what names are // UI without us re-deploying the hub. The prefix list bounds what names are
// allowed (prevents arbitrary file writes outside the campaign domain). // allowed (prevents arbitrary file writes outside the campaign domain).
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-'] const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-', 'event-']
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
function isValidTemplateName (name) { function isValidTemplateName (name) {
@ -2787,5 +2797,5 @@ module.exports = {
// Exposed for testing // Exposed for testing
parseCsv, parseMapCsv, parseGiftbitCsv, parseCsv, parseMapCsv, parseGiftbitCsv,
matchCustomer, normalizeCivic, normalizePhone, normalizePostal, matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
renderTemplate, renderTemplate, renderNamedTemplate,
} }

View File

@ -182,6 +182,7 @@ function buildEvent (base) {
badge_top: String(base.badge_top || ''), badge_top: String(base.badge_top || ''),
badge_bottom: String(base.badge_bottom || ''), badge_bottom: String(base.badge_bottom || ''),
capacity: Math.max(0, parseInt(base.capacity, 10) || 0), capacity: Math.max(0, parseInt(base.capacity, 10) || 0),
email_template: String(base.email_template || ''), // '' = gabarit festif intégré ; sinon nom d'un template éditable (Unlayer)
attachments: Array.isArray(base.attachments) ? base.attachments : [], attachments: Array.isArray(base.attachments) ? base.attachments : [],
fr: content('fr'), fr: content('fr'),
en: content('en'), en: content('en'),
@ -242,6 +243,7 @@ function normalizeConfig (b, existing = null) {
badge_top: String(b.badge_top || '').slice(0, 6), badge_top: String(b.badge_top || '').slice(0, 6),
badge_bottom: String(b.badge_bottom || '').slice(0, 8), badge_bottom: String(b.badge_bottom || '').slice(0, 8),
capacity: Math.max(0, parseInt(b.capacity, 10) || 0), capacity: Math.max(0, parseInt(b.capacity, 10) || 0),
email_template: /^[a-z0-9-]+$/.test(String(b.email_template || '')) ? String(b.email_template) : '',
attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [], attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [],
fr, fr,
...(enRaw ? { en: enRaw } : {}), ...(enRaw ? { en: enRaw } : {}),
@ -305,8 +307,27 @@ function inviteSubject (eventId, lang) {
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} 🎉` return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
} }
// Variables Mustache offertes aux templates éditables d'invitation d'événement.
function inviteTemplateVars (e, lang, name, rsvpUrl) {
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, body: t.body, closing: t.closing,
year: String(new Date().getFullYear()), lang,
}
}
// Invitation : modèle éditable (Unlayer) si configuré, sinon gabarit festif intégré.
function inviteEmail (eventId, lang, name, rsvpUrl) { function inviteEmail (eventId, lang, name, rsvpUrl) {
const e = getEvent(eventId); if (!e) return '' 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))
if (html) return html // template introuvable → repli festif
}
return inviteEmailFestive(e, lang, name, rsvpUrl)
}
// Gabarit festif intégré (charte TARGO garantie) — utilisé par défaut et en repli.
function inviteEmailFestive (e, lang, name, rsvpUrl) {
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(' ')
@ -537,10 +558,14 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: AUD_CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 } for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: AUD_CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
} }
if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 }) if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions) // GARDE-FOU par défaut : exclure les comptes F résiliés (abos fantômes, cf feedback_ghost_active_subscriptions).
const before = customers.length // Sauf si `include_resiliated` (choisi via « Tous ») → on autorise les ex-clients (relance / win-back).
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id))) if (!filters.include_resiliated) {
resiliatedExcluded = before - customers.length const resil = await resiliatedSet()
const before = customers.length
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
resiliatedExcluded = before - customers.length
}
rows = customers.map(mapCustomerRow) rows = customers.map(mapCustomerRow)
} }
const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0 const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
@ -892,12 +917,31 @@ 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)
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 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)
else {
html = require('./campaigns').renderNamedTemplate(override, inviteTemplateVars(event, lang, sampleName, sampleUrl))
if (!html) return json(res, 400, { error: 'template_not_found' })
}
return json(res, 200, { html })
}
// Staff : GET /events/<id>/rsvps // Staff : GET /events/<id>/rsvps
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
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 data = listRsvps(mm[1]) const data = listRsvps(mm[1])
return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, capacity: event.capacity, attachments: event.attachments || [], public_url: `${pub()}/rsvp/${event.id}` }, ...data }) return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, capacity: event.capacity, email_template: event.email_template || '', attachments: event.attachments || [], public_url: `${pub()}/rsvp/${event.id}` }, ...data })
} }
// Staff : DELETE /events/<id>/rsvps/<key> // Staff : DELETE /events/<id>/rsvps/<key>
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)