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_buffering off;
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

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 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 }) }
// 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') }
// 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">
<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="row items-center ops-row-wrap q-gutter-sm">
<div class="ev-badge"><b>20</b><span>ANS</span></div>
<div class="col" style="min-width:0">
<div class="text-h6 text-weight-bold ellipsis">{{ ev.name || 'Targo fête ses 20 ans' }}</div>
<div class="ev-badge">
<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>
<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">
<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>
<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>
<!-- KPIs -->
<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="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>
@ -40,6 +51,9 @@
<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.
</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" />
<div class="text-caption text-grey-5 q-mt-sm">Aucun courriel n'est envoyé sans confirmation.</div>
</div>
@ -68,7 +82,8 @@
no-data-label="Aucune inscription pour l'instant.">
<template #body-cell-client="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>
</q-td>
</template>
@ -107,6 +122,97 @@
</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) -->
<q-dialog v-model="showSend">
<q-card style="min-width:340px;max-width:560px;width:100%">
@ -122,8 +228,9 @@
<!-- TEST -->
<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="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-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-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 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>
<q-btn-toggle v-model="audienceSource" spread no-caps dense @update:model-value="audPreview = null"
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
<!-- LISTE PRINCIPALE (additive, persistée) -->
<div class="ops-card q-pa-sm" style="border-radius:10px;border:1px solid var(--ops-border,#e0e0e0)">
<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 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.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.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" />
</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>
<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>
</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 v-else class="q-gutter-sm">
@ -154,29 +329,9 @@
<template #prepend><q-icon name="upload_file" /></template>
</q-file>
<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>
<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">
<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.
@ -202,15 +357,21 @@ import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
import { useCsvExport } from 'src/composables/useCsvExport'
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 { exportCsv } = useCsvExport()
const EVENT_ID = 'fete-20-ans'
const loading = ref(false)
const error = ref('')
const events = ref([])
const eventId = ref('')
const ev = ref({})
const attachments = ref([])
const rows = ref([])
const count = ref(0)
const headcount = ref(0)
@ -221,20 +382,31 @@ const { userEmail } = usePermissions()
const showSend = ref(false)
const sendChannel = ref('mailjet')
const testEmail = ref('')
const testLang = ref('fr')
const sending = ref(false)
// Envoi de masse (audience + modèle) aperçu seulement ; le blast reste à activer.
const sendMode = ref('test')
const template = ref('')
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
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 audCsvText = ref('')
const audPreview = ref(null)
const previewing = ref(false)
const showNoEmail = ref(false)
// Pool manuel : clients sélectionnés avant ajout à la liste
const pool = ref([])
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 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 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 () {
if (!events.value.length) await loadEventList()
if (!eventId.value) { loading.value = false; return }
loading.value = true; error.value = ''
try {
const d = await listRsvps(EVENT_ID)
const d = await listRsvps(eventId.value)
ev.value = d.event || {}
attachments.value = (d.event && d.event.attachments) || []
rows.value = d.responses || []
count.value = d.count || 0
headcount.value = d.headcount || 0
@ -277,6 +465,8 @@ async function load () {
} finally { loading.value = false }
}
function onSelectEvent () { load() }
function copy (txt) {
if (!txt) return
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 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), '', '', '', '', '', '', '', '']
exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow })
exportCsv(`inscriptions_${eventId.value}`, headers, data, { totalRow })
}
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 })
.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 }) }
})
}
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 () {
try {
const d = await listTemplates()
@ -308,26 +627,96 @@ async function loadTemplates () {
} catch (e) { /* garde le défaut */ }
}
function onCsvFile (file) {
audPreview.value = null
if (!file) { audCsvText.value = ''; return }
const rd = new FileReader()
rd.onload = () => { audCsvText.value = String(rd.result || '') }
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
async function doPreview () {
previewing.value = true; audPreview.value = null; showNoEmail.value = false
function onActiveSubToggle (v) { if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
// Pool manuel : recherche + sélection avant ajout
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 {
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value }
audPreview.value = await previewAudience(EVENT_ID, body)
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { previewing.value = false }
const r = await audienceListAdd(eventId.value, { mode: 'manual', filters: { customer_ids: pool.value.map(p => p.customer_id) }, source_label: 'Sélection manuelle' })
applyListResult(r, 'add'); pool.value = []
} 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 () {
const em = (testEmail.value || '').trim()
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
sending.value = true
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' })
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 }

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 {
const info = await transport.sendMail(mailOpts)
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.
* - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate),
* 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),
* DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test).
* - Endpoints STAFF (gatés) : GET /events, POST /events (créer), PUT/DELETE /events/<id>,
* 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
* (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission,
@ -24,38 +38,22 @@ const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmai
const { generateCustomerToken, verifyJwt } = require('./magic-link')
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 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 normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
// ── Config événements (semé : Fête Targo 20 ans) ───────────────────────────
const SEED = {
'fete-20-ans': {
id: 'fete-20-ans',
active: true,
date_iso: '2026-08-01',
// ── Libellés d'interface COMMUNS (indépendants de l'événement) ─────────────
// Séparés du contenu éditable : mêmes champs/erreurs/remerciements pour tous les événements.
const LABELS = {
fr: {
name: 'Targo fête ses 20 ans',
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
when: '1ᵉʳ août, de 10 h à 15 h',
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: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
{ icon: '🎧', label: 'DJ' },
{ icon: '🎪', label: 'Jeux gonflables' },
{ icon: '🎨', label: 'Maquillage' },
{ icon: '🎁', label: 'Et des surprises' },
],
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.",
closing: "Nous avons hâte de vous retrouver pour célébrer ensemble !",
notes: [
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
"Vous recevrez un coupon repas sur place (à l'accueil).",
],
f_name: 'Votre nom',
f_email: 'Votre adresse courriel',
f_phone: 'Votre téléphone',
@ -67,7 +65,7 @@ const SEED = {
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_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.',
@ -76,26 +74,10 @@ const SEED = {
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: {
name: 'Targo turns 20!',
tagline: 'The whole Targo team is happy to invite you to our big anniversary celebration!',
when: 'August 1st, 10 a.m. to 3 p.m.',
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' },
{ icon: '🎧', label: 'DJ' },
{ icon: '🎪', label: 'Bouncy castles' },
{ icon: '🎨', label: 'Face painting' },
{ icon: '🎁', label: 'And surprises' },
],
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.',
closing: "We can't wait to celebrate together!",
notes: [
'If you have allergies, the menu may not suit you.',
'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',
@ -107,7 +89,7 @@ const SEED = {
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_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.',
@ -116,17 +98,161 @@ const SEED = {
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': {
id: 'fete-20-ans',
active: true,
date_iso: '2026-08-01',
badge_top: '20',
badge_bottom: 'ANS',
capacity: 0,
attachments: [],
fr: {
name: 'Targo fête ses 20 ans',
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
when: '1ᵉʳ août, de 10 h à 15 h',
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: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
{ icon: '🎧', label: 'DJ', sub: '' },
{ icon: '🎪', label: 'Jeux gonflables', sub: '' },
{ icon: '🎨', label: 'Maquillage', sub: '' },
{ icon: '🎁', label: 'Et des surprises', sub: '' },
],
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.",
closing: "Nous avons hâte de vous retrouver pour célébrer ensemble !",
notes: [
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
"Vous recevrez un coupon repas sur place (à l'accueil).",
],
},
en: {
name: 'Targo turns 20!',
tagline: 'The whole Targo team is happy to invite you to our big anniversary celebration!',
when: 'August 1st, 10 a.m. to 3 p.m.',
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' },
{ icon: '🎧', label: 'DJ', sub: '' },
{ icon: '🎪', label: 'Bouncy castles', sub: '' },
{ icon: '🎨', label: 'Face painting', sub: '' },
{ icon: '🎁', label: 'And surprises', sub: '' },
],
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.',
closing: "We can't wait to celebrate together!",
notes: [
'If you have allergies, the menu may not suit you.',
'You will receive a meal coupon on site (at the welcome desk).',
],
},
},
}
function getEvent (eventId) { return SEED[eventId] || null }
function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) }
// ── Persistance des configs (fichier shadow le SEED ; édition/attachments écrivent ici) ──
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) ────────────
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 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]+$/
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 ''
lang = normLang(lang); const t = e[lang]
const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚'
const badge = [e.badge_top, e.badge_bottom].filter(Boolean).join(' ')
const CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO)
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">`
+ `<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('')
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">`
@ -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 align="center" style="padding:22px 30px 0">`
+ `<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>`
+ `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>`
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>`
+ `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
+ `<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>`
+ `<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>`
+ `<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.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>` : '')
+ ((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>` : '')
+ (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>` : '')
+ (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: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>`
+ `<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>`
+ `</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) ──
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 = []
for (const em of emails) {
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)
try {
let ok
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined }); ok = !!r }
else ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined })
if (channel === 'gmail') {
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 })
} 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).
// 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 = '' } = {}) {
let rows = []
let resiliatedExcluded = 0
@ -278,35 +493,55 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
const p = parseAudienceCsv(csv)
if (p.error) return { error: p.error }
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 {
const erp = require('./erp')
const f = []
const f = [] // filtres au niveau Customer
if (filters.statut === 'active') f.push(['disabled', '=', 0])
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
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
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) {
// Agrège les abonnements ACTIFS par client (somme mensuelle) ; filtre par minimum si demandé.
const byCust = {}; let s = 0
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 }
let ids = Object.keys(byCust)
if (minMonthly > 0) ids = ids.filter(c => byCust[c] >= minMonthly)
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: CFIELDS, limit: 500 })
customers.push(...b)
let subIds = Object.keys(byCust)
if (minMonthly > 0) subIds = subIds.filter(c => byCust[c] >= minMonthly)
intersect(new Set(subIds))
}
customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
let customers = []
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 {
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 before = customers.length
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
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
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 }
}
// ── 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) ────────────────────────
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
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)
const t = event[lang]
const other = lang === 'fr' ? 'en' : 'fr'
const known = !!prefill.customer_id
const full = !!opts.full
const greet = t.greet(firstName(prefill.name))
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>`
const program = (t.program || []).map(p =>
`<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('')
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 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">
<title>${esc(t.name)}</title>
<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%;
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.emoji{font-size:2rem}
.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)}
.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 h2{color:var(--g);font-size:1.4rem;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}
</style></head>
<body><div class="confetti"></div><div class="wrap">
<div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div>
<div class="head">
<div class="badge"><b>20</b><span>ANS</span></div>
${badgeHtml}
<img class="logo" src="${LOGO}" alt="TARGO">
<h1>${esc(t.name)}</h1>
<div class="tag">${esc(t.tagline)}</div>
</div>
<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>
<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>
<div class="limited"><span class="i"></span><p>${esc(t.limited)}</p></div>
${program ? `<div class="prog-wrap">${program}</div>` : ''}
${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}
${t.limited ? `<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></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>` : ''}
<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)}
@ -460,6 +752,7 @@ function submitRsvp(e){
if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')}
else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'}
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'}
})
.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)
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' })
return res.end(page(event, lang, prefill))
return res.end(page(event, lang, prefill, { full }))
}
// 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 m = loadResp(eventId)
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] = {
event_id: eventId,
ip,
@ -547,12 +852,52 @@ async function handle (req, res, method, path, url) {
// Staff : GET /events
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
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
if (mm && method === 'GET') {
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
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>
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]))
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é)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/)
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." })
}
// 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$/)
if (mm && method === 'POST') {
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) })
}
// 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' })
} 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 }