diff --git a/services/targo-hub/lib/events.js b/services/targo-hub/lib/events.js index 2127ca5..3cd7c70 100644 --- a/services/targo-hub/lib/events.js +++ b/services/targo-hub/lib/events.js @@ -341,7 +341,7 @@ function inviteEmailFestive (e, lang, name, rsvpUrl) { + `` + `
` + `` - + `` + + `` + `
 
 
` + `
🎈🎉🎈
` + (badge ? `
🎉 ${esc(badge)} 🎉

` : '
') @@ -420,30 +420,97 @@ function loadSendAttachments (event, lang) { return out } +// Normalise les pièces jointes (buffers) au format attendu par le canal. +function attachmentPayload (channel, atts) { + return channel === 'gmail' + ? atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') })) + : atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer })) +} +// Envoi bas niveau d'un courriel d'invitation. customId (Mailjet) = suivi ouvertures/clics. +async function sendInviteMessage ({ channel, to, subject, html, from, attachments, customId }) { + if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to, subject, html, from: from || undefined, attachments }); return { ok: !!r, id: r && r.id } } + const headers = customId ? { 'X-MJ-CustomID': customId } : undefined + const info = await require('./email').sendEmail({ to, subject, html, from: from || undefined, attachments, headers }) + return { ok: !!info, id: info && info.messageId, error: info ? null : ((require('./email').getLastError() || {}).message || 'send_failed') } +} + // ── 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 atts = attachmentPayload(channel, 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) const html = inviteEmail(eventId, lang, name, link) const subject = '[TEST] ' + inviteSubject(eventId, lang) - try { - let ok - 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 }) } + try { const r = await sendInviteMessage({ channel, to: em, subject, html, from, attachments: atts }); results.push({ email: em, ok: r.ok, error: r.error }) } + catch (e) { results.push({ email: em, ok: false, error: e.message }) } } return results } +// ── Envoi de MASSE (blast d'invitations) ─────────────────────────────────── +// Crée un enregistrement CAMPAGNE (suivi Mailjet via X-MJ-CustomID `:` → webhook campagne existant, +// report.csv, /campaigns list/détail, SSE) MAIS events fait l'envoi lui-même : lien rsvp_url PERSONNEL par +// destinataire + pièces jointes par langue + gabarit festif/Unlayer. Le worker campagne ne gère ni rsvp_url +// ni pièces jointes ni HTML festif → on ne le réutilise pas, seulement son format d'enregistrement. +function massSend (eventId, { channel = 'mailjet', from = '' } = {}) { + const event = getEvent(eventId); if (!event) return { error: 'event_not_found' } + const aud = loadAudienceList(eventId) + const recipients = (aud.recipients || []).filter(r => EMAIL_RE.test(r.email || '')) + if (!recipients.length) return { error: 'empty_list' } + const campaigns = require('./campaigns') + const id = campaigns.newCampaignId() + const ch = channel === 'gmail' ? 'gmail' : 'mailjet' + const campaign = { + id, + name: `Invitation — ${event.fr.name}`, + created_at: new Date().toISOString(), + status: 'sending', + params: { channel: ch, from: from || '', type: 'event', event_id: eventId, template: event.email_template || 'festive' }, + recipients: recipients.map(r => ({ email: r.email, firstname: r.firstname || '', lastname: r.lastname || '', language: normLang(r.language), customer_id: r.customer_id || '', source: r.source || '', status: 'pending' })), + } + campaigns.saveCampaign(campaign) + setImmediate(() => sendEventCampaignAsync(id, eventId, ch, from).catch(e => log('event blast async: ' + e.message))) + return { campaign_id: id, count: campaign.recipients.length } +} + +async function sendEventCampaignAsync (id, eventId, channel, from) { + const campaigns = require('./campaigns') + const c = campaigns.loadCampaign(id); if (!c) return + const event = getEvent(eventId); if (!event) return + let sse = null; try { sse = require('./sse') } catch { /* SSE optionnel */ } + const topic = 'campaign:' + id + const bcast = (ev, data) => { try { sse && sse.broadcast(topic, ev, data) } catch { /* */ } } + const payloadByLang = { fr: attachmentPayload(channel, loadSendAttachments(event, 'fr')), en: attachmentPayload(channel, loadSendAttachments(event, 'en')) } + const sleep = (ms) => new Promise(r => setTimeout(r, ms)) + bcast('campaign-status', { id, status: 'sending' }) + for (let i = 0; i < c.recipients.length; i++) { + const r = c.recipients[i] + if (r.status !== 'pending') continue + const lang = normLang(r.language) + const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim() + const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email) + r.rsvp_url = rsvpUrl + const html = inviteEmail(eventId, lang, name, rsvpUrl) + const subject = inviteSubject(eventId, lang) + const customId = id + ':' + i + try { + const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId }) + if (res.ok) { r.status = 'sent'; r.sent_at = new Date().toISOString(); if (channel !== 'gmail') { r.mailjet_custom_id = customId; if (res.id) r.mailjet_uuid = res.id } } + else { r.status = 'failed'; r.error = res.error || 'send_failed' } + } catch (e) { r.status = 'failed'; r.error = e.message } + campaigns.saveCampaign(c) + bcast('recipient-update', { i, recipient: r }) + if (i < c.recipients.length - 1) await sleep(600) // throttle (comme le worker campagne) + } + c.status = 'completed'; c.send_completed_at = new Date().toISOString() + campaigns.saveCampaign(c) + const sent = c.recipients.filter(x => x.status === 'sent').length + log(`Event blast ${eventId} → campaign ${id} : ${sent}/${c.recipients.length} envoyés (${channel})`) + bcast('campaign-done', { id, counters: c.counters }) +} + // ── 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) @@ -1003,7 +1070,14 @@ async function handle (req, res, method, path, url) { log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`) return json(res, 200, { ok: results.every(r => r.ok), test: true, results }) } - return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." }) + // Envoi de MASSE réel (gaté côté UI par une confirmation ; ici on exige mass:true). + if (b.mass) { + const r = massSend(eventId, { channel, from: b.from }) + if (r.error) return json(res, 400, { error: r.error }) + log(`Event invite MASS — ${eventId} · ${channel} · ${r.count} destinataire(s) → campagne ${r.campaign_id}`) + return json(res, 202, { ok: true, campaign_id: r.campaign_id, count: r.count }) + } + return json(res, 400, { error: 'bad_request' }) } // Staff : POST /events//audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/) @@ -1048,4 +1122,4 @@ async function handle (req, res, method, path, url) { } 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, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary } +module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, massSend, sendEventCampaignAsync, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }