${esc(t.tagline)}
${esc(t.greet(firstName(name)) || '')}
` @@ -444,14 +454,14 @@ async function sendInviteMessage ({ channel, to, subject, html, from, attachment } // ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ── -async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) { +async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '', reminder = false }) { const event = getEvent(eventId) 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, lang) - const html = inviteEmail(eventId, lang, name, link) - const subject = '[TEST] ' + inviteSubject(eventId, lang) + const html = inviteEmail(eventId, lang, name, link, !!reminder) + const subject = '[TEST] ' + inviteSubject(eventId, lang, !!reminder) 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 }) } } @@ -484,7 +494,8 @@ function massSend (eventId, { channel = 'mailjet', from = '' } = {}) { return { campaign_id: id, count: campaign.recipients.length } } -async function sendEventCampaignAsync (id, eventId, channel, from) { +async function sendEventCampaignAsync (id, eventId, channel, from, opts = {}) { + const isReminder = !!opts.isReminder const campaigns = require('./campaigns') const c = campaigns.loadCampaign(id); if (!c) return const event = getEvent(eventId); if (!event) return @@ -501,8 +512,8 @@ async function sendEventCampaignAsync (id, eventId, channel, from) { const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim() const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang) r.rsvp_url = rsvpUrl - const html = inviteEmail(eventId, lang, name, rsvpUrl) - const subject = inviteSubject(eventId, lang) + const html = inviteEmail(eventId, lang, name, rsvpUrl, isReminder) + const subject = inviteSubject(eventId, lang, isReminder) const customId = id + ':' + i try { const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId }) @@ -520,6 +531,68 @@ async function sendEventCampaignAsync (id, eventId, channel, from) { bcast('campaign-done', { id, counters: c.counters }) } +// ── Relance (« Petit rappel ») aux invités PAS ENCORE inscrits ───────────── +// Critère retenu = PAS INSCRIT (aucune réponse RSVP correspondante), qui couvre à la fois +// « n'a pas cliqué » (n'a jamais atteint le formulaire) ET « a cliqué mais n'a pas complété » — +// les deux cas mentionnés par le staff. La ventilation clic/ouverture est fournie séparément +// pour transparence (aperçu avant envoi), mais ne restreint PAS l'éligibilité. +function listEventCampaigns (eventId) { + const campaigns = require('./campaigns') + return campaigns.listCampaigns() + .filter(c => c.event_id === eventId && c.type === 'event' && !c.reminder_of) // exclut les relances (on relance TOUJOURS depuis l'envoi original) +} +function reminderCandidates (eventId, sourceCampaignId) { + const campaigns = require('./campaigns') + const src = campaigns.loadCampaign(sourceCampaignId) + if (!src) return { error: 'campaign_not_found' } + if ((src.params || {}).event_id !== eventId) return { error: 'campaign_event_mismatch' } + const resp = loadResp(eventId) + const registeredEmails = new Set(); const registeredCustomers = new Set() + for (const r of Object.values(resp)) { + if (r.email) registeredEmails.add(String(r.email).toLowerCase()) + if (r.customer_id) registeredCustomers.add(r.customer_id) + } + const eligible = (src.recipients || []).filter(r => { + if (r.excluded) return false + if (!['sent', 'opened', 'clicked'].includes(r.status)) return false // seulement les invitations réellement livrées (pas pending/failed) + const email = String(r.email || '').toLowerCase() + if (email && registeredEmails.has(email)) return false + if (r.customer_id && registeredCustomers.has(r.customer_id)) return false + return true + }) + const breakdown = { + not_opened: eligible.filter(r => r.status === 'sent').length, + opened_not_clicked: eligible.filter(r => r.status === 'opened').length, + clicked_not_registered: eligible.filter(r => r.status === 'clicked').length, + } + return { source: { id: src.id, name: src.name, total: (src.recipients || []).length }, candidates: eligible, count: eligible.length, breakdown } +} +// Crée + lance une campagne de RELANCE (statut sending direct, comme massSend) ciblant +// reminderCandidates() ; le worker (sendEventCampaignAsync) reçoit isReminder:true → badge +// « Petit rappel »/« Reminder » + sujet préfixé. Recalcule les candidats à l'envoi (fraîcheur : +// quelqu'un a pu s'inscrire entre l'aperçu et le clic sur « Envoyer »). +function reminderSend (eventId, sourceCampaignId, { channel = 'mailjet', from = '' } = {}) { + const event = getEvent(eventId); if (!event) return { error: 'event_not_found' } + const r = reminderCandidates(eventId, sourceCampaignId) + if (r.error) return r + if (!r.count) return { error: 'no_candidates' } + const campaigns = require('./campaigns') + const id = campaigns.newCampaignId() + '-rem' + const ch = channel === 'gmail' ? 'gmail' : 'mailjet' + const campaign = { + id, + name: `Rappel — ${event.fr.name}`, + created_at: new Date().toISOString(), + status: 'sending', + reminder_of: sourceCampaignId, + params: { channel: ch, from: from || '', type: 'event', event_id: eventId, template: event.email_template || 'festive', reminder_of: sourceCampaignId }, + recipients: r.candidates.map(x => ({ email: x.email, firstname: x.firstname || '', lastname: x.lastname || '', language: normLang(x.language), customer_id: x.customer_id || '', source: 'Rappel : ' + r.source.name, status: 'pending' })), + } + campaigns.saveCampaign(campaign) + setImmediate(() => sendEventCampaignAsync(id, eventId, ch, from, { isReminder: true }).catch(e => log('event reminder async: ' + e.message))) + return { campaign_id: id, count: campaign.recipients.length } +} + // ── 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) @@ -1020,20 +1093,21 @@ async function handle (req, res, method, path, url) { return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] }) } - // Staff : GET /events/