diff --git a/apps/ops/infra/nginx.conf b/apps/ops/infra/nginx.conf index fe81bc9..1f85fd8 100644 --- a/apps/ops/infra/nginx.conf +++ b/apps/ops/infra/nginx.conf @@ -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 diff --git a/apps/ops/src/api/events.js b/apps/ops/src/api/events.js index 573eb0e..01a541e 100644 --- a/apps/ops/src/api/events.js +++ b/apps/ops/src/api/events.js @@ -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' }) } diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue index 1024523..2153c10 100644 --- a/apps/ops/src/pages/EventsPage.vue +++ b/apps/ops/src/pages/EventsPage.vue @@ -2,24 +2,35 @@ - +
-
20ANS
-
-
{{ ev.name || 'Targo fête ses 20 ans' }}
+
+ + 🎉 +
+
+ +
{{ ev.name || '—' }}
- {{ ev.when || '1ᵉʳ août, de 10 h à 15 h' }} + {{ ev.when || '—' }} + plafond {{ ev.capacity }} pers.
- Rafraîchir + + + Rafraîchir
-
+
+ +
@@ -40,6 +51,9 @@
Chaque client reçoit un lien personnalisé : son numéro est pré-rempli et son inscription est rattachée automatiquement.
+
+ {{ attachments.length }} pièce(s) jointe(s) au courriel +
Aucun courriel n'est envoyé sans confirmation.
@@ -68,7 +82,8 @@ no-data-label="Aucune inscription pour l'instant."> @@ -107,6 +122,97 @@ + + + + + +
{{ formMode === 'create' ? 'Nouvel événement' : 'Modifier l\'événement' }}
+ +
+ + +
+ + +
+
+ + + + + +
+
+ + + + + + + + +
Laissez vide pour reprendre le texte français automatiquement.
+ + + + + +
Programme
+
+ + + + +
+ + + + + + +
Notes de bas de page
+
+ + +
+ +
+
+ + + +
+ + + + + + + +
+
+ @@ -122,8 +228,9 @@ -
Envoi d'un test (courriel avec lien personnalisé) à ton adresse.
+
Envoi d'un test (courriel avec lien personnalisé{{ attachments.length ? ' + pièces jointes de la langue choisie' : '' }}) à ton adresse.
+
@@ -132,21 +239,89 @@
Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.
+
{{ attachments.length }} pièce(s) jointe(s) — gérées dans « Modifier ».
-
Audience
- + +
+
+
Liste d'envoi : {{ mainList.count }} destinataire(s)
+ + +
+
+ {{ s.label }} · {{ s.n }} +
+
+ {{ showListSample ? '▾ Masquer' : '▸ Voir' }} les destinataires + + + + {{ r.name }} + {{ r.email }} · {{ r.source }} + + + + …et {{ mainList.count - mainList.sample.length }} autres (affichage plafonné à 200) + +
+
Ajoutez des lots ci-dessous (par filtre, sélection ou CSV). Ils s'additionnent ici, sans doublon.
+
+ +
Ajouter des destinataires
+
- - + + +
+
+ + + + + +
- + -
Le « total mensuel » (somme des abonnements actifs) requiert d'activer « Abonnement actif ». « Résidentiel/Commercial » = type de compte.
+
« Ville » filtre par lieu de service ; « Nom contient » par sous-chaîne. Les critères se combinent (ET). Le « total mensuel » requiert « Abonnement actif ».
+ +
+ +
+ + + + + +
+ + + + {{ p.customer_name || p.customer_id }} + {{ p.customer_id }} · {{ p.email }} · sans courriel (sera ignoré) + + + + + +
+
Cherchez un client et cliquez pour le sélectionner, puis ajoutez-les à la liste d'envoi.
@@ -154,29 +329,9 @@
Colonne obligatoire : email. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.
+
- - - - {{ audPreview.count }} destinataire(s) · {{ audPreview.dropped_no_email }} sans courriel écarté(s) · {{ audPreview.resiliated_excluded }} ex-client(s) résilié(s) exclus -
- ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}… -
-
- - {{ showNoEmail ? '▾ Masquer' : '▸ Voir' }} les {{ audPreview.dropped_no_email }} comptes sans courriel (à valider) - -
-
- {{ ne.name }} - · {{ ne.customer_id }} -
-
…et {{ audPreview.dropped_no_email - audPreview.no_email.length }} autres (liste plafonnée à 300)
-
-
-
- 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 } diff --git a/services/targo-hub/lib/email.js b/services/targo-hub/lib/email.js index 056aa75..50ed68c 100644 --- a/services/targo-hub/lib/email.js +++ b/services/targo-hub/lib/email.js @@ -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'}`) diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js index ef2492c..9fd1036 100644 --- a/services/targo-hub/lib/events.js +++ b/services/targo-hub/lib/events.js @@ -7,8 +7,22 @@ * ?t= → pré-remplit nom/courriel + rattache d'office. * - POST /rsvp//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//rsvps (+ décompte + confiance), - * DELETE /events//rsvps/, POST /events//send (mode test). + * - Endpoints STAFF (gatés) : GET /events, POST /events (créer), PUT/DELETE /events/, + * GET /events//rsvps (+ décompte + confiance), DELETE /events//rsvps/, + * POST /events//send (mode test), POST /events//audience (aperçu), + * POST /events//attachments (téléverser une pièce jointe image), DELETE /events//attachments/. + * + * 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//), 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,19 +38,83 @@ 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 = { +// ── 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: { + f_name: 'Votre nom', + f_email: 'Votre adresse courriel', + f_phone: 'Votre téléphone', + f_number: 'Votre numéro client', + f_party: 'Combien serez-vous ?', + f_allerg: 'Allergies ou restrictions alimentaires', + opt: '(optionnel)', + f_party_ph: 'Nombre de personnes', + submit: 'Confirmer ma présence', + greet: (n) => n ? `Bonjour ${n} !` : '', + thanks_title: 'Merci, votre présence est confirmée ! 🎉', + thanks_body: "On a hâte de vous voir. Vous recevrez votre coupon repas à l'accueil.", + thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`, + thanks_tocheck: 'Nous validerons votre inscription sous peu.', + thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.', + err_name: 'Veuillez entrer votre nom.', + err_party: 'Veuillez indiquer combien de personnes seront présentes.', + err_email: 'Veuillez entrer une adresse courriel valide.', + err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.", + foot: 'TARGO — votre fournisseur Internet local', + full_title: 'Inscriptions complètes', + full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.", + }, + en: { + f_name: 'Your name', + f_email: 'Your email address', + f_phone: 'Your phone', + f_number: 'Your customer number', + f_party: 'How many will attend?', + f_allerg: 'Allergies or dietary restrictions', + opt: '(optional)', + f_party_ph: 'Number of people', + submit: 'Confirm my attendance', + greet: (n) => n ? `Hi ${n}!` : '', + thanks_title: "Thank you, you're confirmed! 🎉", + thanks_body: 'We look forward to seeing you. Pick up your meal coupon at the welcome desk.', + thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`, + thanks_tocheck: "We'll confirm your registration shortly.", + thanks_update: 'You can change your answer anytime from this link.', + err_name: 'Please enter your name.', + err_party: 'Please tell us how many people will attend.', + err_email: 'Please enter a valid email address.', + err_generic: 'Something went wrong. Please try again or contact us.', + foot: 'TARGO — your local Internet provider', + full_title: 'Registration full', + full_msg: 'Unfortunately, the event booking is full.', + }, +} + +// ── Contenu éditable des événements (semé : Fête Targo 20 ans) ───────────── +// Forme brute persistée : { id, active, date_iso, badge_top, badge_bottom, capacity, attachments[], +// fr:{ name, tagline, when, body, program:[{icon,label,sub}], program_line, limited, closing, notes[] }, en:{...} } +const SEED_CONTENT = { 'fete-20-ans': { 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 !", @@ -44,10 +122,10 @@ const SEED = { body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.", program: [ { icon: '🚚', label: 'Food truck', sub: 'repas inclus' }, - { icon: '🎧', label: 'DJ' }, - { icon: '🎪', label: 'Jeux gonflables' }, - { icon: '🎨', label: 'Maquillage' }, - { icon: '🎁', label: 'Et des surprises' }, + { 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.", @@ -56,26 +134,6 @@ const SEED = { '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', - f_number: 'Votre numéro client', - f_party: 'Combien serez-vous ?', - f_allerg: 'Allergies ou restrictions alimentaires', - opt: '(optionnel)', - f_party_ph: 'Nombre de personnes', - submit: 'Confirmer ma présence', - greet: (n) => n ? `Bonjour ${n} !` : '', - thanks_title: 'Merci, votre présence est confirmée ! 🎉', - thanks_body: "On a hâte de vous voir le 1ᵉʳ août. Vous recevrez votre coupon repas à l'accueil.", - thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`, - thanks_tocheck: 'Nous validerons votre inscription sous peu.', - thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.', - err_name: 'Veuillez entrer votre nom.', - err_party: 'Veuillez indiquer combien de personnes seront présentes.', - err_email: 'Veuillez entrer une adresse courriel valide.', - err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.", - foot: 'TARGO — votre fournisseur Internet local', }, en: { name: 'Targo turns 20!', @@ -84,10 +142,10 @@ const SEED = { body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.', program: [ { icon: '🚚', label: 'Food truck', sub: 'meal included' }, - { icon: '🎧', label: 'DJ' }, - { icon: '🎪', label: 'Bouncy castles' }, - { icon: '🎨', label: 'Face painting' }, - { icon: '🎁', label: 'And surprises' }, + { 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.', @@ -96,37 +154,105 @@ const SEED = { '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', - f_number: 'Your customer number', - f_party: 'How many will attend?', - f_allerg: 'Allergies or dietary restrictions', - opt: '(optional)', - f_party_ph: 'Number of people', - submit: 'Confirm my attendance', - greet: (n) => n ? `Hi ${n}!` : '', - thanks_title: "Thank you, you're confirmed! 🎉", - thanks_body: 'We look forward to seeing you on August 1st. Pick up your meal coupon at the welcome desk.', - thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`, - thanks_tocheck: "We'll confirm your registration shortly.", - thanks_update: 'You can change your answer anytime from this link.', - err_name: 'Please enter your name.', - err_party: 'Please tell us how many people will attend.', - err_email: 'Please enter a valid email address.', - err_generic: 'Something went wrong. Please try again or contact us.', - foot: 'TARGO — your local Internet provider', }, }, } -function getEvent (eventId) { return SEED[eventId] || null } -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) => `` - + `
${p.icon}
` + + `
${esc(p.icon)}
` + `
${esc(p.label)}
`).join('') return `` + `` @@ -196,26 +323,86 @@ function inviteEmail (eventId, lang, name, rsvpUrl) { + ` ` + `` + `
🎈🎉🎈
` - + `
🎉 20 ANS 🎉

` + + (badge ? `
🎉 ${esc(badge)} 🎉

` : '
') + `TARGO` + `

${esc(t.name)} 🎉

` + `

${esc(t.tagline)}

` + `

${esc(t.greet(firstName(name)) || '')}

` + `

${esc(t.body)}

` - + `
📅 ${esc(t.when)}
` - + `${prog}
` - + `

${esc(t.program_line)}

` - + `
⏳ ${esc(t.limited)}
` + + (t.when ? `
📅 ${esc(t.when)}
` : '') + + ((t.program || []).length ? `${prog}
` : '') + + (t.program_line ? `

${esc(t.program_line)}

` : '') + + (t.limited ? `
⏳ ${esc(t.limited)}
` : '') + `
${esc(t.submit)} →
` + `

${esc(merci)}

` + `

${esc(t.closing)}

` - + `${esc(t.notes[0])}
${esc(t.notes[1])}` + + `${(t.notes || []).map(esc).join('
')}` + `
🌐 targo.ca  ·  ${esc(t.foot)}
` + `` } +// ── 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) - } - customers.forEach(c => { c._monthly = byCust[c.name] || 0 }) + let subIds = Object.keys(byCust) + if (minMonthly > 0) subIds = subIds.filter(c => byCust[c] >= minMonthly) + intersect(new Set(subIds)) + } + + 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 `${inputHtml}${errId ? `
${esc(errMsg)}
` : ''}` } -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 => - `
${p.icon}
${esc(p.label)}${p.sub ? `${esc(p.sub)}` : ''}
` + const program = (t.program || []).map(p => + `
${esc(p.icon)}
${esc(p.label)}${p.sub ? `${esc(p.sub)}` : ''}
` ).join('') - const notes = t.notes.map((n, i) => `
${'*'.repeat(i + 1)} ${esc(n)}
`).join('') + const notes = (t.notes || []).map((n, i) => `
${'*'.repeat(i + 1)} ${esc(n)}
`).join('') const optlbl = (s) => `${esc(s)} ${esc(t.opt)}` + const badgeHtml = event.badge_top || event.badge_bottom + ? `
${esc(event.badge_top || '🎉')}${event.badge_bottom ? `${esc(event.badge_bottom)}` : ''}
` + : `
🎉
` return ` ${esc(t.name)}
-
20ANS
+ ${badgeHtml}

${esc(t.name)}

${esc(t.tagline)}
-
📅${esc(t.when)}
+ ${t.when ? `
📅${esc(t.when)}
` : ''}

${esc(t.body)}

-
${program}
-

${esc(t.program_line)}

-

${esc(t.limited)}

+ ${program ? `
${program}
` : ''} + ${t.program_line ? `

${esc(t.program_line)}

` : ''} + ${t.limited ? `

${esc(t.limited)}

` : ''}
-
+
+
+
🎪
+

${esc(t.full_title)}

+

${esc(t.full_msg)}

+
+
+ +
${greet ? `
${esc(greet)}
` : ''}
${field('nm', 'name', esc(t.f_name), ``, '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//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/ (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/ (éditer) · DELETE /events/ + 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//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//rsvps/ mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/) @@ -560,6 +905,21 @@ async function handle (req, res, method, path, url) { const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2])) return json(res, ok ? 200 : 404, { ok }) } + // Staff : POST /events//attachments {filename, mime, data(base64)} · DELETE /events//attachments/ + 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//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//audience {mode:'filter'|'csv', filters, csv} → aperçu (décompte + échantillon) + // Staff : POST /events//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//audience-list → { count, by_source, sample, updated } + // POST /events//audience-list/add {mode,filters,csv,source_label} → résout un lot + l'ajoute (dédup) + // POST /events//audience-list/remove {email} → retire un destinataire + // DELETE /events//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 }