diff --git a/apps/ops/src/api/events.js b/apps/ops/src/api/events.js index 80294f5..573eb0e 100644 --- a/apps/ops/src/api/events.js +++ b/apps/ops/src/api/events.js @@ -25,3 +25,5 @@ export async function listEvents () { return hubFetch('/events') } export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps`) } 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 }) } +export async function listTemplates () { return hubFetch('/campaigns/templates') } diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue index d72a5f8..fdb1a26 100644 --- a/apps/ops/src/pages/EventsPage.vue +++ b/apps/ops/src/pages/EventsPage.vue @@ -107,22 +107,67 @@ - + - +
Envoyer une invitation
- -
Envoi d'un test à votre adresse (le courriel contient un lien personnalisé). L'envoi de masse (choix de l'audience) reste à configurer — rien n'est envoyé aux clients ici.
- - + + + + + + +
Envoi d'un test (courriel avec lien personnalisé) à ton adresse.
+ + +
+ + + + + +
Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.
+ +
Audience
+ + +
+ + +
Astuce : « Clients actifs » = ~7900 clients avec courriel. « Abonnement actif » restreint à ceux ayant un service en cours.
+
+ +
+ + + +
Colonne obligatoire : email. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.
+
+ + + + + {{ audPreview.count }} destinataire(s) · {{ audPreview.dropped_no_email }} sans courriel écarté(s) +
+ ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}… +
+
+ + + + L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici. + +
+ - +
@@ -139,7 +184,7 @@ 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 } from 'src/api/events' +import { listRsvps, deleteRsvp, sendInvite, previewAudience, listTemplates } from 'src/api/events' const $q = useQuasar() const { exportCsv } = useCsvExport() @@ -159,6 +204,16 @@ const showSend = ref(false) const sendChannel = ref('mailjet') const testEmail = ref('') 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', active_sub: false }) +const csvFile = ref(null) +const audCsvText = ref('') +const audPreview = ref(null) +const previewing = ref(false) const publicUrl = computed(() => ev.value.public_url || '') @@ -225,7 +280,28 @@ function confirmDelete (row) { }) } -function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; showSend.value = true } +function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); showSend.value = true } +async function loadTemplates () { + try { + const d = await listTemplates() + const files = (d.templates || []).map(t => (typeof t === 'string' ? { value: t, label: t } : { value: t.name, label: t.label || t.name })) + templates.value = [{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }, ...files] + } 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) +} +async function doPreview () { + previewing.value = true; audPreview.value = null + 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 } +} async function sendTestNow () { const em = (testEmail.value || '').trim() if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return } diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js index 9e00f2f..fc322d4 100644 --- a/services/targo-hub/lib/events.js +++ b/services/targo-hub/lib/events.js @@ -231,6 +231,76 @@ async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang return results } +// ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ────── +function splitName (full) { + const parts = String(full || '').trim().split(/\s+/).filter(Boolean) + if (parts.length <= 1) return { firstname: parts[0] || '', lastname: '' } + return { firstname: parts[0], lastname: parts.slice(1).join(' ') } +} +const firstEmail = (s) => String(s || '').split(/[;,]/)[0].trim() + +// CSV : SEULE colonne obligatoire = email ; alias FR/EN acceptés ; en-tête requise. +function parseAudienceCsv (text) { + const lines = String(text || '').split(/\r?\n/).filter(l => l.trim()) + if (!lines.length) return { error: 'empty' } + const split = (l) => l.split(',').map(c => c.trim().replace(/^"|"$/g, '')) + const header = split(lines[0]).map(h => h.toLowerCase()) + const idx = (al) => header.findIndex(h => al.includes(h)) + const iEmail = idx(['email', 'courriel', 'e-mail', 'mail']) + if (iEmail < 0) return { error: 'no_email_column' } + const iFirst = idx(['firstname', 'first_name', 'prénom', 'prenom']) + const iLast = idx(['lastname', 'last_name', 'name', 'nom']) + const iLang = idx(['language', 'langue', 'lang']) + const iCust = idx(['customer_id', 'customer', 'client', 'no_client', 'numéro', 'numero']) + const iPhone = idx(['phone', 'téléphone', 'telephone', 'tel']) + const rows = [] + for (let k = 1; k < lines.length; k++) { + const c = split(lines[k]); const email = firstEmail(c[iEmail] || '') + rows.push({ email, firstname: iFirst >= 0 ? c[iFirst] || '' : '', lastname: iLast >= 0 ? c[iLast] || '' : '', language: normLang(iLang >= 0 ? c[iLang] : 'fr'), customer_id: iCust >= 0 ? c[iCust] || '' : '', phone: iPhone >= 0 ? c[iPhone] || '' : '' }) + } + return { rows } +} + +// Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis). +async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) { + let rows = [] + if (mode === 'csv') { + const p = parseAudienceCsv(csv) + if (p.error) return { error: p.error } + rows = p.rows + } else { + const erp = require('./erp') + const f = [] + if (filters.statut === 'active') f.push(['disabled', '=', 0]) + else if (filters.statut === 'disabled') f.push(['disabled', '=', 1]) + if (filters.territory) f.push(['territory', '=', filters.territory]) + if (filters.group) f.push(['customer_group', '=', filters.group]) + const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'territory', 'customer_group'] + let customers = [] + if (filters.active_sub) { + // Clients ayant ≥1 Service Subscription « Actif ». + const subs = []; let s = 0 + for (let p = 0; p < 30; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer'], limit: 500, start: s }); subs.push(...b); if (b.length < 500) break; s += 500 } + const ids = [...new Set(subs.map(x => x.customer).filter(Boolean))] + 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) + } + } 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 } + } + 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 } }) + } + const seen = new Set(); const recipients = []; let dropped = 0 + for (const r of rows) { + if (!EMAIL_RE.test(r.email || '')) { dropped++; continue } + const k = r.email.toLowerCase(); if (seen.has(k)) continue; seen.add(k) + recipients.push(r) + } + return { recipients, count: recipients.length, dropped_no_email: dropped } +} + // ── Page publique (festive, mobile-first, bilingue) ──────────────────────── function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) { return `${inputHtml}${errId ? `
${esc(errMsg)}
` : ''}` @@ -486,9 +556,18 @@ 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) + 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' }) + const b = await parseBody(req) + const r = await resolveAudience({ mode: b.mode, filters: b.filters || {}, csv: b.csv || '' }) + if (r.error) return json(res, 400, { error: r.error }) + return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, sample: r.recipients.slice(0, 50) }) + } 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, page, normLang } +module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang }