Events: creation, capacity cap, language-tagged attachments, additive audience list

Ops event page overhaul (hub lib/events.js + email.js, ops EventsPage.vue + api):

- Create/edit/delete events (persisted config shadowing the seeded SEED_CONTENT);
  labels factored into shared LABELS, content editable bilingual (EN falls back to FR).
- Capacity cap by headcount: public RSVP form closes with a "booking is full" message
  when reached (server-enforced); existing registrants can still update.
- Email attachments (image/PDF, <=4MB, <=6), tagged per language (fr/en/both) so each
  customer gets files in their account language; optional Gemini auto-detect on images.
  email.sendEmail gains a generic attachments[] param.
- Additive main audience list (persisted per event): build one deduped list by appending
  batches from filters, manual picks, or CSV; per-source breakdown, remove/clear.
- Filters: name-contains (customer_name like) and city (Service Location.city, resolved
  accent/case-insensitively via PG with REST fallback), intersected with other criteria.
- Manual picker + fuzzy search reuse app-wide /collab/customer-search (pg_trgm), so
  "repa" finds "Réparation …".
- nginx /hub/ proxy: client_max_body_size 8m for base64 attachment uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 10:10:43 -04:00
parent 893ac8cd4a
commit 3aa59adec3
5 changed files with 961 additions and 148 deletions

View File

@ -50,6 +50,7 @@ server {
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
client_max_body_size 8m; # pièces jointes événement (image/PDF base64, ~5.5m pour 4m de binaire)
} }
# SPA fallback all routes serve index.html # SPA fallback all routes serve index.html

View File

@ -26,4 +26,25 @@ export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURI
export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) } export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) }
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 }) }
// 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)}`) }
// Liste principale d'audience (additive, persistée par événement).
export async function getAudienceList (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list`) }
export async function audienceListAdd (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list/add`, { method: 'POST', body }) }
export async function audienceListRemove (eventId, email) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list/remove`, { method: 'POST', body: { email } }) }
export async function audienceListClear (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list`, { method: 'DELETE' }) }
export async function listTemplates () { return hubFetch('/campaigns/templates') } export async function listTemplates () { return hubFetch('/campaigns/templates') }
// Config brute éditable d'un événement (pour le formulaire d'édition).
export async function getEventConfig (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}`) }
// Création / édition / suppression d'un événement (config persistée côté hub).
export async function createEvent (body) { return hubFetch('/events', { method: 'POST', body }) }
export async function updateEvent (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}`, { method: 'PUT', body }) }
export async function deleteEvent (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}`, { method: 'DELETE' }) }
// 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' }) }

View File

@ -2,24 +2,35 @@
<q-page class="q-pa-md"> <q-page class="q-pa-md">
<PageHeader title="Événements" /> <PageHeader title="Événements" />
<!-- Bandeau événement --> <!-- Bandeau événement + sélecteur -->
<div class="ops-card q-pa-md q-mb-md" style="border-radius:12px"> <div class="ops-card q-pa-md q-mb-md" style="border-radius:12px">
<div class="row items-center ops-row-wrap q-gutter-sm"> <div class="row items-center ops-row-wrap q-gutter-sm">
<div class="ev-badge"><b>20</b><span>ANS</span></div> <div class="ev-badge">
<div class="col" style="min-width:0"> <template v-if="ev.badge_top || ev.badge_bottom"><b>{{ ev.badge_top || '🎉' }}</b><span v-if="ev.badge_bottom">{{ ev.badge_bottom }}</span></template>
<div class="text-h6 text-weight-bold ellipsis">{{ ev.name || 'Targo fête ses 20 ans' }}</div> <span v-else style="font-size:1.4rem">🎉</span>
</div>
<div class="col" style="min-width:180px">
<q-select v-if="events.length > 1" dense borderless v-model="eventId" :options="eventOptions" emit-value map-options
class="text-h6 text-weight-bold" @update:model-value="onSelectEvent" popup-content-class="text-body1" />
<div v-else class="text-h6 text-weight-bold ellipsis">{{ ev.name || '—' }}</div>
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
<q-icon name="event" size="16px" class="q-mr-xs" />{{ ev.when || '1ᵉʳ août, de 10 h à 15 h' }} <q-icon name="event" size="16px" class="q-mr-xs" />{{ ev.when || '—' }}
<span v-if="ev.capacity" class="q-ml-sm"><q-icon name="groups" size="16px" class="q-mr-xs" />plafond {{ ev.capacity }} pers.</span>
</div> </div>
</div> </div>
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn> <q-btn flat dense no-caps icon="edit" label="Modifier" @click="openEdit" class="col-auto" :disable="!eventId" />
<q-btn unelevated dense no-caps color="primary" icon="add" label="Nouvel événement" @click="openCreate" class="col-auto" />
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div> </div>
</div> </div>
<!-- KPIs --> <!-- KPIs -->
<div class="row q-col-gutter-sm q-mb-md"> <div class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-4"><StatCard label="Inscriptions" :value="count" icon="how_to_reg" color="primary" hint="Réponses reçues" /></div> <div class="col-6 col-sm-4"><StatCard label="Inscriptions" :value="count" icon="how_to_reg" color="primary" hint="Réponses reçues" /></div>
<div class="col-6 col-sm-4"><StatCard label="Personnes attendues" :value="headcount" icon="groups" color="green-7" hint="Total pour le food truck" /></div> <div class="col-6 col-sm-4">
<StatCard label="Personnes attendues" :value="capacityLabel" icon="groups" :color="isFull ? 'red-7' : 'green-7'"
:value-class="isFull ? 'text-red-7' : ''" :hint="ev.capacity ? (isFull ? 'COMPLET — formulaire fermé' : 'Total / plafond') : 'Total pour le food truck'" />
</div>
<div class="col-6 col-sm-4"><StatCard label="À vérifier" :value="unmatched" icon="help_outline" :color="unmatched ? 'orange-8' : 'grey-5'" :value-class="unmatched ? 'text-orange-8' : ''" hint="N° client non reconnu" /></div> <div class="col-6 col-sm-4"><StatCard label="À vérifier" :value="unmatched" icon="help_outline" :color="unmatched ? 'orange-8' : 'grey-5'" :value-class="unmatched ? 'text-orange-8' : ''" hint="N° client non reconnu" /></div>
</div> </div>
@ -40,6 +51,9 @@
<div class="text-caption text-grey-7 q-mb-sm"> <div class="text-caption text-grey-7 q-mb-sm">
Chaque client reçoit un lien <b>personnalisé</b> : son numéro est pré-rempli et son inscription est rattachée automatiquement. Chaque client reçoit un lien <b>personnalisé</b> : son numéro est pré-rempli et son inscription est rattachée automatiquement.
</div> </div>
<div v-if="attachments.length" class="text-caption text-grey-7 q-mb-sm">
<q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) au courriel
</div>
<q-btn unelevated no-caps color="primary" icon="send" label="Préparer l'envoi" @click="prepSend" /> <q-btn unelevated no-caps color="primary" icon="send" label="Préparer l'envoi" @click="prepSend" />
<div class="text-caption text-grey-5 q-mt-sm">Aucun courriel n'est envoyé sans confirmation.</div> <div class="text-caption text-grey-5 q-mt-sm">Aucun courriel n'est envoyé sans confirmation.</div>
</div> </div>
@ -68,7 +82,8 @@
no-data-label="Aucune inscription pour l'instant."> no-data-label="Aucune inscription pour l'instant.">
<template #body-cell-client="props"> <template #body-cell-client="props">
<q-td :props="props"> <q-td :props="props">
<div class="text-weight-medium ellipsis">{{ props.row.customer_name || props.row.customer_number || '—' }}</div> <router-link v-if="props.row.customer_id" :to="'/clients/' + encodeURIComponent(props.row.customer_id)" class="text-weight-medium ellipsis erp-link" style="display:block">{{ props.row.customer_name || props.row.customer_number || '' }}</router-link>
<div v-else class="text-weight-medium ellipsis">{{ props.row.customer_name || props.row.customer_number || '—' }}</div>
<div class="text-caption text-grey-6">{{ props.row.customer_number }}</div> <div class="text-caption text-grey-6">{{ props.row.customer_number }}</div>
</q-td> </q-td>
</template> </template>
@ -107,6 +122,97 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Dialogue de création / édition d'un événement -->
<q-dialog v-model="showForm" persistent>
<q-card style="min-width:340px;max-width:720px;width:100%">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ formMode === 'create' ? 'Nouvel événement' : 'Modifier l\'événement' }}</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section style="max-height:70vh;overflow:auto">
<div class="row q-col-gutter-sm">
<q-input class="col-12 col-sm-8" v-model="form.fr.name" dense outlined label="Nom de l'événement *" hint="Requis" />
<q-input class="col-6 col-sm-4" v-model="form.date_iso" dense outlined type="date" label="Date" stack-label />
</div>
<div class="row q-col-gutter-sm q-mt-none">
<q-input class="col-4 col-sm-2" v-model="form.badge_top" dense outlined label="Badge (haut)" maxlength="6" hint="ex. 20" />
<q-input class="col-8 col-sm-3" v-model="form.badge_bottom" dense outlined label="Badge (bas)" maxlength="8" hint="ex. ANS" />
<q-input class="col-6 col-sm-4" v-model.number="form.capacity" dense outlined type="number" min="0" label="Plafond (personnes)" hint="0 = illimité">
<template #prepend><q-icon name="groups" /></template>
</q-input>
<div class="col-6 col-sm-3 flex items-center"><q-toggle v-model="form.active" label="Actif" /></div>
</div>
<q-tabs v-model="formLang" dense class="text-grey-8 q-mt-md" active-color="primary" indicator-color="primary" align="left" narrow-indicator>
<q-tab name="fr" label="Français" />
<q-tab name="en" label="English (optionnel)" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="formLang" animated class="bg-transparent">
<q-tab-panel v-for="lg in ['fr', 'en']" :key="lg" :name="lg" class="q-px-none q-gutter-sm">
<div v-if="lg === 'en'" class="text-caption text-grey-6">Laissez vide pour reprendre le texte français automatiquement.</div>
<q-input v-if="lg === 'en'" v-model="form.en.name" dense outlined label="Name" />
<q-input v-model="form[lg].tagline" dense outlined autogrow label="Accroche" />
<q-input v-model="form[lg].when" dense outlined :label="lg === 'fr' ? 'Quand (texte)' : 'When (text)'" hint="ex. 1ᵉʳ août, de 10 h à 15 h" />
<q-input v-model="form[lg].body" dense outlined type="textarea" autogrow :label="lg === 'fr' ? 'Texte d\'invitation' : 'Invitation text'" />
<div class="text-weight-medium text-grey-8 q-mt-sm">Programme</div>
<div v-for="(p, i) in form[lg].program" :key="i" class="row q-col-gutter-xs items-center">
<q-input class="col-2" v-model="p.icon" dense outlined label="Icône" maxlength="4" />
<q-input class="col" v-model="p.label" dense outlined label="Libellé" />
<q-input class="col-3" v-model="p.sub" dense outlined label="Détail" />
<q-btn class="col-auto" flat dense round icon="close" color="grey-7" @click="form[lg].program.splice(i, 1)" />
</div>
<q-btn flat dense no-caps size="sm" icon="add" label="Ajouter un élément" color="primary" @click="form[lg].program.push({ icon: '', label: '', sub: '' })" />
<q-input v-model="form[lg].program_line" dense outlined class="q-mt-sm" :label="lg === 'fr' ? 'Ligne sous le programme' : 'Line under program'" />
<q-input v-model="form[lg].limited" dense outlined type="textarea" autogrow :label="lg === 'fr' ? 'Encadré « places limitées »' : 'Limited-space note'" />
<q-input v-model="form[lg].closing" dense outlined :label="lg === 'fr' ? 'Phrase de clôture' : 'Closing line'" />
<div class="text-weight-medium text-grey-8 q-mt-sm">Notes de bas de page</div>
<div v-for="(n, i) in form[lg].notes" :key="'n' + i" class="row q-col-gutter-xs items-center">
<q-input class="col" v-model="form[lg].notes[i]" dense outlined :label="'Note ' + (i + 1)" />
<q-btn class="col-auto" flat dense round icon="close" color="grey-7" @click="form[lg].notes.splice(i, 1)" />
</div>
<q-btn flat dense no-caps size="sm" icon="add" label="Ajouter une note" color="primary" @click="form[lg].notes.push('')" />
</q-tab-panel>
</q-tab-panels>
<!-- Pièces jointes (courriel) seulement en édition (l'événement doit exister) -->
<template v-if="formMode === 'edit'">
<q-separator class="q-my-md" />
<div class="text-weight-medium text-grey-8 q-mb-xs"><q-icon name="attach_file" size="18px" /> Pièces jointes au courriel d'invitation</div>
<div class="text-caption text-grey-6 q-mb-sm">Images (PNG/JPG/GIF/WebP) ou PDF, max 4 Mo · jusqu'à 6 fichiers. Chaque client reçoit les fichiers de <b>sa langue</b> (+ les fichiers « Les deux »).</div>
<q-list bordered separator class="rounded-borders q-mb-sm" v-if="attachments.length">
<q-item v-for="a in attachments" :key="a.stored" dense>
<q-item-section avatar><q-icon :name="/pdf/.test(a.mime) ? 'picture_as_pdf' : 'image'" color="grey-7" /></q-item-section>
<q-item-section><q-item-label>{{ a.filename }}</q-item-label><q-item-label caption>{{ (a.size / 1024).toFixed(0) }} Ko</q-item-label></q-item-section>
<q-item-section side><q-badge :color="attLangColor(a.lang)" :label="attLangLabel(a.lang)" /></q-item-section>
<q-item-section side><q-btn flat dense round icon="delete" color="negative" @click="removeAtt(a)" /></q-item-section>
</q-item>
</q-list>
<div class="row q-col-gutter-sm items-center">
<q-select class="col-12 col-sm-5" v-model="attLang" dense outlined label="Langue du fichier"
:options="[{ label: 'Français', value: 'fr' }, { label: 'English', value: 'en' }, { label: 'Les deux', value: 'both' }, { label: 'Détecter (Gemini)', value: 'auto' }]"
emit-value map-options />
<q-file class="col" v-model="attFile" dense outlined label="Ajouter une pièce jointe" accept="image/*,application/pdf" :loading="uploadingAtt" @update:model-value="onAttFile">
<template #prepend><q-icon name="upload_file" /></template>
</q-file>
</div>
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn v-if="formMode === 'edit'" flat no-caps color="negative" icon="delete" label="Supprimer" @click="confirmDeleteEvent" />
<q-space />
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="primary" :label="formMode === 'create' ? 'Créer' : 'Enregistrer'" :loading="savingForm" @click="saveForm" />
</q-card-actions>
</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%">
@ -122,8 +228,9 @@
<!-- TEST --> <!-- TEST -->
<q-card-section v-if="sendMode === 'test'" class="q-gutter-sm"> <q-card-section v-if="sendMode === 'test'" class="q-gutter-sm">
<div class="text-caption text-grey-7">Envoi d'un test (courriel avec lien personnalisé) à ton adresse.</div> <div class="text-caption text-grey-7">Envoi d'un test (courriel avec lien personnalisé{{ attachments.length ? ' + pièces jointes de la langue choisie' : '' }}) à ton adresse.</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' }]" />
<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-card-section> </q-card-section>
@ -132,21 +239,89 @@
<q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" /> <q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" />
<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 class="text-weight-medium text-grey-8 q-mt-sm">Audience</div> <!-- LISTE PRINCIPALE (additive, persistée) -->
<q-btn-toggle v-model="audienceSource" spread no-caps dense @update:model-value="audPreview = null" <div class="ops-card q-pa-sm" style="border-radius:10px;border:1px solid var(--ops-border,#e0e0e0)">
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" /> <div class="row items-center">
<div class="text-subtitle2 text-weight-bold"><q-icon name="list_alt" class="q-mr-xs" />Liste d'envoi : {{ mainList.count }} destinataire(s)</div>
<q-space />
<q-btn v-if="mainList.count" flat dense no-caps size="sm" color="negative" icon="delete_sweep" label="Vider" :loading="listBusy" @click="clearList" />
</div>
<div v-if="mainListSources.length" class="q-mt-xs">
<q-chip v-for="s in mainListSources" :key="s.label" dense square size="sm" color="blue-1" text-color="blue-10">{{ s.label }} · {{ s.n }}</q-chip>
</div>
<div v-if="mainList.count" class="q-mt-xs">
<a class="cursor-pointer text-caption text-primary" @click="showListSample = !showListSample">{{ showListSample ? '▾ Masquer' : '▸ Voir' }} les destinataires</a>
<q-list v-if="showListSample" dense separator bordered class="rounded-borders q-mt-xs" style="max-height:200px;overflow:auto">
<q-item v-for="r in mainList.sample" :key="r.email" dense>
<q-item-section>
<q-item-label class="text-caption">{{ r.name }} <q-badge dense :color="r.language === 'en' ? 'deep-orange-6' : 'blue-6'" :label="(r.language || 'fr').toUpperCase()" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ r.email }}<span v-if="r.source"> · {{ r.source }}</span></q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" @click="removeFromList(r)" /></q-item-section>
</q-item>
<q-item v-if="mainList.count > mainList.sample.length" dense><q-item-section class="text-caption text-grey-6">et {{ mainList.count - mainList.sample.length }} autres (affichage plafonné à 200)</q-item-section></q-item>
</q-list>
</div>
<div v-else class="text-caption text-grey-6 q-mt-xs">Ajoutez des lots ci-dessous (par filtre, sélection ou CSV). Ils s'additionnent ici, sans doublon.</div>
</div>
<div class="text-weight-medium text-grey-8 q-mt-sm">Ajouter des destinataires</div>
<q-btn-toggle v-model="audienceSource" spread no-caps dense
:options="[{ label: 'Par filtre', value: 'list' }, { label: 'Sélection', value: 'manual' }, { label: 'CSV', value: 'csv' }]" />
<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" @update:model-value="audPreview = null" /> <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.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" @update:model-value="audPreview = null" /> <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 class="row q-col-gutter-sm">
<q-input class="col-12 col-sm-6" v-model="audFilters.city" dense outlined clearable label="Ville" placeholder="ex. Gatineau">
<template #prepend><q-icon name="location_city" /></template>
</q-input>
<q-input class="col-12 col-sm-6" v-model="audFilters.name_like" dense outlined clearable label="Nom du compte contient" placeholder="ex. Proprio">
<template #prepend><q-icon name="search" /></template>
</q-input>
</div> </div>
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="onActiveSubToggle" /> <q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="onActiveSubToggle" />
<q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined :disable="!audFilters.active_sub" label="Total mensuel minimum ($/mois)" @update:model-value="audPreview = null"> <q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined :disable="!audFilters.active_sub" label="Total mensuel minimum ($/mois)">
<template #prepend><q-icon name="attach_money" /></template> <template #prepend><q-icon name="attach_money" /></template>
</q-input> </q-input>
<div class="text-caption text-grey-6">Le « total mensuel » (somme des abonnements actifs) requiert d'activer « Abonnement actif ». « Résidentiel/Commercial » = type de compte.</div> <div class="text-caption text-grey-6">« Ville » filtre par lieu de service ; « Nom contient » par sous-chaîne. Les critères se combinent (ET). Le « total mensuel » requiert « Abonnement actif ».</div>
<q-btn unelevated no-caps color="primary" icon="playlist_add" label="Ajouter ces clients à la liste" :loading="listBusy" @click="addFilterToList" />
</div>
<div v-else-if="audienceSource === 'manual'" class="q-gutter-sm">
<q-select
v-model="poolPick" dense outlined use-input hide-selected fill-input input-debounce="300"
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 #no-option><q-item><q-item-section class="text-grey-6">{{ poolSearching ? 'Recherche…' : 'Tapez pour rechercher' }}</q-item-section></q-item></template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<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-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-section>
</q-item>
</template>
</q-select>
<div v-if="pool.length" class="q-gutter-xs">
<q-list bordered separator class="rounded-borders" style="max-height:170px;overflow:auto">
<q-item v-for="p in pool" :key="p.customer_id" dense>
<q-item-section>
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
<q-item-label caption>{{ p.customer_id }}<span v-if="p.email"> · {{ p.email }}</span><span v-else class="text-orange-8"> · sans courriel (sera ignoré)</span></q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" @click="removeFromPool(p)" /></q-item-section>
</q-item>
</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" />
</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">
@ -154,29 +329,9 @@
<template #prepend><q-icon name="upload_file" /></template> <template #prepend><q-icon name="upload_file" /></template>
</q-file> </q-file>
<div class="text-caption text-grey-6">Colonne obligatoire : <b>email</b>. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.</div> <div class="text-caption text-grey-6">Colonne obligatoire : <b>email</b>. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.</div>
<q-btn unelevated no-caps color="primary" icon="playlist_add" label="Ajouter le CSV à la liste" :disable="!audCsvText" :loading="listBusy" @click="addCsvToList" />
</div> </div>
<q-btn outline no-caps color="primary" icon="groups" label="Aperçu de l'audience" :loading="previewing" @click="doPreview" />
<q-banner v-if="audPreview" dense rounded class="bg-blue-1 text-blue-10">
<b>{{ audPreview.count }}</b> destinataire(s)<span v-if="audPreview.dropped_no_email"> · {{ audPreview.dropped_no_email }} sans courriel écarté(s)</span><span v-if="audPreview.resiliated_excluded" class="text-orange-9"> · {{ audPreview.resiliated_excluded }} ex-client(s) résilié(s) exclus</span>
<div v-if="audPreview.sample && audPreview.sample.length" class="text-caption q-mt-xs" style="opacity:.85">
ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}
</div>
<div v-if="audPreview.no_email && audPreview.no_email.length" class="q-mt-xs">
<a class="cursor-pointer text-weight-medium text-blue-10" @click="showNoEmail = !showNoEmail">
{{ showNoEmail ? '▾ Masquer' : '▸ Voir' }} les {{ audPreview.dropped_no_email }} comptes sans courriel (à valider)
</a>
<div v-if="showNoEmail" class="q-mt-xs q-pa-xs bg-white rounded-borders" style="max-height:170px;overflow:auto">
<div v-for="ne in audPreview.no_email" :key="ne.customer_id" class="text-caption">
<a :href="'#/clients/' + ne.customer_id" target="_blank" class="text-primary">{{ ne.name }}</a>
<span class="text-grey-6"> · {{ ne.customer_id }}</span>
</div>
<div v-if="audPreview.dropped_no_email > audPreview.no_email.length" class="text-caption text-grey-6 q-mt-xs">et {{ audPreview.dropped_no_email - audPreview.no_email.length }} autres (liste plafonnée à 300)</div>
</div>
</div>
</q-banner>
<q-banner dense rounded class="bg-grey-2 text-grey-8"> <q-banner dense rounded class="bg-grey-2 text-grey-8">
<template #avatar><q-icon name="lock" color="grey-7" /></template> <template #avatar><q-icon name="lock" color="grey-7" /></template>
L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici. L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici.
@ -202,15 +357,21 @@ import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters' import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
import { useCsvExport } from 'src/composables/useCsvExport' import { useCsvExport } from 'src/composables/useCsvExport'
import { usePermissions } from 'src/composables/usePermissions' import { usePermissions } from 'src/composables/usePermissions'
import { listRsvps, deleteRsvp, sendInvite, previewAudience, listTemplates } from 'src/api/events' import {
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
} from 'src/api/events'
const $q = useQuasar() const $q = useQuasar()
const { exportCsv } = useCsvExport() const { exportCsv } = useCsvExport()
const EVENT_ID = 'fete-20-ans'
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const events = ref([])
const eventId = ref('')
const ev = ref({}) const ev = ref({})
const attachments = ref([])
const rows = ref([]) const rows = ref([])
const count = ref(0) const count = ref(0)
const headcount = ref(0) const headcount = ref(0)
@ -221,20 +382,31 @@ const { userEmail } = usePermissions()
const showSend = ref(false) const showSend = ref(false)
const sendChannel = ref('mailjet') const sendChannel = ref('mailjet')
const testEmail = ref('') const testEmail = ref('')
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 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: '', 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('')
const audPreview = ref(null) // Pool manuel : clients sélectionnés avant ajout à la liste
const previewing = ref(false) const pool = ref([])
const showNoEmail = ref(false) const poolPick = ref(null)
const poolResults = ref([])
const poolSearching = ref(false)
// Liste principale (additive, persistée côté hub)
const mainList = ref({ count: 0, by_source: {}, sample: [], updated: '' })
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 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 capacityLabel = computed(() => ev.value.capacity ? `${headcount.value} / ${ev.value.capacity}` : headcount.value)
const filterState = ref({ search: '', chips: {} }) const filterState = ref({ search: '', chips: {} })
const chipGroups = [{ key: 'statut', label: 'Compte', icon: 'label', options: [{ value: 'confirmed', label: 'Confirmés', color: 'green-7' }, { value: 'probable', label: 'Probables', color: 'blue-6' }, { value: 'unverified', label: 'À vérifier', color: 'orange-7' }] }] const chipGroups = [{ key: 'statut', label: 'Compte', icon: 'label', options: [{ value: 'confirmed', label: 'Confirmés', color: 'green-7' }, { value: 'probable', label: 'Probables', color: 'blue-6' }, { value: 'unverified', label: 'À vérifier', color: 'orange-7' }] }]
@ -262,11 +434,27 @@ const filtered = computed(() => {
}) })
}) })
async function loadEventList (preferId) {
try {
const d = await listEvents()
events.value = d.events || []
if (!events.value.length) { eventId.value = ''; return }
if (preferId && events.value.some(e => e.id === preferId)) eventId.value = preferId
else if (!eventId.value || !events.value.some(e => e.id === eventId.value)) {
const active = events.value.find(e => e.active) || events.value[0]
eventId.value = active.id
}
} catch (e) { /* laisse load() rapporter l'erreur */ }
}
async function load () { async function load () {
if (!events.value.length) await loadEventList()
if (!eventId.value) { loading.value = false; return }
loading.value = true; error.value = '' loading.value = true; error.value = ''
try { try {
const d = await listRsvps(EVENT_ID) const d = await listRsvps(eventId.value)
ev.value = d.event || {} ev.value = d.event || {}
attachments.value = (d.event && d.event.attachments) || []
rows.value = d.responses || [] rows.value = d.responses || []
count.value = d.count || 0 count.value = d.count || 0
headcount.value = d.headcount || 0 headcount.value = d.headcount || 0
@ -277,6 +465,8 @@ async function load () {
} finally { loading.value = false } } finally { loading.value = false }
} }
function onSelectEvent () { load() }
function copy (txt) { function copy (txt) {
if (!txt) return if (!txt) return
navigator.clipboard?.writeText(txt).then(() => $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 })) navigator.clipboard?.writeText(txt).then(() => $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 }))
@ -288,18 +478,147 @@ function exportRows () {
const headers = ['Client', 'N° client', 'ID compte', 'Nom au dossier', 'Téléphone', 'Personnes', 'Courriel', 'Allergies', 'Confiance', 'Signaux', 'Langue', 'IP', 'Créé le', 'Répondu le'] const headers = ['Client', 'N° client', 'ID compte', 'Nom au dossier', 'Téléphone', 'Personnes', 'Courriel', 'Allergies', 'Confiance', 'Signaux', 'Langue', 'IP', 'Créé le', 'Répondu le']
const data = filtered.value.map(r => [r.customer_name || '', r.customer_number || '', r.customer_id || '', r.account_name || '', r.phone || '', r.party_size || 0, r.email || '', r.allergies || '', conf[r.confidence] || 'À vérifier', (r.signals || []).join(' + '), r.lang || '', r.ip || '', fmtDate(r.created), fmtDate(r.updated)]) const data = filtered.value.map(r => [r.customer_name || '', r.customer_number || '', r.customer_id || '', r.account_name || '', r.phone || '', r.party_size || 0, r.email || '', r.allergies || '', conf[r.confidence] || 'À vérifier', (r.signals || []).join(' + '), r.lang || '', r.ip || '', fmtDate(r.created), fmtDate(r.updated)])
const totalRow = ['TOTAL', '', '', '', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '', '', '', ''] const totalRow = ['TOTAL', '', '', '', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '', '', '', '']
exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow }) exportCsv(`inscriptions_${eventId.value}`, headers, data, { totalRow })
} }
function confirmDelete (row) { function confirmDelete (row) {
$q.dialog({ title: 'Retirer l\'inscription', message: `Retirer ${row.customer_name || row.customer_number || 'cette inscription'} (${row.party_size} pers.) ?`, cancel: true, persistent: true }) $q.dialog({ title: 'Retirer l\'inscription', message: `Retirer ${row.customer_name || row.customer_number || 'cette inscription'} (${row.party_size} pers.) ?`, cancel: true, persistent: true })
.onOk(async () => { .onOk(async () => {
try { await deleteRsvp(EVENT_ID, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() } try { await deleteRsvp(eventId.value, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}) })
} }
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); showSend.value = true } // Création / édition d'un événement
const showForm = ref(false)
const formMode = ref('create')
const formLang = ref('fr')
const savingForm = ref(false)
const attFile = ref(null)
const attLang = ref('both')
const uploadingAtt = ref(false)
function attLangLabel (l) { return l === 'fr' ? 'FR' : l === 'en' ? 'EN' : 'Les deux' }
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 emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, fr: emptyLang(), en: emptyLang() })
const form = ref(emptyForm())
function openCreate () {
form.value = emptyForm()
formMode.value = 'create'
formLang.value = 'fr'
showForm.value = true
}
async function openEdit () {
if (!eventId.value) return
try {
const d = await getEventConfig(eventId.value)
const c = d.event || {}
const f = emptyForm()
f.id = c.id || eventId.value
f.active = c.active !== false
f.date_iso = c.date_iso || ''
f.badge_top = c.badge_top || ''
f.badge_bottom = c.badge_bottom || ''
f.capacity = c.capacity || 0
for (const lg of ['fr', 'en']) {
const src = c[lg] || {}
f[lg] = {
name: src.name || '', tagline: src.tagline || '', when: src.when || '', body: src.body || '',
program: Array.isArray(src.program) ? src.program.map(p => ({ icon: p.icon || '', label: p.label || '', sub: p.sub || '' })) : [],
program_line: src.program_line || '', limited: src.limited || '', closing: src.closing || '',
notes: Array.isArray(src.notes) ? [...src.notes] : [],
}
}
form.value = f
formMode.value = 'edit'
formLang.value = 'fr'
showForm.value = true
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
function cleanLang (c) {
return {
name: (c.name || '').trim(), tagline: (c.tagline || '').trim(), when: (c.when || '').trim(), body: (c.body || '').trim(),
program: (c.program || []).filter(p => (p.label || '').trim() || (p.icon || '').trim()),
program_line: (c.program_line || '').trim(), limited: (c.limited || '').trim(), closing: (c.closing || '').trim(),
notes: (c.notes || []).map(n => (n || '').trim()).filter(Boolean),
}
}
async function saveForm () {
if (!form.value.fr.name.trim()) { $q.notify({ type: 'warning', message: 'Le nom (français) est requis' }); formLang.value = 'fr'; return }
savingForm.value = true
try {
const enHasContent = Object.values(form.value.en).some(v => Array.isArray(v) ? v.length : String(v || '').trim())
const body = {
active: form.value.active,
date_iso: form.value.date_iso,
badge_top: form.value.badge_top,
badge_bottom: form.value.badge_bottom,
capacity: form.value.capacity,
fr: cleanLang(form.value.fr),
...(enHasContent ? { en: cleanLang(form.value.en) } : {}),
}
if (formMode.value === 'create') {
const r = await createEvent(body)
$q.notify({ type: 'positive', message: 'Événement créé' })
showForm.value = false
await loadEventList(r.event && r.event.id)
await load()
} else {
await updateEvent(form.value.id, body)
$q.notify({ type: 'positive', message: 'Événement enregistré' })
showForm.value = false
await loadEventList(form.value.id)
await load()
}
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { savingForm.value = false }
}
function confirmDeleteEvent () {
const seeded = form.value.id === 'fete-20-ans'
$q.dialog({
title: 'Supprimer l\'événement',
message: seeded ? 'Réinitialiser cet événement à sa configuration par défaut ? Les inscriptions sont conservées.' : `Supprimer « ${form.value.fr.name} » ? Les inscriptions déjà reçues sont conservées.`,
cancel: true, persistent: true, ok: { color: 'negative', label: seeded ? 'Réinitialiser' : 'Supprimer' },
}).onOk(async () => {
try {
await deleteEvent(form.value.id)
$q.notify({ type: 'positive', message: seeded ? 'Réinitialisé' : 'Supprimé' })
showForm.value = false
eventId.value = ''
events.value = []
await loadEventList()
await load()
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
// Pièces jointes (édition)
function onAttFile (file) {
if (!file) return
if (file.size > 4 * 1024 * 1024) { $q.notify({ type: 'warning', message: 'Fichier trop volumineux (max 4 Mo)' }); attFile.value = null; return }
uploadingAtt.value = true
const rd = new FileReader()
rd.onload = async () => {
try {
const data = String(rd.result || '').replace(/^data:[^,]*,/, '')
const r = await uploadAttachment(form.value.id, { filename: file.name, mime: file.type || 'application/octet-stream', data, lang: attLang.value })
attachments.value = r.attachments || []
const added = r.attachment
$q.notify({ type: 'positive', message: `Pièce jointe ajoutée (${attLangLabel(added ? added.lang : attLang.value)})` })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { uploadingAtt.value = false; attFile.value = null }
}
rd.onerror = () => { uploadingAtt.value = false; attFile.value = null; $q.notify({ type: 'negative', message: 'Lecture du fichier impossible' }) }
rd.readAsDataURL(file)
}
async function removeAtt (a) {
try { const r = await deleteAttachment(form.value.id, a.stored); attachments.value = r.attachments || []; $q.notify({ type: 'positive', message: 'Retirée' }) }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// Envoi (test + aperçu audience)
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); loadMainList(); showSend.value = true }
async function loadTemplates () { async function loadTemplates () {
try { try {
const d = await listTemplates() const d = await listTemplates()
@ -308,26 +627,96 @@ async function loadTemplates () {
} catch (e) { /* garde le défaut */ } } catch (e) { /* garde le défaut */ }
} }
function onCsvFile (file) { function onCsvFile (file) {
audPreview.value = null
if (!file) { audCsvText.value = ''; return } if (!file) { audCsvText.value = ''; return }
const rd = new FileReader() const rd = new FileReader()
rd.onload = () => { audCsvText.value = String(rd.result || '') } rd.onload = () => { audCsvText.value = String(rd.result || '') }
rd.readAsText(file) rd.readAsText(file)
} }
function onActiveSubToggle (v) { audPreview.value = null; 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
async function doPreview () {
previewing.value = true; audPreview.value = null; showNoEmail.value = false // Pool manuel : recherche + sélection avant ajout
function onPoolFilter (val, update) {
const q = (val || '').trim()
if (q.length < 2) { update(() => { poolResults.value = [] }); return }
poolSearching.value = true
// Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »).
searchCustomersFuzzy(q)
.then(d => update(() => {
poolResults.value = (d.matches || [])
.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) }))
}))
.catch(() => update(() => { poolResults.value = [] }))
.finally(() => { poolSearching.value = false })
}
function addToPool (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) }
// Liste principale : chargement + ajout de lots + retrait/vidage
async function loadMainList () {
try { mainList.value = await getAudienceList(eventId.value) } catch (e) { /* liste vide si indispo */ }
}
function applyListResult (r, verb) {
mainList.value = { count: r.count, by_source: r.by_source, sample: r.sample, updated: r.updated }
const bits = []
if (typeof r.added === 'number') bits.push(`${r.added} ajouté(s)`)
if (r.duplicates) bits.push(`${r.duplicates} doublon(s) ignoré(s)`)
if (r.dropped_no_email) bits.push(`${r.dropped_no_email} sans courriel`)
if (r.resiliated_excluded) bits.push(`${r.resiliated_excluded} résilié(s) exclus`)
$q.notify({ type: r.added === 0 && verb === 'add' ? 'warning' : 'positive', message: (bits.join(' · ') || verb) + `${r.count} au total`, icon: 'playlist_add_check' })
}
function filterLabel () {
const f = audFilters.value; const p = []
if (f.city) p.push('Ville: ' + f.city)
if (f.customer_type === 'Individual') p.push('Résidentiel'); else if (f.customer_type === 'Company') p.push('Commercial')
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.statut === 'active' && !p.length) p.push('Clients actifs')
return p.join(' · ') || 'Filtre'
}
async function addFilterToList () {
listBusy.value = true
try { applyListResult(await audienceListAdd(eventId.value, { mode: 'filter', filters: audFilters.value, source_label: filterLabel() }), 'add') }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
async function addManualToList () {
if (!pool.value.length) return
listBusy.value = true
try { try {
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value } const r = await audienceListAdd(eventId.value, { mode: 'manual', filters: { customer_ids: pool.value.map(p => p.customer_id) }, source_label: 'Sélection manuelle' })
audPreview.value = await previewAudience(EVENT_ID, body) applyListResult(r, 'add'); pool.value = []
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { previewing.value = false } } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
async function addCsvToList () {
if (!audCsvText.value) return
listBusy.value = true
try {
const r = await audienceListAdd(eventId.value, { mode: 'csv', csv: audCsvText.value, source_label: 'CSV' + (csvFile.value ? ': ' + csvFile.value.name : '') })
applyListResult(r, 'add'); csvFile.value = null; audCsvText.value = ''
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
async function removeFromList (r) {
listBusy.value = true
try { const res = await audienceListRemove(eventId.value, r.email); mainList.value = { count: res.count, by_source: res.by_source, sample: res.sample, updated: res.updated } }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
function clearList () {
$q.dialog({ title: 'Vider la liste', message: 'Retirer tous les destinataires de la liste d\'envoi ?', cancel: true, persistent: true, ok: { color: 'negative', label: 'Vider' } })
.onOk(async () => {
listBusy.value = true
try { const r = await audienceListClear(eventId.value); mainList.value = { count: 0, by_source: {}, sample: [], updated: '' }; void r; $q.notify({ type: 'positive', message: 'Liste vidée' }) }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
})
} }
async function sendTestNow () { async function sendTestNow () {
const em = (testEmail.value || '').trim() const em = (testEmail.value || '').trim()
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(EVENT_ID, { test: true, test_emails: [em], channel: sendChannel.value }) const r = await sendInvite(eventId.value, { test: true, test_emails: [em], channel: sendChannel.value, lang: testLang.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 }

View File

@ -76,6 +76,19 @@ async function sendEmail (opts) {
}) })
} }
// Generic attachments (e.g. event invitation images) : [{ filename, content(Buffer), contentType|mime }]
if (Array.isArray(opts.attachments)) {
for (const a of opts.attachments) {
if (a && a.filename && a.content) {
mailOpts.attachments.push({
filename: a.filename,
content: a.content,
contentType: a.contentType || a.mime || 'application/octet-stream',
})
}
}
}
try { try {
const info = await transport.sendMail(mailOpts) const info = await transport.sendMail(mailOpts)
log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`) log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`)

View File

@ -7,8 +7,22 @@
* ?t=<jwt magic-link client> pré-remplit nom/courriel + rattache d'office. * ?t=<jwt magic-link client> pré-remplit nom/courriel + rattache d'office.
* - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate), * - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate),
* clé par client re-soumission = mise à jour (pas de doublon). Jamais bloquant. * clé par client re-soumission = mise à jour (pas de doublon). Jamais bloquant.
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte + confiance), * - Endpoints STAFF (gatés) : GET /events, POST /events (créer), PUT/DELETE /events/<id>,
* DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test). * GET /events/<id>/rsvps (+ décompte + confiance), DELETE /events/<id>/rsvps/<key>,
* POST /events/<id>/send (mode test), POST /events/<id>/audience (aperçu),
* POST /events/<id>/attachments (téléverser une pièce jointe image), DELETE /events/<id>/attachments/<nom>.
*
* MODÈLE : chaque événement réutilise la mise en page festive (barre confetti, pastille badge,
* pastilles de programme). Le CONTENU (nom, date, textes, programme, badge, capacité, pièces jointes)
* est ÉDITABLE et persisté par fichier ; les LIBELLÉS d'interface (champs, erreurs, remerciements)
* sont communs à tous les événements (const LABELS). L'événement « Fête 20 ans » est un cas semé.
*
* CAPACITÉ : `capacity` (0 = illimité) = plafond de PERSONNES (somme des party_size). À l'atteinte,
* le formulaire public est remplacé par un message « inscriptions complètes » (enforcement serveur
* ET client) ; un inscrit existant (jeton) peut toujours modifier sa réponse.
*
* PIÈCES JOINTES : images/fichiers joints au COURRIEL d'invitation (masse + test) uniquement pas
* affichées sur la page RSVP. Stockées sur disque (DATA_DIR/attachments/<id>/), métadonnées dans la config.
* *
* IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client * IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client
* (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission, * (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission,
@ -24,19 +38,83 @@ const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmai
const { generateCustomerToken, verifyJwt } = require('./magic-link') const { generateCustomerToken, verifyJwt } = require('./magic-link')
const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events' const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events'
try { fs.mkdirSync(DATA_DIR, { recursive: true }) } catch { /* best-effort */ } const CONFIG_DIR = path.join(DATA_DIR, 'config') // configs d'événements créés/édités (shadow le SEED)
const ATT_DIR = path.join(DATA_DIR, 'attachments') // pièces jointes des courriels d'invitation
const AUDIENCE_DIR = path.join(DATA_DIR, 'audience') // liste principale d'audience par événement (additive)
try { fs.mkdirSync(CONFIG_DIR, { recursive: true }) } catch { /* best-effort */ }
try { fs.mkdirSync(ATT_DIR, { recursive: true }) } catch { /* best-effort */ }
try { fs.mkdirSync(AUDIENCE_DIR, { recursive: true }) } catch { /* best-effort */ }
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca' const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png' const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr' const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || '' const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
// ── Config événements (semé : Fête Targo 20 ans) ─────────────────────────── // ── Libellés d'interface COMMUNS (indépendants de l'événement) ─────────────
const SEED = { // Séparés du contenu éditable : mêmes champs/erreurs/remerciements pour tous les événements.
const LABELS = {
fr: {
f_name: 'Votre nom',
f_email: 'Votre adresse courriel',
f_phone: 'Votre téléphone',
f_number: 'Votre numéro client',
f_party: 'Combien serez-vous ?',
f_allerg: 'Allergies ou restrictions alimentaires',
opt: '(optionnel)',
f_party_ph: 'Nombre de personnes',
submit: 'Confirmer ma présence',
greet: (n) => n ? `Bonjour ${n} !` : '',
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
thanks_body: "On a hâte de vous voir. Vous recevrez votre coupon repas à l'accueil.",
thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
thanks_tocheck: 'Nous validerons votre inscription sous peu.',
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
err_name: 'Veuillez entrer votre nom.',
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
err_email: 'Veuillez entrer une adresse courriel valide.',
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
foot: 'TARGO — votre fournisseur Internet local',
full_title: 'Inscriptions complètes',
full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.",
},
en: {
f_name: 'Your name',
f_email: 'Your email address',
f_phone: 'Your phone',
f_number: 'Your customer number',
f_party: 'How many will attend?',
f_allerg: 'Allergies or dietary restrictions',
opt: '(optional)',
f_party_ph: 'Number of people',
submit: 'Confirm my attendance',
greet: (n) => n ? `Hi ${n}!` : '',
thanks_title: "Thank you, you're confirmed! 🎉",
thanks_body: 'We look forward to seeing you. Pick up your meal coupon at the welcome desk.',
thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
thanks_tocheck: "We'll confirm your registration shortly.",
thanks_update: 'You can change your answer anytime from this link.',
err_name: 'Please enter your name.',
err_party: 'Please tell us how many people will attend.',
err_email: 'Please enter a valid email address.',
err_generic: 'Something went wrong. Please try again or contact us.',
foot: 'TARGO — your local Internet provider',
full_title: 'Registration full',
full_msg: 'Unfortunately, the event booking is full.',
},
}
// ── Contenu éditable des événements (semé : Fête Targo 20 ans) ─────────────
// Forme brute persistée : { id, active, date_iso, badge_top, badge_bottom, capacity, attachments[],
// fr:{ name, tagline, when, body, program:[{icon,label,sub}], program_line, limited, closing, notes[] }, en:{...} }
const SEED_CONTENT = {
'fete-20-ans': { 'fete-20-ans': {
id: 'fete-20-ans', id: 'fete-20-ans',
active: true, active: true,
date_iso: '2026-08-01', date_iso: '2026-08-01',
badge_top: '20',
badge_bottom: 'ANS',
capacity: 0,
attachments: [],
fr: { fr: {
name: 'Targo fête ses 20 ans', name: 'Targo fête ses 20 ans',
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !", tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
@ -44,10 +122,10 @@ const SEED = {
body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.", body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
program: [ program: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' }, { icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
{ icon: '🎧', label: 'DJ' }, { icon: '🎧', label: 'DJ', sub: '' },
{ icon: '🎪', label: 'Jeux gonflables' }, { icon: '🎪', label: 'Jeux gonflables', sub: '' },
{ icon: '🎨', label: 'Maquillage' }, { icon: '🎨', label: 'Maquillage', sub: '' },
{ icon: '🎁', label: 'Et des surprises' }, { icon: '🎁', label: 'Et des surprises', sub: '' },
], ],
program_line: "Le tout dans une ambiance familiale et conviviale.", program_line: "Le tout dans une ambiance familiale et conviviale.",
limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.", limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.",
@ -56,26 +134,6 @@ const SEED = {
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.', 'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
"Vous recevrez un coupon repas sur place (à l'accueil).", "Vous recevrez un coupon repas sur place (à l'accueil).",
], ],
f_name: 'Votre nom',
f_email: 'Votre adresse courriel',
f_phone: 'Votre téléphone',
f_number: 'Votre numéro client',
f_party: 'Combien serez-vous ?',
f_allerg: 'Allergies ou restrictions alimentaires',
opt: '(optionnel)',
f_party_ph: 'Nombre de personnes',
submit: 'Confirmer ma présence',
greet: (n) => n ? `Bonjour ${n} !` : '',
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
thanks_body: "On a hâte de vous voir le 1ᵉʳ août. Vous recevrez votre coupon repas à l'accueil.",
thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
thanks_tocheck: 'Nous validerons votre inscription sous peu.',
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
err_name: 'Veuillez entrer votre nom.',
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
err_email: 'Veuillez entrer une adresse courriel valide.',
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
foot: 'TARGO — votre fournisseur Internet local',
}, },
en: { en: {
name: 'Targo turns 20!', name: 'Targo turns 20!',
@ -84,10 +142,10 @@ const SEED = {
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.', body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
program: [ program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' }, { icon: '🚚', label: 'Food truck', sub: 'meal included' },
{ icon: '🎧', label: 'DJ' }, { icon: '🎧', label: 'DJ', sub: '' },
{ icon: '🎪', label: 'Bouncy castles' }, { icon: '🎪', label: 'Bouncy castles', sub: '' },
{ icon: '🎨', label: 'Face painting' }, { icon: '🎨', label: 'Face painting', sub: '' },
{ icon: '🎁', label: 'And surprises' }, { icon: '🎁', label: 'And surprises', sub: '' },
], ],
program_line: 'All in a warm, family-friendly atmosphere.', program_line: 'All in a warm, family-friendly atmosphere.',
limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.', limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.',
@ -96,37 +154,105 @@ const SEED = {
'If you have allergies, the menu may not suit you.', 'If you have allergies, the menu may not suit you.',
'You will receive a meal coupon on site (at the welcome desk).', 'You will receive a meal coupon on site (at the welcome desk).',
], ],
f_name: 'Your name',
f_email: 'Your email address',
f_phone: 'Your phone',
f_number: 'Your customer number',
f_party: 'How many will attend?',
f_allerg: 'Allergies or dietary restrictions',
opt: '(optional)',
f_party_ph: 'Number of people',
submit: 'Confirm my attendance',
greet: (n) => n ? `Hi ${n}!` : '',
thanks_title: "Thank you, you're confirmed! 🎉",
thanks_body: 'We look forward to seeing you on August 1st. Pick up your meal coupon at the welcome desk.',
thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
thanks_tocheck: "We'll confirm your registration shortly.",
thanks_update: 'You can change your answer anytime from this link.',
err_name: 'Please enter your name.',
err_party: 'Please tell us how many people will attend.',
err_email: 'Please enter a valid email address.',
err_generic: 'Something went wrong. Please try again or contact us.',
foot: 'TARGO — your local Internet provider',
}, },
}, },
} }
function getEvent (eventId) { return SEED[eventId] || null } // ── Persistance des configs (fichier shadow le SEED ; édition/attachments écrivent ici) ──
function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) } function configFile (id) { return path.join(CONFIG_DIR, `${id}.json`) }
function rawConfig (id) {
const stored = readJsonFile(configFile(id), null)
if (stored && stored.id) return stored
if (SEED_CONTENT[id]) return JSON.parse(JSON.stringify(SEED_CONTENT[id]))
return null
}
function saveConfig (id, obj) { obj.id = id; writeJsonFile(configFile(id), obj) }
// Construit l'objet événement d'exécution : contenu + LIBELLÉS communs (EN retombe sur FR si vide).
function buildEvent (base) {
if (!base) return null
const content = (lang) => {
const c = base[lang] && Object.keys(base[lang]).length ? base[lang] : (base.fr || {})
return { ...LABELS[lang], ...c, program: Array.isArray(c.program) ? c.program : [], notes: Array.isArray(c.notes) ? c.notes : [] }
}
return {
id: base.id,
active: base.active !== false,
date_iso: base.date_iso || '',
badge_top: String(base.badge_top || ''),
badge_bottom: String(base.badge_bottom || ''),
capacity: Math.max(0, parseInt(base.capacity, 10) || 0),
attachments: Array.isArray(base.attachments) ? base.attachments : [],
fr: content('fr'),
en: content('en'),
}
}
function getEvent (eventId) { return buildEvent(rawConfig(eventId)) }
function listEventIds () {
const ids = new Set(Object.keys(SEED_CONTENT))
try { for (const f of fs.readdirSync(CONFIG_DIR)) if (f.endsWith('.json')) ids.add(f.slice(0, -5)) } catch { /* best-effort */ }
return [...ids]
}
function listEvents () {
return listEventIds().map(getEvent).filter(Boolean)
.map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name, capacity: e.capacity, attachments: (e.attachments || []).length }))
.sort((a, b) => (b.date_iso || '').localeCompare(a.date_iso || ''))
}
// ── Slug / validation à la création ────────────────────────────────────────
function slugify (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60)
}
function uniqueId (base) {
let id = base || 'evenement'; let n = 2
const taken = new Set(listEventIds())
while (taken.has(id)) { id = `${base}-${n++}` }
return id
}
function sanitizeProgram (arr) {
return (Array.isArray(arr) ? arr : []).slice(0, 8).map(p => ({
icon: String(p.icon || '').slice(0, 8),
label: String(p.label || '').slice(0, 60),
sub: String(p.sub || '').slice(0, 60),
})).filter(p => p.label || p.icon)
}
function sanitizeLangContent (c) {
c = c || {}
return {
name: String(c.name || '').slice(0, 160),
tagline: String(c.tagline || '').slice(0, 300),
when: String(c.when || '').slice(0, 120),
body: String(c.body || '').slice(0, 1200),
program: sanitizeProgram(c.program),
program_line: String(c.program_line || '').slice(0, 200),
limited: String(c.limited || '').slice(0, 400),
closing: String(c.closing || '').slice(0, 300),
notes: (Array.isArray(c.notes) ? c.notes : []).slice(0, 6).map(n => String(n || '').slice(0, 300)).filter(Boolean),
}
}
// Normalise le corps de création/édition en une config brute persistable.
function normalizeConfig (b, existing = null) {
const fr = sanitizeLangContent(b.fr)
const enRaw = b.en && Object.values(b.en).some(v => (Array.isArray(v) ? v.length : String(v || '').trim())) ? sanitizeLangContent(b.en) : null
return {
id: existing ? existing.id : undefined,
active: b.active !== false,
date_iso: /^\d{4}-\d{2}-\d{2}$/.test(String(b.date_iso || '')) ? b.date_iso : (existing ? existing.date_iso : ''),
badge_top: String(b.badge_top || '').slice(0, 6),
badge_bottom: String(b.badge_bottom || '').slice(0, 8),
capacity: Math.max(0, parseInt(b.capacity, 10) || 0),
attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [],
fr,
...(enRaw ? { en: enRaw } : {}),
}
}
// ── Stockage des réponses (une map par événement, clé = client) ──────────── // ── Stockage des réponses (une map par événement, clé = client) ────────────
function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) } function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) }
function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} } function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) } function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
function headcountOf (m) { return Object.values(m).reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0) }
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) } function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) }
@ -183,11 +309,12 @@ function inviteEmail (eventId, lang, name, rsvpUrl) {
const e = getEvent(eventId); if (!e) return '' const e = getEvent(eventId); if (!e) return ''
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 CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO) const CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO)
const LBL = ['#1e8e3e', '#1a6f9e', '#4b5563', '#1e8e3e', '#1a6f9e'] const LBL = ['#1e8e3e', '#1a6f9e', '#4b5563', '#1e8e3e', '#1a6f9e']
const prog = t.program.map((p, i) => const prog = (t.program || []).map((p, i) =>
`<td align="center" valign="top" style="padding:6px 3px">` `<td align="center" valign="top" style="padding:6px 3px">`
+ `<div style="width:50px;height:50px;line-height:50px;border-radius:25px;background:${CIRC[i % 5]};font-size:25px;margin:0 auto">${p.icon}</div>` + `<div style="width:50px;height:50px;line-height:50px;border-radius:25px;background:${CIRC[i % 5]};font-size:25px;margin:0 auto">${esc(p.icon)}</div>`
+ `<div style="margin-top:6px;font-size:10px;font-weight:700;color:${LBL[i % 5]};line-height:1.2">${esc(p.label)}</div></td>`).join('') + `<div style="margin-top:6px;font-size:10px;font-weight:700;color:${LBL[i % 5]};line-height:1.2">${esc(p.label)}</div></td>`).join('')
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>` return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
+ `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">` + `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
@ -196,26 +323,86 @@ function inviteEmail (eventId, lang, name, rsvpUrl) {
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0">&nbsp;</td></tr>` + `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0">&nbsp;</td></tr>`
+ `<tr><td align="center" style="padding:22px 30px 0">` + `<tr><td align="center" style="padding:22px 30px 0">`
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>` + `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
+ `<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">🎉 20 ANS 🎉</div><br>` + (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"></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"></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>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>` + `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
+ `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` + (t.when ? `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` : '')
+ `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` + ((t.program || []).length ? `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` : '')
+ `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` + (t.program_line ? `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` : '')
+ `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` + (t.limited ? `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` : '')
+ `<tr><td align="center" style="padding:20px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px;box-shadow:0 6px 16px rgba(33,163,74,.3)"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:16px 40px;color:#fff;font-size:17px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>` + `<tr><td align="center" style="padding:20px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px;box-shadow:0 6px 16px rgba(33,163,74,.3)"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:16px 40px;color:#fff;font-size:17px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>`
+ `<tr><td align="center" style="padding:8px 30px 20px"><p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(merci)}</p>` + `<tr><td align="center" style="padding:8px 30px 20px"><p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(merci)}</p>`
+ `<p style="margin:6px 0 0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>` + `<p style="margin:6px 0 0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
+ `<tr><td style="background:#f0f7f2;padding:16px 30px;text-align:center;font-size:12px;line-height:1.6;color:#94a3b8;border-top:1px solid #e2e8f0">${esc(t.notes[0])}<br>${esc(t.notes[1])}` + `<tr><td style="background:#f0f7f2;padding:16px 30px;text-align:center;font-size:12px;line-height:1.6;color:#94a3b8;border-top:1px solid #e2e8f0">${(t.notes || []).map(esc).join('<br>')}`
+ `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #dce8e0"><a href="https://targo.ca" style="color:#21a34a;font-weight:700;text-decoration:none">🌐 targo.ca</a> &nbsp;·&nbsp; ${esc(t.foot)}</div></td></tr>` + `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #dce8e0"><a href="https://targo.ca" style="color:#21a34a;font-weight:700;text-decoration:none">🌐 targo.ca</a> &nbsp;·&nbsp; ${esc(t.foot)}</div></td></tr>`
+ `</table></td></tr></table></body></html>` + `</table></td></tr></table></body></html>`
} }
// ── Pièces jointes du courriel d'invitation ────────────────────────────────
const ATT_MAX_BYTES = 4 * 1024 * 1024 // 4 Mo par fichier
const ATT_MAX_COUNT = 6
const ATT_MIME_OK = /^(image\/(png|jpe?g|gif|webp)|application\/pdf)$/i
function eventAttDir (eventId) { const d = path.join(ATT_DIR, eventId); try { fs.mkdirSync(d, { recursive: true }) } catch { /* */ } return d }
function safeName (name) { return String(name || 'fichier').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'fichier' }
// Langue d'une pièce jointe : 'fr' | 'en' | 'both' (both = envoyée quelle que soit la langue du client).
function normAttLang (l) { l = String(l || '').toLowerCase(); return (l === 'fr' || l === 'en') ? l : 'both' }
// Détection best-effort de la langue d'une image (affiche/flyer) via Gemini Vision. 'both' si échec/ambigu.
const LANG_SCHEMA = { type: 'object', properties: { lang: { type: 'string', enum: ['fr', 'en', 'both'] } }, required: ['lang'] }
async function detectAttachmentLang (base64, mime) {
try {
if (!/^image\//i.test(String(mime || ''))) return 'both' // PDF & co : pas d'auto-détection
const r = await require('./vision').geminiVision(base64,
'This is a marketing/event flyer or poster image. Detect the PRIMARY written language of its text. Answer "fr" (French), "en" (English), or "both" if bilingual or if there is no meaningful text.',
LANG_SCHEMA, String(mime))
return normAttLang(r && r.lang)
} catch (e) { log('detectAttachmentLang: ' + e.message); return 'both' }
}
// Ajoute une pièce jointe (data base64) à un événement → persiste le fichier + métadonnée (dont langue).
// lang: 'fr'|'en'|'both' ; lang='auto' → détection Gemini best-effort (images seulement).
async function addAttachment (eventId, { filename, mime, data, lang }) {
const base = rawConfig(eventId); if (!base) return { error: 'event_not_found' }
if (!ATT_MIME_OK.test(String(mime || ''))) return { error: 'bad_type' }
const raw = String(data || '').replace(/^data:[^,]*,/, '')
const buf = Buffer.from(raw, 'base64')
if (!buf.length) return { error: 'empty' }
if (buf.length > ATT_MAX_BYTES) return { error: 'too_large' }
base.attachments = Array.isArray(base.attachments) ? base.attachments : []
if (base.attachments.length >= ATT_MAX_COUNT) return { error: 'too_many' }
const alang = String(lang || '').toLowerCase() === 'auto' ? await detectAttachmentLang(raw, mime) : normAttLang(lang)
const stored = Date.now().toString(36) + '-' + safeName(filename)
fs.writeFileSync(path.join(eventAttDir(eventId), stored), buf)
const meta = { filename: safeName(filename), stored, mime: String(mime), size: buf.length, lang: alang }
base.attachments.push(meta)
saveConfig(eventId, base)
return { attachment: meta, attachments: base.attachments }
}
function removeAttachment (eventId, stored) {
const base = rawConfig(eventId); if (!base) return { error: 'event_not_found' }
base.attachments = (base.attachments || []).filter(a => a.stored !== stored)
try { fs.unlinkSync(path.join(eventAttDir(eventId), stored)) } catch { /* déjà absent */ }
saveConfig(eventId, base)
return { attachments: base.attachments }
}
// Lit les pièces jointes en mémoire pour l'envoi (buffers), FILTRÉES par langue du destinataire.
// On joint les fichiers de SA langue + les fichiers 'both' (neutres). Ignore un fichier manquant.
function loadSendAttachments (event, lang) {
const want = normLang(lang) // 'fr' ou 'en'
const out = []
for (const a of (event.attachments || [])) {
const al = a.lang || 'both'
if (al !== 'both' && al !== want) continue
try { out.push({ filename: a.filename, mime: a.mime, buffer: fs.readFileSync(path.join(eventAttDir(event.id), a.stored)) }) } catch { /* fichier absent */ }
}
return out
}
// ── 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 = '' }) {
const event = getEvent(eventId)
const atts = 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) const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
@ -223,8 +410,13 @@ async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang
const subject = '[TEST] ' + inviteSubject(eventId, lang) const subject = '[TEST] ' + inviteSubject(eventId, lang)
try { try {
let ok let ok
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined }); ok = !!r } if (channel === 'gmail') {
else ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined }) const attachments = atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined, attachments }); ok = !!r
} else {
const attachments = atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined, attachments })
}
results.push({ email: em, ok: !!ok }) results.push({ email: em, ok: !!ok })
} catch (e) { results.push({ email: em, ok: false, error: e.message }) } } catch (e) { results.push({ email: em, ok: false, error: e.message }) }
} }
@ -271,6 +463,29 @@ function parseAudienceCsv (text) {
} }
// Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis). // Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis).
// mode : 'filter' (liste filtrée), 'csv' (import), 'manual' (pool de clients ajoutés un à un — filters.customer_ids[]).
const AUD_CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type', 'legacy_account_id']
function mapCustomerRow (c) { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } }
// Clients ayant une Service Location dans une ville. FUZZY : accent/casse-insensible (unaccent+lower)
// via le pool Postgres ERPNext (comme la recherche floue app-wide) ; repli REST `like` si pool indispo.
async function cityCustomerIds (city) {
const term = String(city || '').trim()
const ids = new Set()
if (!term) return ids
let pool = null
try { pool = require('./address-db').pool() } catch { pool = null }
if (pool) {
try {
const r = await pool.query(`SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL AND unaccent(lower(coalesce(city,''))) LIKE unaccent(lower($1))`, ['%' + term + '%'])
r.rows.forEach(x => { if (x.customer) ids.add(x.customer) })
return ids // PG autoritatif (même si 0)
} catch (e) { log('cityCustomerIds pg: ' + e.message) /* → repli REST */ }
}
const erp = require('./erp'); let s = 0
for (let p = 0; p < 80; p++) { const b = await erp.list('Service Location', { filters: [['city', 'like', '%' + term + '%']], fields: ['customer'], limit: 500, start: s }); b.forEach(x => { if (x.customer) ids.add(x.customer) }); if (b.length < 500) break; s += 500 }
return ids
}
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) { async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
let rows = [] let rows = []
let resiliatedExcluded = 0 let resiliatedExcluded = 0
@ -278,35 +493,55 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
const p = parseAudienceCsv(csv) const p = parseAudienceCsv(csv)
if (p.error) return { error: p.error } if (p.error) return { error: p.error }
rows = p.rows rows = p.rows
} else if (mode === 'manual') {
// Pool manuel : le staff a ajouté des clients un à un ; on re-résout les IDs → destinataires autoritatifs
// (courriel/langue frais depuis ERPNext). Choix EXPLICITE → pas d'exclusion des résiliés.
const erp = require('./erp')
const ids = [...new Set((filters.customer_ids || []).map(v => String(v).trim()).filter(Boolean))].slice(0, 5000)
if (!ids.length) return { error: 'empty_pool' }
const customers = []
for (let i = 0; i < ids.length; i += 100) { const b = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: AUD_CFIELDS, limit: 500 }); customers.push(...b) }
rows = customers.map(mapCustomerRow)
} else { } else {
const erp = require('./erp') const erp = require('./erp')
const f = [] const f = [] // filtres au niveau Customer
if (filters.statut === 'active') f.push(['disabled', '=', 0]) if (filters.statut === 'active') f.push(['disabled', '=', 0])
else if (filters.statut === 'disabled') f.push(['disabled', '=', 1]) else if (filters.statut === 'disabled') f.push(['disabled', '=', 1])
if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type', 'legacy_account_id'] if (filters.name_like) f.push(['customer_name', 'like', '%' + String(filters.name_like).trim() + '%']) // ex. « Proprio » → comptes dont le nom le contient
const minMonthly = Number(filters.min_monthly) || 0 const minMonthly = Number(filters.min_monthly) || 0
let customers = []
// Ensembles d'IDs à INTERSECTER (ville via Service Location, abonnements via Service Subscription).
let restrict = null // null = pas de restriction ; sinon Set d'IDs Customer
const intersect = (set) => { restrict = restrict ? new Set([...restrict].filter(x => set.has(x))) : set }
// VILLE → Service Location.customer (champ `city` peuplé, contrairement à `territory`).
if (filters.city && String(filters.city).trim()) intersect(await cityCustomerIds(String(filters.city)))
// ABONNEMENTS ACTIFS (somme mensuelle) → restriction + montant par client.
let byCust = null
if (filters.active_sub || minMonthly > 0) { if (filters.active_sub || minMonthly > 0) {
// Agrège les abonnements ACTIFS par client (somme mensuelle) ; filtre par minimum si demandé. byCust = {}; let s = 0
const byCust = {}; let s = 0
for (let p = 0; p < 40; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer', 'monthly_price'], limit: 500, start: s }); b.forEach(x => { if (x.customer) byCust[x.customer] = (byCust[x.customer] || 0) + (Number(x.monthly_price) || 0) }); if (b.length < 500) break; s += 500 } for (let p = 0; p < 40; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer', 'monthly_price'], limit: 500, start: s }); b.forEach(x => { if (x.customer) byCust[x.customer] = (byCust[x.customer] || 0) + (Number(x.monthly_price) || 0) }); if (b.length < 500) break; s += 500 }
let ids = Object.keys(byCust) let subIds = Object.keys(byCust)
if (minMonthly > 0) ids = ids.filter(c => byCust[c] >= minMonthly) if (minMonthly > 0) subIds = subIds.filter(c => byCust[c] >= minMonthly)
for (let i = 0; i < ids.length; i += 100) { intersect(new Set(subIds))
const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: CFIELDS, limit: 500 }) }
customers.push(...b)
} let customers = []
customers.forEach(c => { c._monthly = byCust[c.name] || 0 }) if (restrict) {
const ids = [...restrict]
for (let i = 0; i < ids.length; i += 100) { const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: AUD_CFIELDS, limit: 500 }); customers.push(...b) }
} else { } else {
let s = 0 let s = 0
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: 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 })
const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions) const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions)
const before = customers.length const before = customers.length
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id))) customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
resiliatedExcluded = before - customers.length resiliatedExcluded = before - customers.length
rows = customers.map(c => { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } }) 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
for (const r of rows) { for (const r of rows) {
@ -321,21 +556,65 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail, resiliated_excluded: resiliatedExcluded } return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail, resiliated_excluded: resiliatedExcluded }
} }
// ── Liste principale d'audience (ADDITIVE, persistée par événement) ─────────
// Le staff construit UNE liste en additionnant plusieurs lots (filtre ville A, puis filtre ville B,
// puis sélections manuelles, puis CSV…). Dédup par courriel (insensible à la casse). Chaque
// destinataire porte son `source` (libellé du lot) pour la ventilation. C'est CETTE liste qu'un
// futur envoi de masse consommera.
function audienceFile (id) { return path.join(AUDIENCE_DIR, `${id}.json`) }
function loadAudienceList (id) { const d = readJsonFile(audienceFile(id), null); return (d && Array.isArray(d.recipients)) ? d : { recipients: [], updated: '' } }
function saveAudienceList (id, a) { writeJsonFile(audienceFile(id), a) }
function bySource (a) { const m = {}; for (const r of a.recipients) { const s = r.source || '—'; m[s] = (m[s] || 0) + 1 } return m }
function audienceSummary (a) { return { count: a.recipients.length, by_source: bySource(a), updated: a.updated || '', sample: a.recipients.slice(0, 200).map(r => ({ email: r.email, name: ((r.firstname || '') + ' ' + (r.lastname || '')).trim() || r.email, customer_id: r.customer_id || '', language: r.language || 'fr', source: r.source || '' })) } }
// Résout un lot (filtre/manuel/CSV) et l'AJOUTE à la liste principale (dédup courriel).
async function audienceListAdd (id, { mode, filters, csv, source_label }) {
const r = await resolveAudience({ mode, filters, csv })
if (r.error) return { error: r.error }
const a = loadAudienceList(id)
const seen = new Set(a.recipients.map(x => String(x.email || '').toLowerCase()))
const label = String(source_label || '').slice(0, 80) || (mode === 'csv' ? 'CSV' : mode === 'manual' ? 'Sélection manuelle' : 'Filtre')
let added = 0
for (const rec of r.recipients) {
const k = String(rec.email || '').toLowerCase()
if (!k || seen.has(k)) continue
seen.add(k)
a.recipients.push({ email: rec.email, firstname: rec.firstname || '', lastname: rec.lastname || '', language: rec.language || 'fr', customer_id: rec.customer_id || '', customer_type: rec.customer_type || '', monthly: rec.monthly || 0, source: label })
added++
}
a.updated = new Date().toISOString()
saveAudienceList(id, a)
return { added, duplicates: r.count - added, dropped_no_email: r.dropped_no_email || 0, resiliated_excluded: r.resiliated_excluded || 0, ...audienceSummary(a) }
}
function audienceListRemove (id, email) {
const a = loadAudienceList(id)
const before = a.recipients.length
a.recipients = a.recipients.filter(r => String(r.email || '').toLowerCase() !== String(email || '').toLowerCase())
a.updated = new Date().toISOString()
saveAudienceList(id, a)
return { removed: before - a.recipients.length, ...audienceSummary(a) }
}
function audienceListClear (id) { saveAudienceList(id, { recipients: [], updated: new Date().toISOString() }); return { count: 0, by_source: {}, sample: [] } }
// ── Page publique (festive, mobile-first, bilingue) ──────────────────────── // ── Page publique (festive, mobile-first, bilingue) ────────────────────────
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) { function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}` return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
} }
function page (event, lang, prefill = {}) { function page (event, lang, prefill = {}, opts = {}) {
lang = normLang(lang) lang = normLang(lang)
const t = event[lang] const t = event[lang]
const other = lang === 'fr' ? 'en' : 'fr' const other = lang === 'fr' ? 'en' : 'fr'
const known = !!prefill.customer_id const known = !!prefill.customer_id
const full = !!opts.full
const greet = t.greet(firstName(prefill.name)) const greet = t.greet(firstName(prefill.name))
const program = t.program.map(p => const program = (t.program || []).map(p =>
`<div class="prog"><div class="prog-ic">${p.icon}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>` `<div class="prog"><div class="prog-ic">${esc(p.icon)}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>`
).join('') ).join('')
const notes = t.notes.map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('') const notes = (t.notes || []).map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('')
const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>` const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>`
const badgeHtml = event.badge_top || event.badge_bottom
? `<div class="badge"><b>${esc(event.badge_top || '🎉')}</b>${event.badge_bottom ? `<span>${esc(event.badge_bottom)}</span>` : ''}</div>`
: `<div class="badge emoji">🎉</div>`
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(t.name)}</title> <title>${esc(t.name)}</title>
<style> <style>
@ -351,6 +630,7 @@ body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-s
.badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%; .badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;
background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px} background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px}
.badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px} .badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px}
.badge.emoji{font-size:2rem}
.logo{height:34px;width:auto;display:block;margin:0 auto 14px} .logo{height:34px;width:auto;display:block;margin:0 auto 14px}
h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)} h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)}
.tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px} .tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px}
@ -384,26 +664,38 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px} .thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px} .thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px}
.recog{font-weight:600;color:var(--g);margin:0 0 10px} .recog{font-weight:600;color:var(--g);margin:0 0 10px}
.full{text-align:center;padding:16px 6px}
.full .big{font-size:2.6rem;line-height:1;margin-bottom:12px}
.full h2{color:var(--ink);font-size:1.35rem;margin:0 0 10px}
.full p{color:var(--muted)}
.hidden{display:none} .hidden{display:none}
</style></head> </style></head>
<body><div class="confetti"></div><div class="wrap"> <body><div class="confetti"></div><div class="wrap">
<div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div> <div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div>
<div class="head"> <div class="head">
<div class="badge"><b>20</b><span>ANS</span></div> ${badgeHtml}
<img class="logo" src="${LOGO}" alt="TARGO"> <img class="logo" src="${LOGO}" alt="TARGO">
<h1>${esc(t.name)}</h1> <h1>${esc(t.name)}</h1>
<div class="tag">${esc(t.tagline)}</div> <div class="tag">${esc(t.tagline)}</div>
</div> </div>
<div id="invite" class="card"> <div id="invite" class="card">
<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div> ${t.when ? `<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>` : ''}
<p>${esc(t.body)}</p> <p>${esc(t.body)}</p>
<div class="prog-wrap">${program}</div> ${program ? `<div class="prog-wrap">${program}</div>` : ''}
<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p> ${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}
<div class="limited"><span class="i"></span><p>${esc(t.limited)}</p></div> ${t.limited ? `<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>` : ''}
</div> </div>
<div id="formCard" class="card"> <div id="fullCard" class="card${full ? '' : ' hidden'}">
<div class="full">
<div class="big">🎪</div>
<h2>${esc(t.full_title)}</h2>
<p>${esc(t.full_msg)}</p>
</div>
</div>
<div id="formCard" class="card${full ? ' hidden' : ''}">
${greet ? `<div class="greet">${esc(greet)}</div>` : ''} ${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
<form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on"> <form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on">
${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)} ${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)}
@ -460,6 +752,7 @@ function submitRsvp(e){
if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')} if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')}
else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'} else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'}
hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'}) hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})
}else if(d&&d.full){hide('formCard');show('fullCard');window.scrollTo({top:0,behavior:'smooth'})
}else{b.disabled=false;document.getElementById('e_gen').style.display='block'} }else{b.disabled=false;document.getElementById('e_gen').style.display='block'}
}) })
.catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'}); .catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
@ -497,8 +790,15 @@ async function handle (req, res, method, path, url) {
const p = verifyJwt(token) const p = verifyJwt(token)
if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang } if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang }
} }
// Capacité (personnes) : plein → message « complet », SAUF si cet inscrit existe déjà (peut modifier).
let full = false
if (event.capacity > 0) {
const m = loadResp(event.id)
const existing = prefill.customer_id ? m['c:' + prefill.customer_id] : null
full = headcountOf(m) >= event.capacity && !existing
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(page(event, lang, prefill)) return res.end(page(event, lang, prefill, { full }))
} }
// Soumission publique : POST /rsvp/<eventId>/submit // Soumission publique : POST /rsvp/<eventId>/submit
@ -521,6 +821,11 @@ async function handle (req, res, method, path, url) {
const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || '' const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || ''
const m = loadResp(eventId) const m = loadResp(eventId)
const prev = m[key] || {} const prev = m[key] || {}
// Enforcement CAPACITÉ (personnes) : bloque une NOUVELLE inscription quand le plafond est atteint.
// Un inscrit existant peut toujours mettre à jour sa réponse (déjà compté).
if (event.capacity > 0 && !m[key] && headcountOf(m) >= event.capacity) {
return json(res, 200, { ok: false, full: true, error: 'event_full' })
}
m[key] = { m[key] = {
event_id: eventId, event_id: eventId,
ip, ip,
@ -547,12 +852,52 @@ async function handle (req, res, method, path, url) {
// Staff : GET /events // Staff : GET /events
if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() }) if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() })
// Staff : POST /events (créer un événement)
if (path === '/events' && method === 'POST') {
const b = await parseBody(req)
const fr = (b.fr || {})
if (!String(fr.name || '').trim()) return json(res, 400, { error: 'name_required' })
const id = uniqueId(slugify(b.id || fr.name))
if (!id) return json(res, 400, { error: 'bad_id' })
const conf = normalizeConfig(b)
conf.id = id
saveConfig(id, conf)
log(`Event created — ${id}`)
const e = getEvent(id)
return json(res, 200, { ok: true, event: { id: e.id, name: e.fr.name, date_iso: e.date_iso, capacity: e.capacity, public_url: `${pub()}/rsvp/${e.id}` } })
}
// Staff : GET /events/<id> (config brute éditable — pour le formulaire d'édition)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)$/)
if (mm && method === 'GET') {
const base = rawConfig(mm[1]); if (!base) return json(res, 404, { error: 'event_not_found' })
return json(res, 200, { event: { ...base, public_url: `${pub()}/rsvp/${mm[1]}` } })
}
// Staff : PUT /events/<id> (éditer) · DELETE /events/<id>
if (mm && method === 'PUT') {
const existing = rawConfig(mm[1]); if (!existing) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const fr = (b.fr || {})
if (!String(fr.name || '').trim()) return json(res, 400, { error: 'name_required' })
const conf = normalizeConfig(b, existing)
conf.id = mm[1]
saveConfig(mm[1], conf)
log(`Event updated — ${mm[1]}`)
return json(res, 200, { ok: true })
}
if (mm && method === 'DELETE') {
// Ne supprime que le fichier config (les événements semés reviennent à leur défaut).
try { fs.unlinkSync(configFile(mm[1])) } catch { /* absent */ }
log(`Event config deleted — ${mm[1]}`)
return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] })
}
// 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, 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, 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\/(.+)$/)
@ -560,6 +905,21 @@ async function handle (req, res, method, path, url) {
const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2])) const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
return json(res, ok ? 200 : 404, { ok }) return json(res, ok ? 200 : 404, { ok })
} }
// Staff : POST /events/<id>/attachments {filename, mime, data(base64)} · DELETE /events/<id>/attachments/<stored>
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/attachments$/)
if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const r = await addAttachment(mm[1], { filename: b.filename, mime: b.mime, data: b.data, lang: b.lang })
if (r.error) return json(res, 400, { error: r.error })
return json(res, 200, { ok: true, attachment: r.attachment, attachments: r.attachments })
}
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/attachments\/(.+)$/)
if (mm && method === 'DELETE') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const r = removeAttachment(mm[1], decodeURIComponent(mm[2]))
return json(res, 200, { ok: true, attachments: r.attachments || [] })
}
// Staff : POST /events/<id>/send (mode TEST uniquement ; masse = pas encore activé) // Staff : POST /events/<id>/send (mode TEST uniquement ; masse = pas encore activé)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/)
if (mm && method === 'POST') { if (mm && method === 'POST') {
@ -576,7 +936,7 @@ async function handle (req, res, method, path, url) {
} }
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." }) return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
} }
// Staff : POST /events/<id>/audience {mode:'filter'|'csv', filters, csv} → aperçu (décompte + échantillon) // 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') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' }) if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
@ -586,8 +946,37 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, resiliated_excluded: r.resiliated_excluded || 0, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) }) return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, resiliated_excluded: r.resiliated_excluded || 0, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) })
} }
// Staff : LISTE PRINCIPALE d'audience (additive, persistée)
// GET /events/<id>/audience-list → { count, by_source, sample, updated }
// POST /events/<id>/audience-list/add {mode,filters,csv,source_label} → résout un lot + l'ajoute (dédup)
// POST /events/<id>/audience-list/remove {email} → retire un destinataire
// DELETE /events/<id>/audience-list → vide la liste
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list$/)
if (mm && method === 'GET') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
return json(res, 200, audienceSummary(loadAudienceList(mm[1])))
}
if (mm && method === 'DELETE') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
return json(res, 200, { ok: true, ...audienceListClear(mm[1]) })
}
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list\/add$/)
if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const r = await audienceListAdd(mm[1], { mode: b.mode, filters: b.filters || {}, csv: b.csv || '', source_label: b.source_label })
if (r.error) return json(res, 400, { error: r.error })
return json(res, 200, { ok: true, ...r })
}
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list\/remove$/)
if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
return json(res, 200, { ok: true, ...audienceListRemove(mm[1], b.email) })
}
return json(res, 404, { error: 'not found' }) return json(res, 404, { error: 'not found' })
} 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, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang } module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }