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:
parent
893ac8cd4a
commit
3aa59adec3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }) }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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'}`)
|
||||
|
|
|
|||
|
|
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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"> </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> · ${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 }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user