@vicacha-devs/strapi-plugin-broadcasts 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -2
  3. package/dist/admin/BroadcastsHomePage-CM_N_spo.mjs +1210 -0
  4. package/dist/admin/BroadcastsHomePage-DQueM7nj.js +1228 -0
  5. package/dist/admin/SettingsPage-TawvAAiv.js +1187 -0
  6. package/dist/admin/SettingsPage-ikcb0cfh.mjs +1169 -0
  7. package/dist/admin/ar-vwekVqfp.js +232 -0
  8. package/dist/admin/ar-wZTCd3Za.mjs +232 -0
  9. package/dist/admin/de-IZicxuvv.mjs +232 -0
  10. package/dist/admin/de-Qa2SQQfY.js +232 -0
  11. package/dist/admin/en-B-HnZo8U.mjs +232 -0
  12. package/dist/admin/en-D1byrK0s.js +232 -0
  13. package/dist/admin/es-Co7PmAj8.mjs +232 -0
  14. package/dist/admin/es-Dgylqagv.js +232 -0
  15. package/dist/admin/fr-B9zZn3z1.mjs +232 -0
  16. package/dist/admin/fr-C7-MIWnw.js +232 -0
  17. package/dist/admin/index-CApLw9Uu.mjs +1036 -0
  18. package/dist/admin/index-Dw6J7U1v.js +1053 -0
  19. package/dist/admin/index.js +6 -0
  20. package/dist/admin/index.mjs +6 -0
  21. package/dist/admin/it-DJU9iBqT.mjs +232 -0
  22. package/dist/admin/it-D_mulZtx.js +232 -0
  23. package/dist/admin/pt-B9swwjPu.js +232 -0
  24. package/dist/admin/pt-Dr8u5we0.mjs +232 -0
  25. package/dist/admin/ru-B0NNiMNA.js +232 -0
  26. package/dist/admin/ru-Bd4cEKDV.mjs +232 -0
  27. package/dist/admin/selectOptions-BlvNrhvc.mjs +150 -0
  28. package/dist/admin/selectOptions-DuHQ0KK3.js +149 -0
  29. package/dist/admin/src/components/AutomationAndLogsTabs.d.ts +16 -0
  30. package/dist/admin/src/components/BridgesSandboxTab.d.ts +8 -0
  31. package/dist/admin/src/components/CampaignComposerForm.d.ts +26 -0
  32. package/dist/admin/src/components/CampaignsTableSection.d.ts +27 -0
  33. package/dist/admin/src/components/DeliveryLedgerSection.d.ts +17 -0
  34. package/dist/admin/src/components/Initializer.d.ts +6 -0
  35. package/dist/admin/src/components/OrganizationEmailTab.d.ts +10 -0
  36. package/dist/admin/src/components/PluginIcon.d.ts +3 -0
  37. package/dist/admin/src/components/WhatsAppTransportTab.d.ts +11 -0
  38. package/dist/admin/src/index.d.ts +24 -0
  39. package/dist/admin/src/pages/BroadcastsHomePage.d.ts +2 -0
  40. package/dist/admin/src/pages/SettingsPage.d.ts +3 -0
  41. package/dist/admin/src/permissions.d.ts +30 -0
  42. package/dist/admin/src/pluginId.d.ts +2 -0
  43. package/dist/admin/src/translations/cmLabels.d.ts +1 -0
  44. package/dist/admin/src/translations/cmLabelsExtra.d.ts +1 -0
  45. package/dist/admin/src/translations/cmPlaceholders.d.ts +1 -0
  46. package/dist/admin/src/translations/cmPlaceholdersExtra.d.ts +1 -0
  47. package/dist/admin/src/utils/extractApiError.d.ts +1 -0
  48. package/dist/admin/src/utils/selectOptions.d.ts +21 -0
  49. package/dist/admin/tr-Cc-ItZUP.js +232 -0
  50. package/dist/admin/tr-DlyHa2Vk.mjs +232 -0
  51. package/dist/admin/zh-CLzJ7x3y.mjs +232 -0
  52. package/dist/admin/zh-CaqwZk-p.js +232 -0
  53. package/dist/server/index.js +3399 -0
  54. package/dist/server/index.mjs +3399 -0
  55. package/dist/server/src/bootstrap.d.ts +5 -0
  56. package/dist/server/src/config/index.d.ts +5 -0
  57. package/dist/server/src/content-types/automation-rule/index.d.ts +90 -0
  58. package/dist/server/src/content-types/automation-rule/schema.json.d.ts +90 -0
  59. package/dist/server/src/content-types/campaign/index.d.ts +118 -0
  60. package/dist/server/src/content-types/campaign/schema.json.d.ts +118 -0
  61. package/dist/server/src/content-types/delivery-log/index.d.ts +71 -0
  62. package/dist/server/src/content-types/delivery-log/schema.json.d.ts +71 -0
  63. package/dist/server/src/content-types/index.d.ts +279 -0
  64. package/dist/server/src/controllers/broadcastController.d.ts +32 -0
  65. package/dist/server/src/controllers/index.d.ts +22 -0
  66. package/dist/server/src/destroy.d.ts +5 -0
  67. package/dist/server/src/eventHubRegistry.d.ts +5 -0
  68. package/dist/server/src/index.d.ts +463 -0
  69. package/dist/server/src/register.d.ts +5 -0
  70. package/dist/server/src/routes/admin.d.ts +17 -0
  71. package/dist/server/src/routes/content-api.d.ts +13 -0
  72. package/dist/server/src/routes/index.d.ts +31 -0
  73. package/dist/server/src/services/automationService.d.ts +33 -0
  74. package/dist/server/src/services/campaignChannelDispatch.d.ts +20 -0
  75. package/dist/server/src/services/campaignHelpers.d.ts +20 -0
  76. package/dist/server/src/services/campaignLifecycle.d.ts +23 -0
  77. package/dist/server/src/services/campaignService.d.ts +54 -0
  78. package/dist/server/src/services/deliveryLedger.d.ts +18 -0
  79. package/dist/server/src/services/emailDesignerBridge.d.ts +30 -0
  80. package/dist/server/src/services/idempotency.d.ts +5 -0
  81. package/dist/server/src/services/index.d.ts +119 -0
  82. package/dist/server/src/services/settings.d.ts +12 -0
  83. package/dist/server/src/services/transportService.d.ts +70 -0
  84. package/dist/shared/index.d.ts +1 -0
  85. package/dist/shared/index.js +825 -0
  86. package/dist/shared/index.mjs +823 -0
  87. package/package.json +109 -3
@@ -0,0 +1,3399 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const shared = require("@vicacha-devs/strapi-plugin-calendar/shared");
4
+ const node_crypto = require("node:crypto");
5
+ function isSupportedCalendar(cal) {
6
+ if (typeof cal !== "string" || !cal.trim()) return false;
7
+ return shared.SUPPORTED_INTL_CALENDARS.some((item) => item.id === cal.trim());
8
+ }
9
+ function formatSecondaryDate(isoDateString, calendar = "islamic-umalqura", locale = "es") {
10
+ if (!isoDateString) {
11
+ return { calendar, formatted: "" };
12
+ }
13
+ const date = new Date(isoDateString);
14
+ if (Number.isNaN(date.getTime())) {
15
+ return { calendar, formatted: isoDateString };
16
+ }
17
+ try {
18
+ const formatter = new Intl.DateTimeFormat(`${locale}-u-ca-${calendar}`, {
19
+ year: "numeric",
20
+ month: "short",
21
+ day: "numeric"
22
+ });
23
+ const parts = formatter.formatToParts(date);
24
+ const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
25
+ const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
26
+ const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
27
+ return {
28
+ calendar,
29
+ formatted: formatter.format(date),
30
+ year,
31
+ month,
32
+ day
33
+ };
34
+ } catch {
35
+ return {
36
+ calendar,
37
+ formatted: date.toISOString().slice(0, 10)
38
+ };
39
+ }
40
+ }
41
+ const PLUGIN_ID = "broadcasts";
42
+ const CAMPAIGN_UID = "plugin::broadcasts.campaign";
43
+ const AUTOMATION_RULE_UID = "plugin::broadcasts.automation-rule";
44
+ const DELIVERY_LOG_UID = "plugin::broadcasts.delivery-log";
45
+ const SUPPORTED_CHANNELS = [
46
+ "email",
47
+ "telegram",
48
+ "whatsapp"
49
+ ];
50
+ const SUPPORTED_WHATSAPP_DRIVERS = [
51
+ "meta-cloud",
52
+ "openwa",
53
+ "evolution"
54
+ ];
55
+ const DEFAULT_BROADCAST_TOPICS = [
56
+ "announcements",
57
+ "events",
58
+ "courses",
59
+ "ramadan_eid",
60
+ "urgent_janazah",
61
+ "newsletter"
62
+ ];
63
+ const DEFAULT_BROADCAST_SETTINGS = {
64
+ primaryCalendar: "gregory",
65
+ secondaryCalendars: ["islamic-umalqura"],
66
+ defaultLocale: "es",
67
+ supportedLocales: ["es", "en", "ar", "tr"],
68
+ availableTopics: [...DEFAULT_BROADCAST_TOPICS],
69
+ dryRun: true,
70
+ enableInboxSubscriberBridge: true,
71
+ enableEventHubAutomations: true,
72
+ enableEmailDesignerBridge: true,
73
+ defaultEmailTemplateRef: "",
74
+ siteBaseUrl: "https://mezquitabogota.org",
75
+ organizationName: "Mezquita Central Abu Bakr As-Siddiq — Bogotá",
76
+ emailFromAddress: "no-reply@mezquitabogota.org",
77
+ emailReplyToAddress: "info@mezquitabogota.org",
78
+ telegramEnabled: true,
79
+ telegramBotToken: "",
80
+ telegramDefaultChatId: "@MezquitaCentralBogota",
81
+ telegramTopicChatMap: {},
82
+ telegramTopicThreadMap: {},
83
+ whatsappEnabled: true,
84
+ whatsappDriver: "meta-cloud",
85
+ whatsappPhoneNumberId: "",
86
+ whatsappAccessToken: "",
87
+ whatsappGatewayUrl: "",
88
+ whatsappGatewaySession: "default",
89
+ whatsappGatewayApiKey: "",
90
+ whatsappDefaultRecipients: [],
91
+ whatsappTopicGroupMap: {},
92
+ allowLocalhostGateways: false,
93
+ publicChannelLinks: [
94
+ {
95
+ id: "telegram-main",
96
+ channel: "telegram",
97
+ label: "Canal Oficial de Telegram",
98
+ handle: "@MezquitaCentralBogota",
99
+ inviteUrl: "https://t.me/MezquitaCentralBogota",
100
+ topic: "announcements",
101
+ localizedLabels: {
102
+ es: "Canal Oficial de Telegram",
103
+ en: "Official Telegram Channel",
104
+ ar: "القناة الرسمية على تيليجرام",
105
+ tr: "Resmi Telegram Kanalı"
106
+ },
107
+ localizedDescriptions: {
108
+ es: "Horarios de Jumu’ah, avistamiento lunar de Ramadán, cursos y avisos urgentes.",
109
+ en: "Jumu’ah schedules, Ramadan moon sighting, courses, and urgent community notices.",
110
+ ar: "مواعيد صلاة الجمعة، تحري هلال رمضان، الدورات والإعلانات العاجلة.",
111
+ tr: "Cuma vakitleri, Ramazan hilal gözlemi, kurslar ve acil duyurular."
112
+ }
113
+ },
114
+ {
115
+ id: "whatsapp-community",
116
+ channel: "whatsapp",
117
+ label: "Comunidad Oficial de WhatsApp",
118
+ handle: "+57 310 000 0000",
119
+ inviteUrl: "https://whatsapp.com/channel/0029VaMezquitaBogota",
120
+ topic: "announcements",
121
+ localizedLabels: {
122
+ es: "Comunidad Oficial de WhatsApp",
123
+ en: "Official WhatsApp Community",
124
+ ar: "مجتمع واتساب الرسمي",
125
+ tr: "Resmi WhatsApp Topluluğu"
126
+ },
127
+ localizedDescriptions: {
128
+ es: "Alertas directas de oración funeraria (Janazah), eventos comunitarios y boletín semanal.",
129
+ en: "Direct Janazah funeral prayer alerts, community events, and weekly newsletter.",
130
+ ar: "تنبيهات صلاة الجنازة العاجلة، الفعاليات المجتمعية والنشرة الأسبوعية.",
131
+ tr: "Acil cenaze namazı duyuruları, topluluk etkinlikleri ve haftalık bülten."
132
+ }
133
+ }
134
+ ]
135
+ };
136
+ const STARTER_CAMPAIGN_TEMPLATES = [
137
+ {
138
+ id: "jumuah-weekly-reminder",
139
+ slug: "jumuah-weekly-reminder",
140
+ title: "Recordatorio Semanal de Khutbah y Oración del Viernes (Jumu’ah)",
141
+ topic: "announcements",
142
+ channels: ["email", "telegram", "whatsapp"],
143
+ priority: "normal",
144
+ subject: "🕌 Jumu’ah en {{organizationName}} ({{secondaryDate}})",
145
+ bodyText: "As-salamu alaykum {{recipientName}}. Te esperamos este viernes para la Khutbah y oración de Jumu’ah en {{organizationName}}. Fecha Hijri: {{secondaryDate}}.",
146
+ bodyHtml: "<p>As-salamu alaykum <strong>{{recipientName}}</strong>,</p><p>Te esperamos este viernes para la Khutbah y oración congregacional de <strong>Jumu’ah</strong> en {{organizationName}}.</p><p>Calendario Hijri: <em>{{secondaryDate}}</em></p>",
147
+ ctaLabel: "Ver Horarios de Oración",
148
+ ctaUrl: "https://mezquitabogota.org/es/horarios",
149
+ translations: {
150
+ es: {
151
+ subject: "🕌 Jumu’ah en {{organizationName}} ({{secondaryDate}})",
152
+ bodyText: "As-salamu alaykum {{recipientName}}. Te esperamos este viernes para la Khutbah y oración de Jumu’ah en {{organizationName}}. Fecha Hijri: {{secondaryDate}}.",
153
+ bodyHtml: "<p>As-salamu alaykum <strong>{{recipientName}}</strong>,</p><p>Te esperamos este viernes para la Khutbah y oración congregacional de <strong>Jumu’ah</strong> en {{organizationName}}.</p><p>Calendario Hijri: <em>{{secondaryDate}}</em></p>",
154
+ ctaLabel: "Ver Horarios de Oración",
155
+ ctaUrl: "https://mezquitabogota.org/es/horarios"
156
+ },
157
+ en: {
158
+ subject: "🕌 Friday Jumu’ah Reminder — {{organizationName}} ({{secondaryDate}})",
159
+ bodyText: "As-salamu alaykum {{recipientName}}. Join us this Friday for the Jumu’ah Khutbah and congregational prayer at {{organizationName}}. Hijri date: {{secondaryDate}}.",
160
+ bodyHtml: "<p>As-salamu alaykum <strong>{{recipientName}}</strong>,</p><p>Join us this Friday for the <strong>Jumu’ah Khutbah</strong> and congregational prayer at {{organizationName}}.</p><p>Hijri date: <em>{{secondaryDate}}</em></p>",
161
+ ctaLabel: "View Prayer Schedule",
162
+ ctaUrl: "https://mezquitabogota.org/en/prayer-times"
163
+ },
164
+ ar: {
165
+ subject: "🕌 تذكير صلاة وخطبة الجمعة — {{organizationName}} ({{secondaryDate}})",
166
+ bodyText: "السلام عليكم {{recipientName}}، ندعوكم لحضور خطبة وصلاة الجمعة في {{organizationName}}. التاريخ الهجري: {{secondaryDate}}.",
167
+ bodyHtml: "<p>السلام عليكم <strong>{{recipientName}}</strong>،</p><p>ندعوكم لحضور خطبة وصلاة <strong>الجمعة</strong> في {{organizationName}}.</p><p>التاريخ الهجري: <em>{{secondaryDate}}</em></p>",
168
+ ctaLabel: "عرض مواقيت الصلاة",
169
+ ctaUrl: "https://mezquitabogota.org/ar/prayer-times"
170
+ },
171
+ tr: {
172
+ subject: "🕌 Cuma Hutbesi ve Namazı Hatırlatması — {{organizationName}} ({{secondaryDate}})",
173
+ bodyText: "Es-selamu aleyküm {{recipientName}}. Bu Cuma {{organizationName}} bünyesinde Cuma hutbesi ve namazına davetlisiniz. Hicri Tarih: {{secondaryDate}}.",
174
+ bodyHtml: "<p>Es-selamu aleyküm <strong>{{recipientName}}</strong>,</p><p>Bu Cuma {{organizationName}} bünyesinde <strong>Cuma Hutbesi ve Namazına</strong> davetlisiniz.</p><p>Hicri Tarih: <em>{{secondaryDate}}</em></p>",
175
+ ctaLabel: "Namaz Vakitlerini Gör",
176
+ ctaUrl: "https://mezquitabogota.org/tr/prayer-times"
177
+ }
178
+ }
179
+ },
180
+ {
181
+ id: "urgent-janazah-alert",
182
+ slug: "urgent-janazah-alert",
183
+ title: "Aviso Urgente de Oración Funeraria (Salat al-Janazah)",
184
+ topic: "urgent_janazah",
185
+ channels: ["telegram", "whatsapp", "email"],
186
+ priority: "urgent",
187
+ subject: "🕯️ Aviso Urgente: Salat al-Janazah en {{organizationName}}",
188
+ bodyText: "Inna lillahi wa inna ilayhi raji’un. Informamos a la comunidad que se realizará la oración funeraria (Salat al-Janazah) en {{organizationName}} después de la oración de Dhuhr ({{secondaryDate}}).",
189
+ bodyHtml: "<p><strong>Inna lillahi wa inna ilayhi raji’un</strong></p><p>Informamos a la comunidad que se realizará la oración funeraria (<strong>Salat al-Janazah</strong>) en {{organizationName}} después de la oración de Dhuhr ({{secondaryDate}}).</p>",
190
+ ctaLabel: "Detalles y Ubicación",
191
+ ctaUrl: "https://mezquitabogota.org/es/avisos",
192
+ translations: {
193
+ es: {
194
+ subject: "🕯️ Aviso Urgente: Salat al-Janazah en {{organizationName}}",
195
+ bodyText: "Inna lillahi wa inna ilayhi raji’un. Informamos a la comunidad que se realizará la oración funeraria (Salat al-Janazah) en {{organizationName}} después de la oración de Dhuhr ({{secondaryDate}}).",
196
+ ctaLabel: "Detalles y Ubicación",
197
+ ctaUrl: "https://mezquitabogota.org/es/avisos"
198
+ },
199
+ en: {
200
+ subject: "🕯️ Urgent Community Notice: Salat al-Janazah at {{organizationName}}",
201
+ bodyText: "Inna lillahi wa inna ilayhi raji’un. Funeral prayer (Salat al-Janazah) will be held at {{organizationName}} following Dhuhr prayer ({{secondaryDate}}).",
202
+ ctaLabel: "Details & Location",
203
+ ctaUrl: "https://mezquitabogota.org/en/announcements"
204
+ },
205
+ ar: {
206
+ subject: "🕯️ إعلان عاجل: صلاة الجنازة في {{organizationName}}",
207
+ bodyText: "إنا لله وإنا إليه راجعون. تُقام صلاة الجنازة في {{organizationName}} عقب صلاة الظهر ({{secondaryDate}}).",
208
+ ctaLabel: "التفاصيل والموقع",
209
+ ctaUrl: "https://mezquitabogota.org/ar/announcements"
210
+ },
211
+ tr: {
212
+ subject: "🕯️ Acil Duyuru: {{organizationName}} Cenaze Namazı",
213
+ bodyText: "İnna lillahi ve inna ileyhi raciun. Öğle namazını müteakip {{organizationName}} camiimizde cenaze namazı kılınacaktır ({{secondaryDate}}).",
214
+ ctaLabel: "Detaylar ve Konum",
215
+ ctaUrl: "https://mezquitabogota.org/tr/announcements"
216
+ }
217
+ }
218
+ }
219
+ ];
220
+ const STARTER_AUTOMATION_RULES = [
221
+ {
222
+ slug: "auto-alert-banner-broadcast",
223
+ name: "Auto-Broadcast Urgent Site Alert Banners",
224
+ eventName: "inbox.alert.published",
225
+ topic: "announcements",
226
+ channels: ["telegram", "whatsapp"],
227
+ active: true,
228
+ priority: "urgent",
229
+ subjectTemplate: "🚨 {{title}} — {{organizationName}}",
230
+ bodyTextTemplate: "🚨 {{title}}: {{message}} ({{secondaryDate}})",
231
+ bodyHtmlTemplate: "<p><strong>🚨 {{title}}</strong></p><p>{{message}}</p><p><em>{{secondaryDate}}</em></p>",
232
+ ctaLabelTemplate: "Ver Aviso Oficial",
233
+ ctaUrlTemplate: "{{siteBaseUrl}}"
234
+ },
235
+ {
236
+ slug: "auto-event-published-broadcast",
237
+ name: "Auto-Broadcast Newly Published Community Events",
238
+ eventName: "event.published",
239
+ targetContentType: "api::event.event",
240
+ topic: "events",
241
+ channels: ["telegram", "email"],
242
+ active: true,
243
+ priority: "normal",
244
+ subjectTemplate: "📅 Nuevo Evento: {{title}} ({{secondaryDate}})",
245
+ bodyTextTemplate: "Nuevo evento comunitario en {{organizationName}}: {{title}}. {{summary}} ({{secondaryDate}}).",
246
+ bodyHtmlTemplate: "<p>Nuevo evento comunitario en <strong>{{organizationName}}</strong>:</p><h3>{{title}}</h3><p>{{summary}}</p><p><em>{{secondaryDate}}</em></p>",
247
+ ctaLabelTemplate: "Inscribirse al Evento",
248
+ ctaUrlTemplate: "{{siteBaseUrl}}/events"
249
+ }
250
+ ];
251
+ const HTML_ESCAPE_MAP = {
252
+ "&": "&amp;",
253
+ "<": "&lt;",
254
+ ">": "&gt;",
255
+ '"': "&quot;",
256
+ "'": "&#39;"
257
+ };
258
+ const TELEGRAM_INLINE_TAGS = /* @__PURE__ */ new Set([
259
+ "b",
260
+ "strong",
261
+ "i",
262
+ "em",
263
+ "u",
264
+ "ins",
265
+ "s",
266
+ "strike",
267
+ "del",
268
+ "code",
269
+ "pre",
270
+ "tg-spoiler"
271
+ ]);
272
+ function escapeHtml(raw) {
273
+ return String(raw).replace(
274
+ /[&<>"']/g,
275
+ (char) => HTML_ESCAPE_MAP[char] || char
276
+ );
277
+ }
278
+ function stripHeaderControlChars(raw) {
279
+ return String(raw || "").replace(/[\r\n<>]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
280
+ }
281
+ function extractSafeAnchorHref(attrs) {
282
+ const match = attrs.match(
283
+ /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
284
+ );
285
+ const rawHref = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").replace(/&amp;/gi, "&").trim();
286
+ if (!rawHref || /[\u0000-\u001f\u007f]/.test(rawHref)) return null;
287
+ try {
288
+ const parsed = new URL(rawHref);
289
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
290
+ return null;
291
+ }
292
+ return parsed.toString();
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+ function sanitizeTelegramHtml(rawHtml) {
298
+ if (!rawHtml) return "";
299
+ let text = String(rawHtml).replace(/[\u0001\u0002]/g, "").replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "").replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<h[1-6](?:\s[^>]*)?>/gi, "<b>").replace(/<\/h[1-6]\s*>/gi, "</b>\n").replace(/<p(?:\s[^>]*)?>/gi, "").replace(/<\/p\s*>/gi, "\n\n").replace(/<div(?:\s[^>]*)?>/gi, "").replace(/<\/div\s*>/gi, "\n").replace(/<li(?:\s[^>]*)?>/gi, "• ").replace(/<\/li\s*>/gi, "\n");
300
+ let openAnchors = 0;
301
+ text = text.replace(
302
+ /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?(\/?)>/g,
303
+ (_full, slash, rawName, attrs = "", selfClose = "") => {
304
+ const tag = rawName.toLowerCase();
305
+ if (selfClose === "/") return "";
306
+ if (slash === "/") {
307
+ if (TELEGRAM_INLINE_TAGS.has(tag)) return `/${tag}`;
308
+ if (tag === "a" && openAnchors > 0) {
309
+ openAnchors -= 1;
310
+ return "/a";
311
+ }
312
+ return "";
313
+ }
314
+ if (TELEGRAM_INLINE_TAGS.has(tag)) return `${tag}`;
315
+ if (tag === "a") {
316
+ const safeHref = extractSafeAnchorHref(attrs);
317
+ if (!safeHref) return "";
318
+ openAnchors += 1;
319
+ return `a href="${escapeHtml(safeHref)}"`;
320
+ }
321
+ return "";
322
+ }
323
+ );
324
+ while (openAnchors > 0) {
325
+ text += "/a";
326
+ openAnchors -= 1;
327
+ }
328
+ text = text.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/gi, "&amp;").replace(/\u0001([^\u0002]+)\u0002/g, "<$1>").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
329
+ return text;
330
+ }
331
+ function interpolateBroadcastTemplate(template, variables, options2 = {}) {
332
+ if (!template) return "";
333
+ const shouldEscape = Boolean(options2.escapeHtmlValues);
334
+ return template.replace(
335
+ /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g,
336
+ (_match, key) => {
337
+ const rawValue = variables[key];
338
+ if (rawValue === void 0 || rawValue === null) {
339
+ return "";
340
+ }
341
+ const strValue = String(rawValue);
342
+ return shouldEscape ? escapeHtml(strValue) : strValue;
343
+ }
344
+ );
345
+ }
346
+ function maskEmail(email) {
347
+ const trimmed = String(email || "").trim().toLowerCase();
348
+ const atIndex = trimmed.indexOf("@");
349
+ if (atIndex <= 0) {
350
+ return "***@invalid";
351
+ }
352
+ const local = trimmed.slice(0, atIndex);
353
+ const domain = trimmed.slice(atIndex + 1);
354
+ if (!domain) {
355
+ return "***@invalid";
356
+ }
357
+ const visiblePrefix = local.slice(0, 1);
358
+ return `${visiblePrefix}***@${domain}`;
359
+ }
360
+ function maskPhone(phone) {
361
+ const normalized = normalizeE164Phone(phone);
362
+ if (normalized.length <= 4) {
363
+ return "***";
364
+ }
365
+ const prefix = normalized.startsWith("+") ? normalized.slice(0, 3) : normalized.slice(0, 2);
366
+ const suffix = normalized.slice(-4);
367
+ return `${prefix}***${suffix}`;
368
+ }
369
+ function isWhatsAppGroupOrChannelJid(raw) {
370
+ const trimmed = String(raw || "").trim().toLowerCase();
371
+ return /^[\d-]+@g\.us$/.test(trimmed) || /^[\d-]+@newsletter$/.test(trimmed);
372
+ }
373
+ function normalizeWhatsAppTarget(raw, defaultCountryCode = "57") {
374
+ const trimmed = String(raw || "").trim();
375
+ if (!trimmed) {
376
+ return {
377
+ normalized: "",
378
+ isGroupOrChannel: false,
379
+ chatId: "",
380
+ evolutionNumber: ""
381
+ };
382
+ }
383
+ const lower = trimmed.toLowerCase();
384
+ if (isWhatsAppGroupOrChannelJid(lower)) {
385
+ return {
386
+ normalized: lower,
387
+ isGroupOrChannel: true,
388
+ chatId: lower,
389
+ evolutionNumber: lower
390
+ };
391
+ }
392
+ if (lower.endsWith("@c.us") || lower.endsWith("@s.whatsapp.net")) {
393
+ const userPart = lower.split("@")[0];
394
+ const e1642 = normalizeE164Phone(userPart, defaultCountryCode);
395
+ const digits2 = e1642.replace(/^\+/, "");
396
+ return {
397
+ normalized: e1642,
398
+ isGroupOrChannel: false,
399
+ chatId: digits2 ? `${digits2}@c.us` : "",
400
+ evolutionNumber: digits2
401
+ };
402
+ }
403
+ const e164 = normalizeE164Phone(trimmed, defaultCountryCode);
404
+ const digits = e164.replace(/^\+/, "");
405
+ return {
406
+ normalized: e164,
407
+ isGroupOrChannel: false,
408
+ chatId: digits ? `${digits}@c.us` : "",
409
+ evolutionNumber: digits
410
+ };
411
+ }
412
+ function maskRecipientIdentifier(identifier) {
413
+ const trimmed = String(identifier || "").trim();
414
+ if (!trimmed) return "***";
415
+ const lower = trimmed.toLowerCase();
416
+ if (isWhatsAppGroupOrChannelJid(lower)) {
417
+ return lower;
418
+ }
419
+ if (trimmed.includes("@") && !trimmed.startsWith("@")) {
420
+ return maskEmail(trimmed);
421
+ }
422
+ if (trimmed.startsWith("@") || /^-\d+$/.test(trimmed)) {
423
+ return trimmed;
424
+ }
425
+ return maskPhone(trimmed);
426
+ }
427
+ function normalizeE164Phone(phone, defaultCountryCode = "57") {
428
+ const raw = String(phone || "").trim();
429
+ if (!raw) return "";
430
+ if (isWhatsAppGroupOrChannelJid(raw)) {
431
+ return raw.toLowerCase();
432
+ }
433
+ const hasPlus = raw.startsWith("+");
434
+ const digitsOnly = raw.replace(/\D+/g, "");
435
+ if (!digitsOnly) return "";
436
+ if (hasPlus) {
437
+ return `+${digitsOnly}`;
438
+ }
439
+ if (digitsOnly.startsWith("00") && digitsOnly.length > 4) {
440
+ return `+${digitsOnly.slice(2)}`;
441
+ }
442
+ if (digitsOnly.length === 10 && defaultCountryCode) {
443
+ return `+${defaultCountryCode.replace(/\D+/g, "")}${digitsOnly}`;
444
+ }
445
+ return `+${digitsOnly}`;
446
+ }
447
+ function resolveLocalizedCampaignCopy(campaign2, locale, defaultLocale = "es") {
448
+ const translations = campaign2.translations || {};
449
+ const match = translations[locale] || translations[defaultLocale];
450
+ return {
451
+ subject: match?.subject || campaign2.subject,
452
+ bodyText: match?.bodyText || campaign2.bodyText,
453
+ bodyHtml: match?.bodyHtml || campaign2.bodyHtml,
454
+ ctaLabel: match?.ctaLabel || campaign2.ctaLabel,
455
+ ctaUrl: match?.ctaUrl || campaign2.ctaUrl
456
+ };
457
+ }
458
+ const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
459
+ const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
460
+ "metadata.google.internal",
461
+ "metadata",
462
+ "100.100.100.200"
463
+ ]);
464
+ function parseIpv4Octets(host) {
465
+ const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
466
+ if (!match) return null;
467
+ const octets = [
468
+ Number(match[1]),
469
+ Number(match[2]),
470
+ Number(match[3]),
471
+ Number(match[4])
472
+ ];
473
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
474
+ return null;
475
+ }
476
+ return octets;
477
+ }
478
+ function isBlockedIpv4([a, b, c, d], allowLoopback) {
479
+ if (a === 127) {
480
+ return !(allowLoopback && b === 0 && c === 0 && d === 1);
481
+ }
482
+ if (a === 0 || a === 10 || a >= 224) return true;
483
+ if (a === 100 && b >= 64 && b <= 127) return true;
484
+ if (a === 169 && b === 254) return true;
485
+ if (a === 172 && b >= 16 && b <= 31) return true;
486
+ if (a === 192 && b === 168) return true;
487
+ if (a === 192 && b === 0 && c === 0) return true;
488
+ return false;
489
+ }
490
+ function expandIpv6Groups(rawHost) {
491
+ if (!rawHost.includes(":") || rawHost.includes("%")) return null;
492
+ let candidate = rawHost;
493
+ const lastColon = candidate.lastIndexOf(":");
494
+ const tail = candidate.slice(lastColon + 1);
495
+ if (tail.includes(".")) {
496
+ const v4 = parseIpv4Octets(tail);
497
+ if (!v4) return null;
498
+ const hi = (v4[0] << 8 | v4[1]).toString(16);
499
+ const lo = (v4[2] << 8 | v4[3]).toString(16);
500
+ candidate = `${candidate.slice(0, lastColon)}:${hi}:${lo}`;
501
+ }
502
+ const dblParts = candidate.split("::");
503
+ if (dblParts.length > 2) return null;
504
+ const parsePart = (part) => {
505
+ if (!part) return [];
506
+ const tokens = part.split(":");
507
+ const nums = [];
508
+ for (const tok of tokens) {
509
+ if (!/^[0-9a-f]{1,4}$/i.test(tok)) return null;
510
+ nums.push(Number.parseInt(tok, 16));
511
+ }
512
+ return nums;
513
+ };
514
+ const left = parsePart(dblParts[0]);
515
+ if (!left) return null;
516
+ if (dblParts.length === 1) {
517
+ return left.length === 8 ? left : null;
518
+ }
519
+ const right = parsePart(dblParts[1]);
520
+ if (!right) return null;
521
+ const missing = 8 - (left.length + right.length);
522
+ if (missing < 1) return null;
523
+ return [...left, ...Array.from({ length: missing }, () => 0), ...right];
524
+ }
525
+ function isBlockedIpv6(groups, allowLoopback) {
526
+ const allZeroPrefix = groups.slice(0, 7).every((g) => g === 0);
527
+ if (allZeroPrefix && groups[7] === 0) return true;
528
+ if (allZeroPrefix && groups[7] === 1) return !allowLoopback;
529
+ const first = groups[0];
530
+ if ((first & 65024) === 64512) return true;
531
+ if ((first & 65472) === 65152) return true;
532
+ if ((first & 65280) === 65280) return true;
533
+ if (first === 8194) {
534
+ const embedded6to4 = [
535
+ groups[1] >> 8 & 255,
536
+ groups[1] & 255,
537
+ groups[2] >> 8 & 255,
538
+ groups[2] & 255
539
+ ];
540
+ return isBlockedIpv4(embedded6to4, false);
541
+ }
542
+ const isMappedV4 = groups.slice(0, 5).every((g) => g === 0) && (groups[5] === 65535 || groups[5] === 0);
543
+ const isNat64 = groups[0] === 100 && groups[1] === 65435 && groups.slice(2, 6).every((g) => g === 0);
544
+ if (isMappedV4 || isNat64) {
545
+ const mappedQuad = [
546
+ groups[6] >> 8 & 255,
547
+ groups[6] & 255,
548
+ groups[7] >> 8 & 255,
549
+ groups[7] & 255
550
+ ];
551
+ return isBlockedIpv4(mappedQuad, false);
552
+ }
553
+ return false;
554
+ }
555
+ function normalizeUrlHostname(rawHostname) {
556
+ const lower = rawHostname.toLowerCase();
557
+ let unbracketed = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
558
+ while (unbracketed.endsWith(".")) {
559
+ unbracketed = unbracketed.slice(0, -1);
560
+ }
561
+ return unbracketed;
562
+ }
563
+ function evaluateGatewayHost(host, allowLocalhost) {
564
+ if (!host) {
565
+ return { isExactLoopback: false, blockedError: "Invalid gateway hostname" };
566
+ }
567
+ const isExactLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
568
+ if (BLOCKED_HOSTNAMES.has(host) || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
569
+ return {
570
+ isExactLoopback: false,
571
+ blockedError: "Private, loopback, and metadata hosts are forbidden (SSRF protection)"
572
+ };
573
+ }
574
+ if (host === "localhost") {
575
+ return {
576
+ isExactLoopback: true,
577
+ blockedError: allowLocalhost ? null : "Localhost gateway URLs are forbidden unless explicitly allowed"
578
+ };
579
+ }
580
+ const v4 = parseIpv4Octets(host);
581
+ if (v4) {
582
+ return {
583
+ isExactLoopback,
584
+ blockedError: isBlockedIpv4(v4, allowLocalhost) ? "Private, loopback, CGNAT, and link-local IPv4 hosts are forbidden" : null
585
+ };
586
+ }
587
+ if (host.includes(":")) {
588
+ const v6 = expandIpv6Groups(host);
589
+ if (!v6 || isBlockedIpv6(v6, allowLocalhost)) {
590
+ return {
591
+ isExactLoopback,
592
+ blockedError: "Private, loopback, link-local, and mapped IPv6 hosts are forbidden"
593
+ };
594
+ }
595
+ }
596
+ return { isExactLoopback, blockedError: null };
597
+ }
598
+ function validateGatewayUrl(rawUrl, options2 = {}) {
599
+ const errors = [];
600
+ const trimmed = String(rawUrl || "").trim();
601
+ if (!trimmed) {
602
+ return { valid: false, errors: ["Gateway URL is required"] };
603
+ }
604
+ if (trimmed.includes("\\") || /[\u0000-\u001f\u007f]/.test(trimmed)) {
605
+ return {
606
+ valid: false,
607
+ errors: ["Gateway URL must not contain backslashes or control chars"]
608
+ };
609
+ }
610
+ let parsed;
611
+ try {
612
+ parsed = new URL(trimmed);
613
+ } catch {
614
+ return { valid: false, errors: ["Invalid URL format"] };
615
+ }
616
+ if (parsed.username || parsed.password) {
617
+ errors.push("Gateway URL must not contain embedded credentials");
618
+ }
619
+ if (parsed.search || parsed.hash) {
620
+ errors.push("Gateway URL must not contain query parameters or fragments");
621
+ }
622
+ const allowLocalhost = Boolean(options2.allowLocalhost);
623
+ const host = normalizeUrlHostname(parsed.hostname);
624
+ const { isExactLoopback, blockedError } = evaluateGatewayHost(
625
+ host,
626
+ allowLocalhost
627
+ );
628
+ if (blockedError) errors.push(blockedError);
629
+ if (parsed.protocol === "http:") {
630
+ if (!allowLocalhost || !isExactLoopback) {
631
+ errors.push("HTTPS protocol is required for external gateway URLs");
632
+ }
633
+ } else if (parsed.protocol !== "https:") {
634
+ errors.push(
635
+ "Only HTTPS (or explicitly allowed localhost HTTP) protocol is permitted"
636
+ );
637
+ }
638
+ return { valid: errors.length === 0, errors };
639
+ }
640
+ function isSafeHttpUrl(rawUrl) {
641
+ if (typeof rawUrl !== "string") return false;
642
+ const trimmed = rawUrl.trim();
643
+ if (!trimmed || /[\u0000-\u001f\u007f<>\\]/.test(trimmed)) return false;
644
+ try {
645
+ const parsed = new URL(trimmed);
646
+ if (parsed.username || parsed.password) return false;
647
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
648
+ } catch {
649
+ return false;
650
+ }
651
+ }
652
+ function validatePublicChannelLinks(links) {
653
+ const errors = [];
654
+ if (!Array.isArray(links)) {
655
+ return { valid: false, errors: ["publicChannelLinks must be an array"] };
656
+ }
657
+ for (let i = 0; i < links.length; i += 1) {
658
+ const item = links[i];
659
+ if (!item || typeof item !== "object") {
660
+ errors.push(`publicChannelLinks[${i}] must be an object`);
661
+ continue;
662
+ }
663
+ if (!isSafeHttpUrl(item.inviteUrl)) {
664
+ errors.push(
665
+ `publicChannelLinks[${i}].inviteUrl must be a valid http(s) URL`
666
+ );
667
+ }
668
+ }
669
+ return { valid: errors.length === 0, errors };
670
+ }
671
+ function normalizeChannels(rawChannels, fallback = ["email"]) {
672
+ if (!Array.isArray(rawChannels)) {
673
+ return [...fallback];
674
+ }
675
+ const filtered = rawChannels.map((c) => String(c).trim().toLowerCase()).filter(
676
+ (c) => SUPPORTED_CHANNELS.includes(c)
677
+ );
678
+ return filtered.length > 0 ? Array.from(new Set(filtered)) : [...fallback];
679
+ }
680
+ function isSupportedWhatsAppDriver(driver) {
681
+ return SUPPORTED_WHATSAPP_DRIVERS.includes(
682
+ String(driver || "").trim()
683
+ );
684
+ }
685
+ const GATEWAY_SESSION_RE = /^[A-Za-z0-9_-]{1,64}$/;
686
+ function isValidGatewaySession(session) {
687
+ return typeof session === "string" && GATEWAY_SESSION_RE.test(session.trim());
688
+ }
689
+ function validateCampaignChannelsList(rawChannels, errors) {
690
+ const channels = Array.isArray(rawChannels) ? rawChannels : [];
691
+ if (channels.length === 0) {
692
+ errors.push("At least one broadcast channel is required");
693
+ return;
694
+ }
695
+ for (const ch of channels) {
696
+ const normalized = String(ch).trim().toLowerCase();
697
+ if (!SUPPORTED_CHANNELS.includes(normalized)) {
698
+ errors.push(`Unsupported broadcast channel: ${String(ch)}`);
699
+ }
700
+ }
701
+ }
702
+ function validateOptionalHttpUrl(urlValue, fieldName, errors) {
703
+ if (!urlValue) return;
704
+ if (!isSafeHttpUrl(urlValue)) {
705
+ errors.push(`${fieldName} must use http or https protocol`);
706
+ }
707
+ }
708
+ function validateCampaignInput(input) {
709
+ const errors = [];
710
+ const slug = String(input.slug || "").trim();
711
+ const title = String(input.title || "").trim();
712
+ const subject = String(input.subject || "").trim();
713
+ const bodyText = String(input.bodyText || "").trim();
714
+ if (!slug || !SLUG_REGEX.test(slug)) {
715
+ errors.push("Campaign slug must be lowercase kebab-case");
716
+ }
717
+ if (title.length < 3) {
718
+ errors.push("Campaign title must be at least 3 characters");
719
+ }
720
+ if (subject.length < 3) {
721
+ errors.push("Campaign subject must be at least 3 characters");
722
+ }
723
+ if (bodyText.length < 5) {
724
+ errors.push("Campaign bodyText must be at least 5 characters");
725
+ }
726
+ validateCampaignChannelsList(input.channels, errors);
727
+ validateOptionalHttpUrl(input.ctaUrl, "ctaUrl", errors);
728
+ validateOptionalHttpUrl(input.mediaUrl, "mediaUrl", errors);
729
+ if (input.translations && typeof input.translations === "object") {
730
+ for (const [locale, tr] of Object.entries(input.translations)) {
731
+ if (tr && typeof tr === "object" && tr.ctaUrl) {
732
+ validateOptionalHttpUrl(
733
+ tr.ctaUrl,
734
+ `translations.${locale}.ctaUrl`,
735
+ errors
736
+ );
737
+ }
738
+ }
739
+ }
740
+ return { valid: errors.length === 0, errors };
741
+ }
742
+ function requireNonEmptyFields(fields, errors) {
743
+ for (const [val, errMessage] of fields) {
744
+ if (!String(val || "").trim()) {
745
+ errors.push(errMessage);
746
+ }
747
+ }
748
+ }
749
+ function validateCtaUrlTemplate(rawTemplate, errors) {
750
+ if (!rawTemplate) return;
751
+ const trimmed = String(rawTemplate).trim();
752
+ if (!trimmed) return;
753
+ const resolvedProbe = trimmed.replace(/^\{\{\s*siteBaseUrl\s*\}\}/, "https://example.org").replace(/\{\{\s*[a-zA-Z0-9_.-]+\s*\}\}/g, "segment");
754
+ if (!isSafeHttpUrl(resolvedProbe)) {
755
+ errors.push("ctaUrlTemplate must resolve to an http or https URL");
756
+ }
757
+ }
758
+ function validateAutomationRuleInput(input) {
759
+ const errors = [];
760
+ const slug = String(input.slug || "").trim();
761
+ const name = String(input.name || "").trim();
762
+ const eventName = String(input.eventName || "").trim();
763
+ const targetUid = String(input.targetContentType || "").trim();
764
+ if (!slug || !SLUG_REGEX.test(slug)) {
765
+ errors.push("Automation rule slug must be lowercase kebab-case");
766
+ }
767
+ if (name.length < 3) {
768
+ errors.push("Automation rule name must be at least 3 characters");
769
+ }
770
+ requireNonEmptyFields(
771
+ [
772
+ [input.eventName, "eventName is required"],
773
+ [input.subjectTemplate, "subjectTemplate is required"],
774
+ [input.bodyTextTemplate, "bodyTextTemplate is required"]
775
+ ],
776
+ errors
777
+ );
778
+ if (eventName.toLowerCase().startsWith("broadcasts.")) {
779
+ errors.push('eventName cannot start with "broadcasts." (loop protection)');
780
+ }
781
+ if (targetUid.toLowerCase().startsWith("plugin::broadcasts.")) {
782
+ errors.push(
783
+ 'targetContentType cannot target "plugin::broadcasts.*" (loop protection)'
784
+ );
785
+ }
786
+ if (input.channels !== void 0) {
787
+ validateCampaignChannelsList(input.channels, errors);
788
+ }
789
+ validateCtaUrlTemplate(input.ctaUrlTemplate, errors);
790
+ return { valid: errors.length === 0, errors };
791
+ }
792
+ const MONITORED_EVENTS = [
793
+ "inbox.alert.published",
794
+ "inbox.subscriber.confirmed",
795
+ "event.published",
796
+ "media-feeds.episode.synced"
797
+ ];
798
+ const hubRegistry = /* @__PURE__ */ new WeakMap();
799
+ function getOrCreateEntry(strapi) {
800
+ let entry = hubRegistry.get(strapi);
801
+ if (!entry) {
802
+ entry = { unsubs: [], boundEvents: /* @__PURE__ */ new Set(), usesCatchAll: false };
803
+ hubRegistry.set(strapi, entry);
804
+ }
805
+ return entry;
806
+ }
807
+ async function dispatchEventToAutomation(strapi, eventName, payload) {
808
+ if (!eventName || eventName.toLowerCase().startsWith("broadcasts.")) {
809
+ return;
810
+ }
811
+ try {
812
+ const automationService = strapi.plugin(PLUGIN_ID).service("automationService");
813
+ const safePayload = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
814
+ await automationService.handleEventTrigger(eventName, safePayload);
815
+ } catch (err) {
816
+ strapi.log.warn(
817
+ `[@vicacha-devs/strapi-plugin-broadcasts] EventHub automation warning for ${eventName}: ${err.message}`
818
+ );
819
+ }
820
+ }
821
+ function ensureEventHubListener(strapi, rawEventName) {
822
+ const evt = String(rawEventName || "").trim();
823
+ if (!evt || evt.toLowerCase().startsWith("broadcasts.")) return;
824
+ const entry = getOrCreateEntry(strapi);
825
+ if (entry.usesCatchAll || entry.boundEvents.has(evt)) return;
826
+ if (typeof strapi.eventHub?.on !== "function") return;
827
+ const listener = async (payload) => {
828
+ await dispatchEventToAutomation(strapi, evt, payload);
829
+ };
830
+ strapi.eventHub.on(evt, listener);
831
+ entry.boundEvents.add(evt);
832
+ entry.unsubs.push(() => {
833
+ if (typeof strapi.eventHub?.off === "function") {
834
+ strapi.eventHub.off(evt, listener);
835
+ } else if (typeof strapi.eventHub?.removeListener === "function") {
836
+ strapi.eventHub.removeListener(evt, listener);
837
+ }
838
+ });
839
+ }
840
+ async function registerEventHubListeners(strapi) {
841
+ cleanupEventHubListeners(strapi);
842
+ if (!strapi.eventHub) return;
843
+ const entry = getOrCreateEntry(strapi);
844
+ if (typeof strapi.eventHub.subscribe === "function") {
845
+ const unsubscribe = strapi.eventHub.subscribe(
846
+ async (eventName, payload) => {
847
+ await dispatchEventToAutomation(strapi, eventName, payload);
848
+ }
849
+ );
850
+ entry.usesCatchAll = true;
851
+ if (typeof unsubscribe === "function") {
852
+ entry.unsubs.push(unsubscribe);
853
+ }
854
+ return;
855
+ }
856
+ for (const evt of MONITORED_EVENTS) {
857
+ ensureEventHubListener(strapi, evt);
858
+ }
859
+ const automationService = strapi.plugin(PLUGIN_ID).service("automationService");
860
+ const existingRules = await automationService.listRules();
861
+ for (const rule of existingRules) {
862
+ if (rule.eventName) {
863
+ ensureEventHubListener(strapi, rule.eventName);
864
+ }
865
+ }
866
+ }
867
+ function cleanupEventHubListeners(strapi) {
868
+ const entry = hubRegistry.get(strapi);
869
+ if (!entry) return;
870
+ for (const unsub of entry.unsubs) {
871
+ try {
872
+ unsub();
873
+ } catch {
874
+ }
875
+ }
876
+ hubRegistry.delete(strapi);
877
+ }
878
+ const bootstrap = async ({ strapi }) => {
879
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
880
+ await settingsService.getSettings();
881
+ const campaignService = strapi.plugin(PLUGIN_ID).service("campaignService");
882
+ await campaignService.seedStarterCampaigns();
883
+ const automationService = strapi.plugin(PLUGIN_ID).service("automationService");
884
+ await automationService.seedStarterRules();
885
+ await registerEventHubListeners(strapi);
886
+ if (strapi.admin?.services?.permission?.actionProvider?.registerMany) {
887
+ await strapi.admin.services.permission.actionProvider.registerMany([
888
+ {
889
+ section: "plugins",
890
+ displayName: "Read Broadcast Campaigns & Delivery Ledger",
891
+ uid: "read",
892
+ pluginName: PLUGIN_ID
893
+ },
894
+ {
895
+ section: "plugins",
896
+ displayName: "Manage Broadcast Campaigns",
897
+ uid: "campaigns.manage",
898
+ pluginName: PLUGIN_ID
899
+ },
900
+ {
901
+ section: "plugins",
902
+ displayName: "Send & Dispatch Broadcasts",
903
+ uid: "campaigns.send",
904
+ pluginName: PLUGIN_ID
905
+ },
906
+ {
907
+ section: "plugins",
908
+ displayName: "Manage EventHub Automation Rules",
909
+ uid: "automations.manage",
910
+ pluginName: PLUGIN_ID
911
+ },
912
+ {
913
+ section: "plugins",
914
+ displayName: "Manage Broadcast Transport Settings",
915
+ uid: "settings.manage",
916
+ pluginName: PLUGIN_ID
917
+ }
918
+ ]);
919
+ }
920
+ };
921
+ const config = {
922
+ default: DEFAULT_BROADCAST_SETTINGS,
923
+ validator() {
924
+ }
925
+ };
926
+ const kind$2 = "collectionType";
927
+ const collectionName$2 = "broadcast_automation_rules";
928
+ const info$2 = { "singularName": "automation-rule", "pluralName": "automation-rules", "displayName": "Broadcast Automation Rule", "description": "EventHub-driven automated multi-channel broadcast rule" };
929
+ const options$2 = { "draftAndPublish": false };
930
+ const pluginOptions$2 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
931
+ const attributes$2 = { "name": { "type": "string", "required": true }, "slug": { "type": "uid", "targetField": "name", "required": true }, "eventName": { "type": "string", "required": true }, "targetContentType": { "type": "string" }, "topic": { "type": "string", "default": "announcements", "required": true }, "channels": { "type": "json", "required": true, "default": ["telegram"] }, "active": { "type": "boolean", "default": true }, "priority": { "type": "enumeration", "enum": ["low", "normal", "high", "urgent"], "default": "normal" }, "subjectTemplate": { "type": "string", "required": true }, "bodyTextTemplate": { "type": "text", "required": true }, "bodyHtmlTemplate": { "type": "richtext" }, "ctaLabelTemplate": { "type": "string" }, "ctaUrlTemplate": { "type": "string" }, "translations": { "type": "json", "default": {} }, "lastTriggeredAt": { "type": "datetime" }, "triggerCount": { "type": "integer", "default": 0, "min": 0 } };
932
+ const schema$2 = {
933
+ kind: kind$2,
934
+ collectionName: collectionName$2,
935
+ info: info$2,
936
+ options: options$2,
937
+ pluginOptions: pluginOptions$2,
938
+ attributes: attributes$2
939
+ };
940
+ const automationRule = {
941
+ schema: schema$2
942
+ };
943
+ const kind$1 = "collectionType";
944
+ const collectionName$1 = "broadcast_campaigns";
945
+ const info$1 = { "singularName": "campaign", "pluralName": "campaigns", "displayName": "Broadcast Campaign", "description": "Omnichannel Email, Telegram & WhatsApp broadcast campaign" };
946
+ const options$1 = { "draftAndPublish": false };
947
+ const pluginOptions$1 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
948
+ const attributes$1 = { "title": { "type": "string", "required": true }, "slug": { "type": "uid", "targetField": "title", "required": true }, "topic": { "type": "string", "default": "announcements", "required": true }, "channels": { "type": "json", "required": true, "default": ["email", "telegram"] }, "priority": { "type": "enumeration", "enum": ["low", "normal", "high", "urgent"], "default": "normal", "required": true }, "status": { "type": "enumeration", "enum": ["draft", "scheduled", "sending", "sent", "failed", "cancelled"], "default": "draft", "required": true }, "scheduledAt": { "type": "datetime" }, "sentAt": { "type": "datetime" }, "subject": { "type": "string", "required": true }, "bodyText": { "type": "text", "required": true }, "bodyHtml": { "type": "richtext" }, "ctaLabel": { "type": "string" }, "ctaUrl": { "type": "string" }, "mediaUrl": { "type": "string" }, "emailTemplateRef": { "type": "string" }, "whatsappTemplateName": { "type": "string" }, "whatsappGroupIds": { "type": "json", "default": [] }, "telegramChatId": { "type": "string" }, "telegramThreadId": { "type": "integer" }, "translations": { "type": "json", "default": {} }, "secondaryDate": { "type": "json", "default": {} }, "secondaryDateLabel": { "type": "string" }, "stats": { "type": "json", "default": {} }, "metadata": { "type": "json", "default": {} } };
949
+ const schema$1 = {
950
+ kind: kind$1,
951
+ collectionName: collectionName$1,
952
+ info: info$1,
953
+ options: options$1,
954
+ pluginOptions: pluginOptions$1,
955
+ attributes: attributes$1
956
+ };
957
+ const campaign = {
958
+ schema: schema$1
959
+ };
960
+ const kind = "collectionType";
961
+ const collectionName = "broadcast_delivery_logs";
962
+ const info = { "singularName": "delivery-log", "pluralName": "delivery-logs", "displayName": "Broadcast Delivery Log", "description": "Idempotent, PII-masked multi-channel delivery audit log" };
963
+ const options = { "draftAndPublish": false };
964
+ const pluginOptions = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
965
+ const attributes = { "idempotencyKey": { "type": "string", "required": true, "unique": true }, "campaignSlug": { "type": "string", "required": true }, "channel": { "type": "enumeration", "enum": ["email", "telegram", "whatsapp"], "required": true }, "driver": { "type": "string", "required": true }, "recipientMasked": { "type": "string", "required": true }, "locale": { "type": "string", "default": "es", "required": true }, "status": { "type": "enumeration", "enum": ["sent", "simulated", "skipped_duplicate", "failed"], "required": true }, "externalMessageId": { "type": "string" }, "errorMessage": { "type": "text" }, "sentAt": { "type": "datetime", "required": true }, "secondaryDateLabel": { "type": "string" } };
966
+ const schema = {
967
+ kind,
968
+ collectionName,
969
+ info,
970
+ options,
971
+ pluginOptions,
972
+ attributes
973
+ };
974
+ const deliveryLog = {
975
+ schema
976
+ };
977
+ const contentTypes = {
978
+ campaign,
979
+ "automation-rule": automationRule,
980
+ "delivery-log": deliveryLog
981
+ };
982
+ const SECRET_MASK_ONLY_RE = /^[•*]+$/;
983
+ function computeIdempotencyKey(campaignSlug, channel, recipient, options2 = {}) {
984
+ const normalizedRecipient = String(recipient || "").trim().toLowerCase();
985
+ const digest = node_crypto.createHmac("sha256", "vicacha-broadcasts-idempotency-v1").update(`${campaignSlug}:${channel}:${normalizedRecipient}`, "utf8").digest("hex").slice(0, 24);
986
+ const baseKey = `${campaignSlug}:${channel}:${digest}`;
987
+ return options2.isTest ? `test:${baseKey}` : baseKey;
988
+ }
989
+ function generateUniqueSlugSuffix() {
990
+ return `${Date.now().toString(36)}-${node_crypto.randomBytes(3).toString("hex")}`;
991
+ }
992
+ function redactSecretsFromMessage(rawMessage, secrets = []) {
993
+ let sanitized = String(rawMessage || "");
994
+ if (!sanitized) return "";
995
+ for (const candidate of secrets) {
996
+ const trimmed = typeof candidate === "string" ? candidate.trim() : "";
997
+ if (trimmed.length < 4 || SECRET_MASK_ONLY_RE.test(trimmed)) continue;
998
+ sanitized = sanitized.split(trimmed).join("[REDACTED]");
999
+ const encoded = encodeURIComponent(trimmed);
1000
+ if (encoded !== trimmed && encoded.length >= 4) {
1001
+ sanitized = sanitized.split(encoded).join("[REDACTED]");
1002
+ }
1003
+ }
1004
+ sanitized = sanitized.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]");
1005
+ sanitized = sanitized.replace(/\b\d{6,}:[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]");
1006
+ sanitized = sanitized.replace(
1007
+ /Bearer\s+[A-Z0-9._~+/=-]+/gi,
1008
+ "Bearer [REDACTED]"
1009
+ );
1010
+ sanitized = sanitized.replace(
1011
+ /((?:api[_-]?key|access[_-]?token|token)=)[^&\s"']+/gi,
1012
+ "$1[REDACTED]"
1013
+ );
1014
+ return sanitized;
1015
+ }
1016
+ const TELEGRAM_BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]+$/;
1017
+ const FETCH_TIMEOUT_MS = 15e3;
1018
+ function clampLimit(val, defaultLimit) {
1019
+ if (val === void 0 || val === null || val === "") return defaultLimit;
1020
+ const parsed = typeof val === "number" ? val : typeof val === "string" ? Number(val) : Number.NaN;
1021
+ if (!Number.isFinite(parsed)) return defaultLimit;
1022
+ return Math.min(100, Math.max(1, Math.trunc(parsed)));
1023
+ }
1024
+ function buildTelegramIdempotencyKey(payload, targetChatId) {
1025
+ const hasThread = typeof payload.threadId === "number" && payload.threadId > 0;
1026
+ const threadKeySuffix = hasThread ? `:${payload.threadId}` : "";
1027
+ return computeIdempotencyKey(
1028
+ payload.campaignSlug,
1029
+ "telegram",
1030
+ `${targetChatId}${threadKeySuffix}:${payload.locale}`,
1031
+ { isTest: payload.isTestDispatch }
1032
+ );
1033
+ }
1034
+ function makeResult(channel, driver, recipientMasked, locale, status, idempotencyKey, extra) {
1035
+ return {
1036
+ channel,
1037
+ driver,
1038
+ recipientMasked,
1039
+ locale,
1040
+ status,
1041
+ idempotencyKey,
1042
+ ...extra
1043
+ };
1044
+ }
1045
+ function parseTargetList(raw) {
1046
+ if (!raw) return [];
1047
+ const items = Array.isArray(raw) ? raw : String(raw).split(",");
1048
+ return Array.from(
1049
+ new Set(items.map((item) => String(item).trim()).filter(Boolean))
1050
+ );
1051
+ }
1052
+ function resolveTelegramTargets(campaign2, settings) {
1053
+ const fromCampaign = parseTargetList(campaign2.telegramChatId);
1054
+ if (fromCampaign.length > 0) return fromCampaign;
1055
+ const fromTopic = parseTargetList(
1056
+ settings.telegramTopicChatMap?.[campaign2.topic]
1057
+ );
1058
+ if (fromTopic.length > 0) return fromTopic;
1059
+ return parseTargetList(settings.telegramDefaultChatId);
1060
+ }
1061
+ function resolveWhatsAppRecipients(campaign2, settings, subscribers, isTestDispatch) {
1062
+ const configuredGroupsAndNumbers = isTestDispatch ? [] : [
1063
+ ...parseTargetList(settings.whatsappDefaultRecipients),
1064
+ ...parseTargetList(settings.whatsappTopicGroupMap?.[campaign2.topic]),
1065
+ ...parseTargetList(campaign2.whatsappGroupIds)
1066
+ ];
1067
+ const rawPhoneRecipients = [
1068
+ ...subscribers.filter((r) => Boolean(r.phone)),
1069
+ ...configuredGroupsAndNumbers.map((phone) => ({
1070
+ phone,
1071
+ fullName: "Comunidad",
1072
+ locale: settings.defaultLocale || "es",
1073
+ topics: [campaign2.topic]
1074
+ }))
1075
+ ];
1076
+ const seenWhatsAppTargets = /* @__PURE__ */ new Set();
1077
+ const deduplicated = [];
1078
+ for (const recipient of rawPhoneRecipients) {
1079
+ if (!recipient.phone) continue;
1080
+ const normKey = normalizeWhatsAppTarget(recipient.phone).normalized;
1081
+ if (!normKey || seenWhatsAppTargets.has(normKey)) continue;
1082
+ seenWhatsAppTargets.add(normKey);
1083
+ deduplicated.push(recipient);
1084
+ }
1085
+ return deduplicated;
1086
+ }
1087
+ function createEmptyStats() {
1088
+ return {
1089
+ totalRecipients: 0,
1090
+ sentCount: 0,
1091
+ simulatedCount: 0,
1092
+ skippedCount: 0,
1093
+ failedCount: 0,
1094
+ byChannel: {}
1095
+ };
1096
+ }
1097
+ function recordResultInStats(stats, result) {
1098
+ stats.totalRecipients += 1;
1099
+ const bucket = stats.byChannel[result.channel] || {
1100
+ sent: 0,
1101
+ simulated: 0,
1102
+ skipped: 0,
1103
+ failed: 0
1104
+ };
1105
+ if (result.status === "sent") {
1106
+ stats.sentCount += 1;
1107
+ bucket.sent += 1;
1108
+ } else if (result.status === "simulated") {
1109
+ stats.simulatedCount += 1;
1110
+ bucket.simulated += 1;
1111
+ } else if (result.status === "skipped_duplicate") {
1112
+ stats.skippedCount += 1;
1113
+ bucket.skipped += 1;
1114
+ } else {
1115
+ stats.failedCount += 1;
1116
+ bucket.failed += 1;
1117
+ }
1118
+ stats.byChannel[result.channel] = bucket;
1119
+ }
1120
+ function resolveEffectiveEmailTemplateRef(payload, settings) {
1121
+ if (settings.enableEmailDesignerBridge === false) return "";
1122
+ return String(
1123
+ payload.emailTemplateRef || settings.defaultEmailTemplateRef || ""
1124
+ ).trim();
1125
+ }
1126
+ function buildEmailTemplateVariables(payload, settings, inlineHtml) {
1127
+ const extraEntries = Object.entries(payload.templateData || {}).map(
1128
+ ([k, v]) => [
1129
+ k,
1130
+ v !== void 0 && v !== null ? escapeHtml(String(v)) : void 0
1131
+ ]
1132
+ );
1133
+ return {
1134
+ subject: escapeHtml(payload.subject),
1135
+ bodyText: escapeHtml(payload.text),
1136
+ bodyHtml: inlineHtml,
1137
+ text: escapeHtml(payload.text),
1138
+ html: inlineHtml,
1139
+ ctaLabel: escapeHtml(payload.ctaLabel || ""),
1140
+ ctaUrl: escapeHtml(payload.ctaUrl || settings.siteBaseUrl),
1141
+ unsubscribeUrl: escapeHtml(payload.unsubscribeUrl || ""),
1142
+ secondaryDate: escapeHtml(payload.secondaryDateLabel || ""),
1143
+ organizationName: escapeHtml(settings.organizationName),
1144
+ siteBaseUrl: escapeHtml(settings.siteBaseUrl),
1145
+ ...Object.fromEntries(extraEntries)
1146
+ };
1147
+ }
1148
+ function buildTelegramRequestPayload(payload, targetChatId) {
1149
+ const normalizedSubject = String(payload.subject || "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
1150
+ const safeSubject = escapeHtml(normalizedSubject);
1151
+ const safeBody = sanitizeTelegramHtml(payload.htmlBody);
1152
+ const text = safeBody ? `<b>${safeSubject}</b>
1153
+
1154
+ ${safeBody}` : `<b>${safeSubject}</b>`;
1155
+ const bodyPayload = {
1156
+ chat_id: targetChatId,
1157
+ text,
1158
+ parse_mode: "HTML",
1159
+ disable_web_page_preview: false
1160
+ };
1161
+ if (typeof payload.threadId === "number" && payload.threadId > 0) {
1162
+ bodyPayload.message_thread_id = payload.threadId;
1163
+ }
1164
+ if (payload.ctaLabel && payload.ctaUrl && isSafeHttpUrl(payload.ctaUrl)) {
1165
+ bodyPayload.reply_markup = {
1166
+ inline_keyboard: [[{ text: payload.ctaLabel, url: payload.ctaUrl }]]
1167
+ };
1168
+ }
1169
+ return bodyPayload;
1170
+ }
1171
+ async function executeTelegramHttpSend(payload, targetChatId, botToken) {
1172
+ const cleanToken = String(botToken || "").trim();
1173
+ if (!TELEGRAM_BOT_TOKEN_RE.test(cleanToken)) {
1174
+ throw new Error("Invalid Telegram bot token format");
1175
+ }
1176
+ const endpoint = `https://api.telegram.org/bot${cleanToken}/sendMessage`;
1177
+ const bodyPayload = buildTelegramRequestPayload(payload, targetChatId);
1178
+ const response = await fetch(endpoint, {
1179
+ method: "POST",
1180
+ headers: { "Content-Type": "application/json" },
1181
+ body: JSON.stringify(bodyPayload),
1182
+ redirect: "error",
1183
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1184
+ });
1185
+ const json = await response.json();
1186
+ if (!response.ok || !json.ok) {
1187
+ throw new Error(json.description || `Telegram HTTP ${response.status}`);
1188
+ }
1189
+ return String(json.result?.message_id || Date.now());
1190
+ }
1191
+ async function sendViaMetaCloudApi(payload, settings, evolutionNumber, fullText) {
1192
+ const phoneId = String(settings.whatsappPhoneNumberId || "").trim();
1193
+ if (!/^[A-Za-z0-9_-]+$/.test(phoneId)) {
1194
+ throw new Error("Invalid Meta WABA phoneNumberId format");
1195
+ }
1196
+ const endpoint = `https://graph.facebook.com/v20.0/${encodeURIComponent(
1197
+ phoneId
1198
+ )}/messages`;
1199
+ const reqBody = payload.templateName ? {
1200
+ messaging_product: "whatsapp",
1201
+ to: evolutionNumber,
1202
+ type: "template",
1203
+ template: {
1204
+ name: payload.templateName,
1205
+ language: { code: payload.locale }
1206
+ }
1207
+ } : {
1208
+ messaging_product: "whatsapp",
1209
+ to: evolutionNumber,
1210
+ type: "text",
1211
+ text: { preview_url: true, body: fullText }
1212
+ };
1213
+ const res = await fetch(endpoint, {
1214
+ method: "POST",
1215
+ headers: {
1216
+ "Authorization": `Bearer ${settings.whatsappAccessToken}`,
1217
+ "Content-Type": "application/json"
1218
+ },
1219
+ body: JSON.stringify(reqBody),
1220
+ redirect: "error",
1221
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1222
+ });
1223
+ const json = await res.json();
1224
+ if (!res.ok) {
1225
+ throw new Error(json.error?.message || `Meta Cloud HTTP ${res.status}`);
1226
+ }
1227
+ return json.messages?.[0]?.id || `waba_${Date.now()}`;
1228
+ }
1229
+ async function sendViaSelfHostedGateway(driver, settings, target, fullText) {
1230
+ const gatewayCheck = validateGatewayUrl(settings.whatsappGatewayUrl || "", {
1231
+ allowLocalhost: Boolean(settings.allowLocalhostGateways)
1232
+ });
1233
+ if (!gatewayCheck.valid) throw new Error(gatewayCheck.errors.join("; "));
1234
+ const rawSession = String(
1235
+ settings.whatsappGatewaySession || "default"
1236
+ ).trim();
1237
+ if (!isValidGatewaySession(rawSession)) {
1238
+ throw new Error("Invalid WhatsApp gateway session identifier");
1239
+ }
1240
+ let baseUrl = settings.whatsappGatewayUrl || "";
1241
+ while (baseUrl.endsWith("/")) {
1242
+ baseUrl = baseUrl.slice(0, -1);
1243
+ }
1244
+ const session = encodeURIComponent(rawSession);
1245
+ const endpoint = driver === "evolution" ? `${baseUrl}/message/sendText/${session}` : `${baseUrl}/api/sendText`;
1246
+ const headers = {
1247
+ "Content-Type": "application/json"
1248
+ };
1249
+ if (settings.whatsappGatewayApiKey) {
1250
+ headers.apikey = settings.whatsappGatewayApiKey;
1251
+ headers["X-Api-Key"] = settings.whatsappGatewayApiKey;
1252
+ }
1253
+ const body = driver === "evolution" ? { number: target.evolutionNumber, text: fullText } : {
1254
+ session: rawSession,
1255
+ chatId: target.chatId,
1256
+ text: fullText
1257
+ };
1258
+ const res = await fetch(endpoint, {
1259
+ method: "POST",
1260
+ headers,
1261
+ body: JSON.stringify(body),
1262
+ redirect: "error",
1263
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
1264
+ });
1265
+ if (!res.ok) throw new Error(`${driver} gateway HTTP ${res.status}`);
1266
+ return `${driver}_${Date.now()}`;
1267
+ }
1268
+ async function executeWhatsAppHttpSend(driver, payload, settings, target) {
1269
+ const safeCtaUrl = payload.ctaUrl && isSafeHttpUrl(payload.ctaUrl) ? payload.ctaUrl : void 0;
1270
+ const fullText = safeCtaUrl ? `${payload.textBody}
1271
+
1272
+ 🔗 ${safeCtaUrl}` : payload.textBody;
1273
+ if (driver === "meta-cloud") {
1274
+ return sendViaMetaCloudApi(
1275
+ payload,
1276
+ settings,
1277
+ target.evolutionNumber,
1278
+ fullText
1279
+ );
1280
+ }
1281
+ return sendViaSelfHostedGateway(driver, settings, target, fullText);
1282
+ }
1283
+ function hasWhatsAppCredentials(driver, settings) {
1284
+ if (driver === "meta-cloud") {
1285
+ return Boolean(
1286
+ settings.whatsappPhoneNumberId && settings.whatsappAccessToken
1287
+ );
1288
+ }
1289
+ return Boolean(settings.whatsappGatewayUrl);
1290
+ }
1291
+ function toPlainString(val) {
1292
+ if (typeof val !== "string") return void 0;
1293
+ const trimmed = val.trim();
1294
+ return trimmed.length > 0 ? trimmed : void 0;
1295
+ }
1296
+ function respondError(ctx, err) {
1297
+ const message = err?.message || "Invalid request";
1298
+ if (/not found$/i.test(message)) {
1299
+ if (typeof ctx.notFound === "function") {
1300
+ return ctx.notFound(message);
1301
+ }
1302
+ ctx.status = 404;
1303
+ ctx.body = { error: message };
1304
+ return void 0;
1305
+ }
1306
+ if (typeof ctx.badRequest === "function") {
1307
+ return ctx.badRequest(message);
1308
+ }
1309
+ ctx.status = 400;
1310
+ ctx.body = { error: message };
1311
+ return void 0;
1312
+ }
1313
+ const broadcastController = ({ strapi }) => ({
1314
+ async getSettings(ctx) {
1315
+ const svc = strapi.plugin(PLUGIN_ID).service("settings");
1316
+ const data = await svc.getPublicSafeSettings();
1317
+ ctx.body = { data };
1318
+ },
1319
+ async updateSettings(ctx) {
1320
+ const svc = strapi.plugin(PLUGIN_ID).service("settings");
1321
+ try {
1322
+ await svc.updateSettings(
1323
+ ctx.request.body || {}
1324
+ );
1325
+ const data = await svc.getPublicSafeSettings();
1326
+ ctx.body = { data };
1327
+ return void 0;
1328
+ } catch (err) {
1329
+ return respondError(ctx, err);
1330
+ }
1331
+ },
1332
+ async listStarterTemplates(ctx) {
1333
+ ctx.body = { data: STARTER_CAMPAIGN_TEMPLATES };
1334
+ },
1335
+ async listEmailTemplates(ctx) {
1336
+ const svc = strapi.plugin(PLUGIN_ID).service("transportService");
1337
+ const data = await svc.listEmailDesignerTemplates();
1338
+ ctx.body = { data };
1339
+ },
1340
+ async listCampaigns(ctx) {
1341
+ const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
1342
+ const data = await svc.listCampaigns({
1343
+ status: toPlainString(ctx.query?.status),
1344
+ topic: toPlainString(ctx.query?.topic)
1345
+ });
1346
+ ctx.body = { data };
1347
+ },
1348
+ async createCampaign(ctx) {
1349
+ const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
1350
+ try {
1351
+ const data = await svc.createCampaign(
1352
+ ctx.request.body || {}
1353
+ );
1354
+ ctx.status = 201;
1355
+ ctx.body = { data };
1356
+ return void 0;
1357
+ } catch (err) {
1358
+ return respondError(ctx, err);
1359
+ }
1360
+ },
1361
+ async updateCampaign(ctx) {
1362
+ const documentId = toPlainString(ctx.params?.documentId) || "";
1363
+ const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
1364
+ try {
1365
+ const data = await svc.updateCampaign(
1366
+ documentId,
1367
+ ctx.request.body || {}
1368
+ );
1369
+ ctx.body = { data };
1370
+ return void 0;
1371
+ } catch (err) {
1372
+ return respondError(ctx, err);
1373
+ }
1374
+ },
1375
+ async createFromTemplate(ctx) {
1376
+ const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
1377
+ try {
1378
+ const body = ctx.request.body || {};
1379
+ const templateId = toPlainString(body.templateId) || "";
1380
+ const data = await svc.createFromStarterTemplate(templateId);
1381
+ ctx.status = 201;
1382
+ ctx.body = { data };
1383
+ return void 0;
1384
+ } catch (err) {
1385
+ return respondError(ctx, err);
1386
+ }
1387
+ },
1388
+ async sendCampaign(ctx) {
1389
+ const documentId = toPlainString(ctx.params?.documentId) || "";
1390
+ const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
1391
+ try {
1392
+ const rawBody = ctx.request.body && typeof ctx.request.body === "object" && !Array.isArray(ctx.request.body) ? ctx.request.body : {};
1393
+ const customRecipients = Array.isArray(rawBody.customRecipients) ? rawBody.customRecipients.filter(
1394
+ (item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
1395
+ ) : void 0;
1396
+ const data = await svc.dispatchCampaign(documentId, {
1397
+ customRecipients,
1398
+ testRecipientEmail: toPlainString(rawBody.testRecipientEmail),
1399
+ testRecipientPhone: toPlainString(rawBody.testRecipientPhone),
1400
+ testTelegramChatId: toPlainString(rawBody.testTelegramChatId)
1401
+ });
1402
+ ctx.body = { data };
1403
+ return void 0;
1404
+ } catch (err) {
1405
+ return respondError(ctx, err);
1406
+ }
1407
+ },
1408
+ async listAutomationRules(ctx) {
1409
+ const svc = strapi.plugin(PLUGIN_ID).service("automationService");
1410
+ const data = await svc.listRules();
1411
+ ctx.body = { data };
1412
+ },
1413
+ async createAutomationRule(ctx) {
1414
+ const svc = strapi.plugin(PLUGIN_ID).service("automationService");
1415
+ try {
1416
+ const data = await svc.createRule(
1417
+ ctx.request.body || {}
1418
+ );
1419
+ ctx.status = 201;
1420
+ ctx.body = { data };
1421
+ return void 0;
1422
+ } catch (err) {
1423
+ return respondError(ctx, err);
1424
+ }
1425
+ },
1426
+ async updateAutomationRule(ctx) {
1427
+ const documentId = toPlainString(ctx.params?.documentId) || "";
1428
+ const svc = strapi.plugin(PLUGIN_ID).service("automationService");
1429
+ try {
1430
+ const data = await svc.updateRule(
1431
+ documentId,
1432
+ ctx.request.body || {}
1433
+ );
1434
+ ctx.body = { data };
1435
+ return void 0;
1436
+ } catch (err) {
1437
+ return respondError(ctx, err);
1438
+ }
1439
+ },
1440
+ async listDeliveryLogs(ctx) {
1441
+ const svc = strapi.plugin(PLUGIN_ID).service("transportService");
1442
+ const rawChannel = toPlainString(ctx.query?.channel);
1443
+ const channel = rawChannel && SUPPORTED_CHANNELS.includes(rawChannel) ? rawChannel : void 0;
1444
+ const data = await svc.listDeliveryLogs({
1445
+ campaignSlug: toPlainString(ctx.query?.campaignSlug),
1446
+ channel,
1447
+ limit: clampLimit(ctx.query?.limit, 100)
1448
+ });
1449
+ ctx.body = { data };
1450
+ },
1451
+ async listPublicChannels(ctx) {
1452
+ const svc = strapi.plugin(PLUGIN_ID).service("settings");
1453
+ const settings = await svc.getSettings();
1454
+ const safeLinks = (settings.publicChannelLinks || []).filter(
1455
+ (link) => isSafeHttpUrl(link?.inviteUrl)
1456
+ );
1457
+ ctx.body = {
1458
+ data: {
1459
+ organizationName: settings.organizationName,
1460
+ defaultLocale: settings.defaultLocale,
1461
+ supportedLocales: settings.supportedLocales,
1462
+ availableTopics: settings.availableTopics,
1463
+ publicChannelLinks: safeLinks
1464
+ }
1465
+ };
1466
+ },
1467
+ async listPublicArchive(ctx) {
1468
+ const svc = strapi.plugin(PLUGIN_ID).service("campaignService");
1469
+ const data = await svc.listPublicArchive({
1470
+ topic: toPlainString(ctx.query?.topic),
1471
+ limit: clampLimit(ctx.query?.limit, 20)
1472
+ });
1473
+ ctx.body = { data };
1474
+ }
1475
+ });
1476
+ const controllers = {
1477
+ broadcastController
1478
+ };
1479
+ const destroy = ({ strapi }) => {
1480
+ cleanupEventHubListeners(strapi);
1481
+ strapi.log.debug("[@vicacha-devs/strapi-plugin-broadcasts] Destroyed");
1482
+ };
1483
+ const DANGEROUS_KEYS$1 = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1484
+ function sanitizeAttributeDefinition(attrDef) {
1485
+ const safe = {};
1486
+ for (const [key, val] of Object.entries(attrDef)) {
1487
+ if (DANGEROUS_KEYS$1.has(key) || !Object.hasOwn(attrDef, key)) continue;
1488
+ safe[key] = val;
1489
+ }
1490
+ return safe;
1491
+ }
1492
+ function applyAttributeExtension(schema2, attrName, attrDef) {
1493
+ if (!schema2 || DANGEROUS_KEYS$1.has(attrName)) return;
1494
+ const safeDef = sanitizeAttributeDefinition(attrDef);
1495
+ if (schema2.attributes) {
1496
+ const prev = Object.hasOwn(schema2.attributes, attrName) ? schema2.attributes[attrName] : {};
1497
+ schema2.attributes[attrName] = { ...prev, ...safeDef };
1498
+ }
1499
+ if (schema2.__schema__?.attributes) {
1500
+ const prev = Object.hasOwn(schema2.__schema__.attributes, attrName) ? schema2.__schema__.attributes[attrName] : {};
1501
+ schema2.__schema__.attributes[attrName] = { ...prev, ...safeDef };
1502
+ }
1503
+ }
1504
+ function extendContentTypeSchema(strapi, targetKey, extraAttributes) {
1505
+ if (!targetKey || DANGEROUS_KEYS$1.has(targetKey) || !extraAttributes || typeof extraAttributes !== "object" || Array.isArray(extraAttributes)) {
1506
+ return;
1507
+ }
1508
+ const resolvedUid = targetKey.startsWith("plugin::") ? targetKey : `plugin::${PLUGIN_ID}.${targetKey}`;
1509
+ if (DANGEROUS_KEYS$1.has(resolvedUid)) return;
1510
+ const contentTypes2 = strapi.contentTypes;
1511
+ const schema2 = contentTypes2 && Object.hasOwn(contentTypes2, resolvedUid) ? contentTypes2[resolvedUid] : void 0;
1512
+ for (const [attrName, attrDef] of Object.entries(extraAttributes)) {
1513
+ if (!attrName || DANGEROUS_KEYS$1.has(attrName) || !Object.hasOwn(extraAttributes, attrName) || !attrDef || typeof attrDef !== "object" || Array.isArray(attrDef)) {
1514
+ continue;
1515
+ }
1516
+ applyAttributeExtension(schema2, attrName, attrDef);
1517
+ }
1518
+ }
1519
+ const register = ({ strapi }) => {
1520
+ const config2 = strapi.config?.get?.(`plugin::${PLUGIN_ID}`) ?? {};
1521
+ const extensions = config2.schemaExtensions;
1522
+ if (extensions && typeof extensions === "object" && !Array.isArray(extensions)) {
1523
+ for (const [targetKey, extraAttributes] of Object.entries(extensions)) {
1524
+ if (!Object.hasOwn(extensions, targetKey)) continue;
1525
+ extendContentTypeSchema(strapi, targetKey, extraAttributes);
1526
+ }
1527
+ }
1528
+ strapi.log.debug(
1529
+ "[@vicacha-devs/strapi-plugin-broadcasts] Registered omnichannel broadcasts plugin"
1530
+ );
1531
+ };
1532
+ function adminPolicies(...actionUids) {
1533
+ return [
1534
+ "admin::isAuthenticatedAdmin",
1535
+ {
1536
+ name: "admin::hasPermissions",
1537
+ config: {
1538
+ actions: actionUids.map((uid) => `plugin::${PLUGIN_ID}.${uid}`)
1539
+ }
1540
+ }
1541
+ ];
1542
+ }
1543
+ const admin = {
1544
+ type: "admin",
1545
+ routes: [
1546
+ {
1547
+ method: "GET",
1548
+ path: "/settings",
1549
+ handler: "broadcastController.getSettings",
1550
+ config: { policies: adminPolicies("settings.manage") }
1551
+ },
1552
+ {
1553
+ method: "PUT",
1554
+ path: "/settings",
1555
+ handler: "broadcastController.updateSettings",
1556
+ config: { policies: adminPolicies("settings.manage") }
1557
+ },
1558
+ {
1559
+ method: "GET",
1560
+ path: "/templates",
1561
+ handler: "broadcastController.listStarterTemplates",
1562
+ config: { policies: adminPolicies("read") }
1563
+ },
1564
+ {
1565
+ method: "GET",
1566
+ path: "/email-templates",
1567
+ handler: "broadcastController.listEmailTemplates",
1568
+ config: { policies: adminPolicies("read") }
1569
+ },
1570
+ {
1571
+ method: "GET",
1572
+ path: "/campaigns",
1573
+ handler: "broadcastController.listCampaigns",
1574
+ config: { policies: adminPolicies("read") }
1575
+ },
1576
+ {
1577
+ method: "POST",
1578
+ path: "/campaigns",
1579
+ handler: "broadcastController.createCampaign",
1580
+ config: { policies: adminPolicies("campaigns.manage") }
1581
+ },
1582
+ {
1583
+ method: "PUT",
1584
+ path: "/campaigns/:documentId",
1585
+ handler: "broadcastController.updateCampaign",
1586
+ config: { policies: adminPolicies("campaigns.manage") }
1587
+ },
1588
+ {
1589
+ method: "POST",
1590
+ path: "/campaigns/from-template",
1591
+ handler: "broadcastController.createFromTemplate",
1592
+ config: { policies: adminPolicies("campaigns.manage") }
1593
+ },
1594
+ {
1595
+ method: "POST",
1596
+ path: "/campaigns/:documentId/send",
1597
+ handler: "broadcastController.sendCampaign",
1598
+ config: { policies: adminPolicies("campaigns.send") }
1599
+ },
1600
+ {
1601
+ method: "GET",
1602
+ path: "/automation-rules",
1603
+ handler: "broadcastController.listAutomationRules",
1604
+ config: { policies: adminPolicies("read") }
1605
+ },
1606
+ {
1607
+ method: "POST",
1608
+ path: "/automation-rules",
1609
+ handler: "broadcastController.createAutomationRule",
1610
+ config: { policies: adminPolicies("automations.manage") }
1611
+ },
1612
+ {
1613
+ method: "PUT",
1614
+ path: "/automation-rules/:documentId",
1615
+ handler: "broadcastController.updateAutomationRule",
1616
+ config: { policies: adminPolicies("automations.manage") }
1617
+ },
1618
+ {
1619
+ method: "GET",
1620
+ path: "/delivery-logs",
1621
+ handler: "broadcastController.listDeliveryLogs",
1622
+ config: { policies: adminPolicies("read") }
1623
+ }
1624
+ ]
1625
+ };
1626
+ const contentApi = {
1627
+ type: "content-api",
1628
+ routes: [
1629
+ {
1630
+ method: "GET",
1631
+ path: "/public/channels",
1632
+ handler: "broadcastController.listPublicChannels",
1633
+ config: {
1634
+ policies: [],
1635
+ auth: false
1636
+ }
1637
+ },
1638
+ {
1639
+ method: "GET",
1640
+ path: "/public/archive",
1641
+ handler: "broadcastController.listPublicArchive",
1642
+ config: {
1643
+ policies: [],
1644
+ auth: false
1645
+ }
1646
+ }
1647
+ ]
1648
+ };
1649
+ const routes = {
1650
+ "content-api": contentApi,
1651
+ admin
1652
+ };
1653
+ function buildRuleDocumentData(rule, channels, fallbackActive = true) {
1654
+ const safeBodyTemplate = String(rule.bodyTextTemplate || "");
1655
+ return {
1656
+ slug: rule.slug,
1657
+ name: rule.name,
1658
+ eventName: rule.eventName,
1659
+ targetContentType: rule.targetContentType,
1660
+ topic: rule.topic || "announcements",
1661
+ channels,
1662
+ active: rule.active ?? fallbackActive,
1663
+ priority: rule.priority || "normal",
1664
+ subjectTemplate: rule.subjectTemplate,
1665
+ bodyTextTemplate: safeBodyTemplate,
1666
+ bodyHtmlTemplate: rule.bodyHtmlTemplate || `<p>${escapeHtml(safeBodyTemplate)}</p>`,
1667
+ ctaLabelTemplate: rule.ctaLabelTemplate,
1668
+ ctaUrlTemplate: rule.ctaUrlTemplate,
1669
+ translations: rule.translations || {}
1670
+ };
1671
+ }
1672
+ function buildPresetRulePayload(preset) {
1673
+ return {
1674
+ slug: preset.slug,
1675
+ name: preset.name,
1676
+ eventName: preset.eventName,
1677
+ targetContentType: preset.targetContentType,
1678
+ topic: preset.topic || "announcements",
1679
+ channels: preset.channels || ["telegram"],
1680
+ active: preset.active ?? true,
1681
+ priority: preset.priority || "normal",
1682
+ subjectTemplate: preset.subjectTemplate || "📢 {{title}}",
1683
+ bodyTextTemplate: preset.bodyTextTemplate || "{{title}}: {{message}} ({{secondaryDate}})",
1684
+ bodyHtmlTemplate: preset.bodyHtmlTemplate,
1685
+ ctaLabelTemplate: preset.ctaLabelTemplate,
1686
+ ctaUrlTemplate: preset.ctaUrlTemplate,
1687
+ translations: preset.translations || {}
1688
+ };
1689
+ }
1690
+ function doesRuleMatchTrigger(rule, eventName, payload) {
1691
+ if (!rule.active || rule.eventName !== eventName) return false;
1692
+ if (rule.targetContentType && payload.model && String(payload.model) !== rule.targetContentType) {
1693
+ return false;
1694
+ }
1695
+ return true;
1696
+ }
1697
+ function firstNonEmptyString(record, keys, fallback) {
1698
+ for (const key of keys) {
1699
+ const val = record[key];
1700
+ if (val !== void 0 && val !== null && String(val) !== "") {
1701
+ return String(val);
1702
+ }
1703
+ }
1704
+ return fallback;
1705
+ }
1706
+ function extractTriggerVariables(eventName, payload, settings, secondaryDateFormatted) {
1707
+ const entryRecord = payload.entry ?? payload;
1708
+ return {
1709
+ title: firstNonEmptyString(
1710
+ entryRecord,
1711
+ ["title", "name", "subject"],
1712
+ eventName
1713
+ ),
1714
+ message: firstNonEmptyString(
1715
+ entryRecord,
1716
+ ["message", "summary", "description"],
1717
+ ""
1718
+ ),
1719
+ summary: firstNonEmptyString(
1720
+ entryRecord,
1721
+ ["summary", "description", "message"],
1722
+ ""
1723
+ ),
1724
+ code: firstNonEmptyString(entryRecord, ["code", "slug"], "event"),
1725
+ organizationName: settings.organizationName,
1726
+ siteBaseUrl: settings.siteBaseUrl,
1727
+ secondaryDate: secondaryDateFormatted
1728
+ };
1729
+ }
1730
+ function buildAutoCampaignInput(rule, vars) {
1731
+ let rawCode = String(vars.code || "item").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "");
1732
+ while (rawCode.endsWith("-")) {
1733
+ rawCode = rawCode.slice(0, -1);
1734
+ }
1735
+ const safeCode = rawCode || "item";
1736
+ const uniqueSuffix = generateUniqueSlugSuffix();
1737
+ const bodyHtml = rule.bodyHtmlTemplate ? interpolateBroadcastTemplate(rule.bodyHtmlTemplate, vars, {
1738
+ escapeHtmlValues: true
1739
+ }) : `<p>${escapeHtml(interpolateBroadcastTemplate(rule.bodyTextTemplate, vars))}</p>`;
1740
+ const rawCtaUrl = rule.ctaUrlTemplate ? interpolateBroadcastTemplate(rule.ctaUrlTemplate, vars) : void 0;
1741
+ return {
1742
+ slug: `auto-${rule.slug}-${safeCode}-${uniqueSuffix}`,
1743
+ title: `[Auto] ${rule.name}: ${vars.title}`,
1744
+ topic: rule.topic,
1745
+ channels: rule.channels,
1746
+ priority: rule.priority,
1747
+ subject: interpolateBroadcastTemplate(rule.subjectTemplate, vars),
1748
+ bodyText: interpolateBroadcastTemplate(rule.bodyTextTemplate, vars),
1749
+ bodyHtml,
1750
+ ctaLabel: rule.ctaLabelTemplate ? interpolateBroadcastTemplate(rule.ctaLabelTemplate, vars) : void 0,
1751
+ ctaUrl: rawCtaUrl && isSafeHttpUrl(rawCtaUrl) ? rawCtaUrl : void 0
1752
+ };
1753
+ }
1754
+ function isSelfBroadcastEvent(eventName, payload) {
1755
+ const normalizedEvent = String(eventName || "").trim().toLowerCase();
1756
+ if (!normalizedEvent || normalizedEvent.startsWith("broadcasts.")) {
1757
+ return true;
1758
+ }
1759
+ const model = typeof payload?.model === "string" ? payload.model.trim().toLowerCase() : "";
1760
+ const uid = typeof payload?.uid === "string" ? payload.uid.trim().toLowerCase() : "";
1761
+ return model.startsWith("plugin::broadcasts.") || uid.startsWith("plugin::broadcasts.");
1762
+ }
1763
+ const createAutomationService = ({ strapi }) => ({
1764
+ getSettingsService() {
1765
+ return strapi.plugin(PLUGIN_ID).service("settings");
1766
+ },
1767
+ getCampaignService() {
1768
+ return strapi.plugin(PLUGIN_ID).service("campaignService");
1769
+ },
1770
+ async listRules() {
1771
+ const docs = await strapi.documents(AUTOMATION_RULE_UID).findMany({
1772
+ limit: 100
1773
+ });
1774
+ return docs;
1775
+ },
1776
+ async getRuleBySlug(slug) {
1777
+ const docs = await strapi.documents(AUTOMATION_RULE_UID).findMany({
1778
+ filters: { slug },
1779
+ limit: 1
1780
+ });
1781
+ return docs[0] || null;
1782
+ },
1783
+ async findRuleByDocIdOrSlug(documentIdOrSlug) {
1784
+ const byDoc = await strapi.documents(AUTOMATION_RULE_UID).findOne({
1785
+ documentId: documentIdOrSlug
1786
+ });
1787
+ return byDoc ?? await this.getRuleBySlug(documentIdOrSlug);
1788
+ },
1789
+ async createRule(input) {
1790
+ const rawChannels = input.channels === void 0 ? ["telegram"] : input.channels;
1791
+ const validation = validateAutomationRuleInput({
1792
+ ...input,
1793
+ channels: rawChannels
1794
+ });
1795
+ if (!validation.valid) {
1796
+ throw new Error(validation.errors.join("; "));
1797
+ }
1798
+ const channels = normalizeChannels(rawChannels, ["telegram"]);
1799
+ const candidate = {
1800
+ ...input
1801
+ };
1802
+ const created = await strapi.documents(AUTOMATION_RULE_UID).create({
1803
+ data: {
1804
+ ...buildRuleDocumentData(candidate, channels, true),
1805
+ triggerCount: 0
1806
+ }
1807
+ });
1808
+ if (candidate.eventName) {
1809
+ ensureEventHubListener(strapi, candidate.eventName);
1810
+ }
1811
+ return created;
1812
+ },
1813
+ async updateRule(documentIdOrSlug, input) {
1814
+ const existing = await this.findRuleByDocIdOrSlug(documentIdOrSlug);
1815
+ if (!existing?.documentId) {
1816
+ throw new Error(`Automation rule "${documentIdOrSlug}" not found`);
1817
+ }
1818
+ const rawChannels = input.channels === void 0 ? existing.channels : input.channels;
1819
+ const resolvedBodyHtmlTemplate = input.bodyHtmlTemplate !== void 0 ? input.bodyHtmlTemplate : input.bodyTextTemplate !== void 0 && (input.bodyTextTemplate !== existing.bodyTextTemplate || !existing.bodyHtmlTemplate) ? `<p>${escapeHtml(String(input.bodyTextTemplate))}</p>` : existing.bodyHtmlTemplate;
1820
+ const mergedForValidation = {
1821
+ ...existing,
1822
+ ...input,
1823
+ bodyHtmlTemplate: resolvedBodyHtmlTemplate,
1824
+ channels: rawChannels
1825
+ };
1826
+ const validation = validateAutomationRuleInput(mergedForValidation);
1827
+ if (!validation.valid) {
1828
+ throw new Error(validation.errors.join("; "));
1829
+ }
1830
+ const channels = normalizeChannels(rawChannels, ["telegram"]);
1831
+ const merged = {
1832
+ ...mergedForValidation,
1833
+ channels
1834
+ };
1835
+ const updated = await strapi.documents(AUTOMATION_RULE_UID).update({
1836
+ documentId: existing.documentId,
1837
+ data: buildRuleDocumentData(merged, channels, existing.active ?? true)
1838
+ });
1839
+ if (merged.eventName) {
1840
+ ensureEventHubListener(strapi, merged.eventName);
1841
+ }
1842
+ return updated || merged;
1843
+ },
1844
+ async seedStarterRules() {
1845
+ const settings = await this.getSettingsService().getSettings();
1846
+ const presets = settings.presetAutomationRules?.length ? settings.presetAutomationRules : STARTER_AUTOMATION_RULES;
1847
+ let seededCount = 0;
1848
+ for (const preset of presets) {
1849
+ if (!preset.slug || !preset.name || !preset.eventName) continue;
1850
+ const existing = await this.getRuleBySlug(preset.slug);
1851
+ if (existing) continue;
1852
+ await this.createRule(buildPresetRulePayload(preset));
1853
+ seededCount += 1;
1854
+ }
1855
+ return seededCount;
1856
+ },
1857
+ async executeSingleTriggerRule(rule, vars) {
1858
+ const campaignService = this.getCampaignService();
1859
+ const campaign2 = await campaignService.createCampaign(
1860
+ buildAutoCampaignInput(rule, vars)
1861
+ );
1862
+ const dispatched = await campaignService.dispatchCampaign(
1863
+ campaign2.documentId || campaign2.slug
1864
+ );
1865
+ if (rule.documentId) {
1866
+ await strapi.documents(AUTOMATION_RULE_UID).update({
1867
+ documentId: rule.documentId,
1868
+ data: {
1869
+ lastTriggeredAt: (/* @__PURE__ */ new Date()).toISOString(),
1870
+ triggerCount: (rule.triggerCount || 0) + 1
1871
+ }
1872
+ });
1873
+ }
1874
+ return {
1875
+ ruleSlug: rule.slug,
1876
+ campaignSlug: dispatched.campaign.slug,
1877
+ results: dispatched.results
1878
+ };
1879
+ },
1880
+ async handleEventTrigger(eventName, payload = {}) {
1881
+ if (isSelfBroadcastEvent(eventName, payload)) {
1882
+ return [];
1883
+ }
1884
+ const settings = await this.getSettingsService().getSettings();
1885
+ if (!settings.enableEventHubAutomations) return [];
1886
+ const allRules = await this.listRules();
1887
+ const matchingRules = allRules.filter(
1888
+ (rule) => doesRuleMatchTrigger(rule, eventName, payload)
1889
+ );
1890
+ if (matchingRules.length === 0) return [];
1891
+ const secondaryCal = settings.secondaryCalendars?.[0] || "islamic-umalqura";
1892
+ const secondaryDate = formatSecondaryDate(
1893
+ (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
1894
+ secondaryCal,
1895
+ settings.defaultLocale || "es"
1896
+ );
1897
+ const vars = extractTriggerVariables(
1898
+ eventName,
1899
+ payload,
1900
+ settings,
1901
+ secondaryDate.formatted
1902
+ );
1903
+ const outcomes = [];
1904
+ for (const rule of matchingRules) {
1905
+ outcomes.push(await this.executeSingleTriggerRule(rule, vars));
1906
+ }
1907
+ return outcomes;
1908
+ }
1909
+ });
1910
+ function buildCampaignDocumentData(candidate, channels, resolvedStatus = "draft") {
1911
+ const safeBodyText = String(candidate.bodyText || "");
1912
+ return {
1913
+ title: candidate.title,
1914
+ slug: candidate.slug,
1915
+ topic: candidate.topic || "announcements",
1916
+ channels,
1917
+ priority: candidate.priority || "normal",
1918
+ status: resolvedStatus,
1919
+ scheduledAt: candidate.scheduledAt,
1920
+ subject: candidate.subject,
1921
+ bodyText: safeBodyText,
1922
+ bodyHtml: candidate.bodyHtml || `<p>${escapeHtml(safeBodyText)}</p>`,
1923
+ ctaLabel: candidate.ctaLabel,
1924
+ ctaUrl: candidate.ctaUrl,
1925
+ mediaUrl: candidate.mediaUrl,
1926
+ emailTemplateRef: candidate.emailTemplateRef,
1927
+ whatsappTemplateName: candidate.whatsappTemplateName,
1928
+ whatsappGroupIds: Array.isArray(candidate.whatsappGroupIds) ? candidate.whatsappGroupIds : [],
1929
+ telegramChatId: candidate.telegramChatId,
1930
+ telegramThreadId: candidate.telegramThreadId,
1931
+ translations: candidate.translations || {}
1932
+ };
1933
+ }
1934
+ function buildPresetCampaignPayload(preset) {
1935
+ return {
1936
+ slug: preset.slug,
1937
+ title: preset.title,
1938
+ topic: preset.topic || "announcements",
1939
+ channels: preset.channels || ["email", "telegram"],
1940
+ priority: preset.priority || "normal",
1941
+ subject: preset.subject || preset.title,
1942
+ bodyText: preset.bodyText || preset.title,
1943
+ bodyHtml: preset.bodyHtml,
1944
+ ctaLabel: preset.ctaLabel,
1945
+ ctaUrl: preset.ctaUrl,
1946
+ emailTemplateRef: preset.emailTemplateRef,
1947
+ translations: preset.translations || {}
1948
+ };
1949
+ }
1950
+ function mapRawSubscriber(sub, defaultLocale) {
1951
+ return {
1952
+ email: typeof sub.email === "string" ? sub.email : void 0,
1953
+ phone: typeof sub.phone === "string" ? sub.phone : void 0,
1954
+ fullName: typeof sub.fullName === "string" ? sub.fullName : void 0,
1955
+ locale: typeof sub.locale === "string" && sub.locale ? sub.locale : defaultLocale,
1956
+ topics: Array.isArray(sub.topics) ? sub.topics.map((t) => String(t)) : ["announcements"],
1957
+ unsubscribeToken: typeof sub.unsubscribeToken === "string" ? sub.unsubscribeToken : void 0
1958
+ };
1959
+ }
1960
+ async function dispatchTelegramChannel(transport, campaign2, settings, secondaryDateLabel, isTestDispatch, testTelegramChatId, stats, results) {
1961
+ if (isTestDispatch && !testTelegramChatId) {
1962
+ return;
1963
+ }
1964
+ const channelLocale = settings.defaultLocale || "es";
1965
+ const localized = resolveLocalizedCampaignCopy(
1966
+ campaign2,
1967
+ channelLocale,
1968
+ settings.defaultLocale
1969
+ );
1970
+ const vars = {
1971
+ recipientName: "Comunidad",
1972
+ organizationName: settings.organizationName,
1973
+ secondaryDate: secondaryDateLabel,
1974
+ siteBaseUrl: settings.siteBaseUrl
1975
+ };
1976
+ const tgTargets = isTestDispatch ? parseTargetList(testTelegramChatId) : resolveTelegramTargets(campaign2, settings);
1977
+ const threadId = campaign2.telegramThreadId ?? settings.telegramTopicThreadMap?.[campaign2.topic];
1978
+ const htmlBody = localized.bodyHtml ? interpolateBroadcastTemplate(localized.bodyHtml, vars, {
1979
+ escapeHtmlValues: true
1980
+ }) : escapeHtml(interpolateBroadcastTemplate(localized.bodyText, vars));
1981
+ const interpolatedCtaUrl = localized.ctaUrl ? interpolateBroadcastTemplate(localized.ctaUrl, vars) : void 0;
1982
+ for (const chatId of tgTargets) {
1983
+ const tgRes = await transport.sendTelegramBroadcast({
1984
+ campaignSlug: campaign2.slug,
1985
+ chatId,
1986
+ threadId,
1987
+ locale: channelLocale,
1988
+ subject: interpolateBroadcastTemplate(localized.subject, vars),
1989
+ htmlBody,
1990
+ ctaLabel: localized.ctaLabel ? interpolateBroadcastTemplate(localized.ctaLabel, vars) : void 0,
1991
+ ctaUrl: interpolatedCtaUrl && isSafeHttpUrl(interpolatedCtaUrl) ? interpolatedCtaUrl : void 0,
1992
+ secondaryDateLabel,
1993
+ isTestDispatch
1994
+ });
1995
+ results.push(tgRes);
1996
+ recordResultInStats(stats, tgRes);
1997
+ }
1998
+ }
1999
+ function buildEmailSendPayloadForRecipient(campaign2, recipient, settings, secondaryDateLabel, isTestDispatch) {
2000
+ const locale = recipient.locale || settings.defaultLocale || "es";
2001
+ const localized = resolveLocalizedCampaignCopy(
2002
+ campaign2,
2003
+ locale,
2004
+ settings.defaultLocale
2005
+ );
2006
+ let baseSite = settings.siteBaseUrl;
2007
+ while (baseSite.endsWith("/")) {
2008
+ baseSite = baseSite.slice(0, -1);
2009
+ }
2010
+ const rawUnsubscribeUrl = recipient.unsubscribeToken ? `${baseSite}/api/inbox/public/unsubscribe?token=${encodeURIComponent(
2011
+ recipient.unsubscribeToken
2012
+ )}` : `${baseSite}/${encodeURIComponent(locale)}/unsubscribe`;
2013
+ const unsubscribeUrl = stripHeaderControlChars(rawUnsubscribeUrl);
2014
+ const recipientName = recipient.fullName || recipient.email;
2015
+ const vars = {
2016
+ recipientName,
2017
+ organizationName: settings.organizationName,
2018
+ secondaryDate: secondaryDateLabel,
2019
+ siteBaseUrl: settings.siteBaseUrl,
2020
+ unsubscribeUrl
2021
+ };
2022
+ const html = localized.bodyHtml ? interpolateBroadcastTemplate(localized.bodyHtml, vars, {
2023
+ escapeHtmlValues: true
2024
+ }) : `<p>${escapeHtml(interpolateBroadcastTemplate(localized.bodyText, vars))}</p>`;
2025
+ const resolvedCtaUrl = localized.ctaUrl ? interpolateBroadcastTemplate(localized.ctaUrl, vars) : void 0;
2026
+ return {
2027
+ campaignSlug: campaign2.slug,
2028
+ recipientEmail: recipient.email,
2029
+ locale,
2030
+ subject: stripHeaderControlChars(
2031
+ interpolateBroadcastTemplate(localized.subject, vars)
2032
+ ),
2033
+ text: interpolateBroadcastTemplate(localized.bodyText, vars),
2034
+ html,
2035
+ ctaLabel: localized.ctaLabel ? interpolateBroadcastTemplate(localized.ctaLabel, vars) : void 0,
2036
+ ctaUrl: resolvedCtaUrl && isSafeHttpUrl(resolvedCtaUrl) ? resolvedCtaUrl : void 0,
2037
+ unsubscribeUrl,
2038
+ emailTemplateRef: campaign2.emailTemplateRef,
2039
+ secondaryDateLabel,
2040
+ templateData: {
2041
+ recipientName,
2042
+ title: campaign2.title,
2043
+ topic: campaign2.topic
2044
+ },
2045
+ isTestDispatch
2046
+ };
2047
+ }
2048
+ async function dispatchEmailChannel(transport, campaign2, settings, recipients, secondaryDateLabel, isTestDispatch, stats, results) {
2049
+ for (const recipient of recipients) {
2050
+ if (!recipient.email) continue;
2051
+ const payload = buildEmailSendPayloadForRecipient(
2052
+ campaign2,
2053
+ recipient,
2054
+ settings,
2055
+ secondaryDateLabel,
2056
+ isTestDispatch
2057
+ );
2058
+ const emailRes = await transport.sendEmailBroadcast(payload);
2059
+ results.push(emailRes);
2060
+ recordResultInStats(stats, emailRes);
2061
+ }
2062
+ }
2063
+ async function dispatchWhatsAppChannel(transport, campaign2, settings, recipients, isTestDispatch, secondaryDateLabel, stats, results) {
2064
+ const waRecipients = resolveWhatsAppRecipients(
2065
+ campaign2,
2066
+ settings,
2067
+ recipients,
2068
+ isTestDispatch
2069
+ );
2070
+ for (const recipient of waRecipients) {
2071
+ if (!recipient.phone) continue;
2072
+ const locale = recipient.locale || settings.defaultLocale || "es";
2073
+ const localized = resolveLocalizedCampaignCopy(
2074
+ campaign2,
2075
+ locale,
2076
+ settings.defaultLocale
2077
+ );
2078
+ const vars = {
2079
+ recipientName: recipient.fullName || "Comunidad",
2080
+ organizationName: settings.organizationName,
2081
+ secondaryDate: secondaryDateLabel,
2082
+ siteBaseUrl: settings.siteBaseUrl
2083
+ };
2084
+ const resolvedCtaUrl = localized.ctaUrl ? interpolateBroadcastTemplate(localized.ctaUrl, vars) : void 0;
2085
+ const waRes = await transport.sendWhatsAppBroadcast({
2086
+ campaignSlug: campaign2.slug,
2087
+ recipientPhone: recipient.phone,
2088
+ locale,
2089
+ textBody: interpolateBroadcastTemplate(localized.bodyText, vars),
2090
+ templateName: campaign2.whatsappTemplateName,
2091
+ ctaUrl: resolvedCtaUrl && isSafeHttpUrl(resolvedCtaUrl) ? resolvedCtaUrl : void 0,
2092
+ secondaryDateLabel,
2093
+ isTestDispatch
2094
+ });
2095
+ results.push(waRes);
2096
+ recordResultInStats(stats, waRes);
2097
+ }
2098
+ }
2099
+ async function dispatchAllActiveChannels(params) {
2100
+ const {
2101
+ transport,
2102
+ campaign: campaign2,
2103
+ settings,
2104
+ recipients,
2105
+ isTestDispatch,
2106
+ testTelegramChatId,
2107
+ secondaryDateLabel,
2108
+ stats
2109
+ } = params;
2110
+ const results = [];
2111
+ const activeChannels = normalizeChannels(campaign2.channels);
2112
+ if (activeChannels.includes("telegram") && settings.telegramEnabled) {
2113
+ await dispatchTelegramChannel(
2114
+ transport,
2115
+ campaign2,
2116
+ settings,
2117
+ secondaryDateLabel,
2118
+ isTestDispatch,
2119
+ testTelegramChatId,
2120
+ stats,
2121
+ results
2122
+ );
2123
+ }
2124
+ if (activeChannels.includes("email")) {
2125
+ await dispatchEmailChannel(
2126
+ transport,
2127
+ campaign2,
2128
+ settings,
2129
+ recipients,
2130
+ secondaryDateLabel,
2131
+ isTestDispatch,
2132
+ stats,
2133
+ results
2134
+ );
2135
+ }
2136
+ if (activeChannels.includes("whatsapp") && settings.whatsappEnabled) {
2137
+ await dispatchWhatsAppChannel(
2138
+ transport,
2139
+ campaign2,
2140
+ settings,
2141
+ recipients,
2142
+ isTestDispatch,
2143
+ secondaryDateLabel,
2144
+ stats,
2145
+ results
2146
+ );
2147
+ }
2148
+ return results;
2149
+ }
2150
+ const ALLOWED_INITIAL_STATUSES = /* @__PURE__ */ new Set([
2151
+ "draft",
2152
+ "scheduled"
2153
+ ]);
2154
+ const ALLOWED_USER_STATUS_TRANSITIONS = {
2155
+ draft: ["draft", "scheduled", "cancelled"],
2156
+ scheduled: ["draft", "scheduled", "cancelled"],
2157
+ sending: [],
2158
+ sent: ["sent"],
2159
+ failed: ["draft", "scheduled", "cancelled"],
2160
+ cancelled: ["cancelled", "draft"]
2161
+ };
2162
+ function resolveInitialStatus(rawStatus) {
2163
+ if (rawStatus === void 0) return "draft";
2164
+ if (!ALLOWED_INITIAL_STATUSES.has(rawStatus)) {
2165
+ throw new Error('Initial campaign status must be "draft" or "scheduled"');
2166
+ }
2167
+ return rawStatus;
2168
+ }
2169
+ function resolveUpdatedStatus(currentStatus, requestedStatus) {
2170
+ if (currentStatus === "sending") {
2171
+ throw new Error("Cannot modify a campaign while it is sending");
2172
+ }
2173
+ if (requestedStatus === void 0 || requestedStatus === currentStatus) {
2174
+ return currentStatus;
2175
+ }
2176
+ const allowed = ALLOWED_USER_STATUS_TRANSITIONS[currentStatus] || [];
2177
+ if (!allowed.includes(requestedStatus)) {
2178
+ throw new Error(
2179
+ `Invalid campaign status transition from "${currentStatus}" to "${requestedStatus}"`
2180
+ );
2181
+ }
2182
+ return requestedStatus;
2183
+ }
2184
+ function computeCampaignSecondaryDate(campaign2, settings) {
2185
+ if (campaign2.secondaryDate?.formatted) {
2186
+ return campaign2.secondaryDate;
2187
+ }
2188
+ const secondaryCal = settings.secondaryCalendars?.[0] || "islamic-umalqura";
2189
+ return formatSecondaryDate(
2190
+ (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
2191
+ secondaryCal,
2192
+ settings.defaultLocale || "es"
2193
+ );
2194
+ }
2195
+ function toOptionalPlainString(val) {
2196
+ if (typeof val !== "string") return void 0;
2197
+ const trimmed = val.trim();
2198
+ return trimmed.length > 0 ? trimmed : void 0;
2199
+ }
2200
+ function sanitizeDispatchOptions(rawOptions, defaultLocale) {
2201
+ const safeObj = rawOptions && typeof rawOptions === "object" && !Array.isArray(rawOptions) ? rawOptions : {};
2202
+ const testRecipientEmail = toOptionalPlainString(safeObj.testRecipientEmail);
2203
+ const testRecipientPhone = toOptionalPlainString(safeObj.testRecipientPhone);
2204
+ const testTelegramChatId = toOptionalPlainString(safeObj.testTelegramChatId);
2205
+ const hasCustomRecipientsList = Array.isArray(safeObj.customRecipients) && safeObj.customRecipients.length > 0;
2206
+ const customRecipients = hasCustomRecipientsList ? safeObj.customRecipients.filter(
2207
+ (item) => Boolean(item) && typeof item === "object" && !Array.isArray(item)
2208
+ ).map((item) => mapRawSubscriber(item, defaultLocale)) : [];
2209
+ return {
2210
+ hasCustomRecipientsList,
2211
+ customRecipients,
2212
+ testRecipientEmail,
2213
+ testRecipientPhone,
2214
+ testTelegramChatId
2215
+ };
2216
+ }
2217
+ async function buildSelectedRecipients(campaign2, settings, options2, resolveTopicRecipients) {
2218
+ const clean = sanitizeDispatchOptions(
2219
+ options2,
2220
+ settings.defaultLocale || "es"
2221
+ );
2222
+ const isTestDispatch = Boolean(
2223
+ clean.testRecipientEmail || clean.testRecipientPhone || clean.testTelegramChatId
2224
+ );
2225
+ if (isTestDispatch) {
2226
+ return {
2227
+ isTestDispatch: true,
2228
+ testTelegramChatId: clean.testTelegramChatId,
2229
+ recipients: [
2230
+ {
2231
+ email: clean.testRecipientEmail,
2232
+ phone: clean.testRecipientPhone,
2233
+ telegramChatId: clean.testTelegramChatId,
2234
+ fullName: "Test Recipient",
2235
+ locale: settings.defaultLocale,
2236
+ topics: [campaign2.topic]
2237
+ }
2238
+ ]
2239
+ };
2240
+ }
2241
+ if (clean.hasCustomRecipientsList) {
2242
+ return { isTestDispatch: false, recipients: clean.customRecipients };
2243
+ }
2244
+ return {
2245
+ isTestDispatch: false,
2246
+ recipients: await resolveTopicRecipients(campaign2.topic)
2247
+ };
2248
+ }
2249
+ const createCampaignService = ({ strapi }) => {
2250
+ const activeDispatches = /* @__PURE__ */ new Set();
2251
+ return {
2252
+ getSettingsService() {
2253
+ return strapi.plugin(PLUGIN_ID).service("settings");
2254
+ },
2255
+ getTransportService() {
2256
+ return strapi.plugin(PLUGIN_ID).service("transportService");
2257
+ },
2258
+ async listCampaigns(params) {
2259
+ const filters = {};
2260
+ if (typeof params?.status === "string" && params.status.trim()) {
2261
+ filters.status = params.status.trim();
2262
+ }
2263
+ if (typeof params?.topic === "string" && params.topic.trim()) {
2264
+ filters.topic = params.topic.trim();
2265
+ }
2266
+ const docs = await strapi.documents(CAMPAIGN_UID).findMany({ filters, limit: 100 });
2267
+ return docs;
2268
+ },
2269
+ async listPublicArchive(params) {
2270
+ const filters = { status: "sent" };
2271
+ if (typeof params?.topic === "string" && params.topic.trim()) {
2272
+ filters.topic = params.topic.trim();
2273
+ }
2274
+ const docs = await strapi.documents(CAMPAIGN_UID).findMany({
2275
+ filters,
2276
+ limit: clampLimit(params?.limit, 20)
2277
+ });
2278
+ return docs.map((c) => ({
2279
+ slug: c.slug,
2280
+ title: c.title,
2281
+ topic: c.topic,
2282
+ subject: c.subject,
2283
+ bodyText: c.bodyText,
2284
+ ctaLabel: c.ctaLabel,
2285
+ ctaUrl: c.ctaUrl,
2286
+ sentAt: c.sentAt,
2287
+ secondaryDateLabel: c.secondaryDateLabel,
2288
+ translations: c.translations
2289
+ }));
2290
+ },
2291
+ async getCampaignBySlug(slug) {
2292
+ const docs = await strapi.documents(CAMPAIGN_UID).findMany({
2293
+ filters: { slug },
2294
+ limit: 1
2295
+ });
2296
+ return docs[0] || null;
2297
+ },
2298
+ async findCampaignByDocIdOrSlug(documentIdOrSlug) {
2299
+ const byDoc = await strapi.documents(CAMPAIGN_UID).findOne({
2300
+ documentId: documentIdOrSlug
2301
+ });
2302
+ return byDoc ?? await this.getCampaignBySlug(documentIdOrSlug);
2303
+ },
2304
+ async createCampaign(input) {
2305
+ const initialStatus = resolveInitialStatus(input.status);
2306
+ const rawChannels = input.channels === void 0 ? ["email"] : input.channels;
2307
+ const validation = validateCampaignInput({
2308
+ ...input,
2309
+ channels: rawChannels
2310
+ });
2311
+ if (!validation.valid) {
2312
+ throw new Error(validation.errors.join("; "));
2313
+ }
2314
+ const channels = normalizeChannels(rawChannels);
2315
+ const candidate = {
2316
+ ...input,
2317
+ emailTemplateRef: input.emailTemplateRef !== void 0 ? String(input.emailTemplateRef).trim() : void 0
2318
+ };
2319
+ const settings = await this.getSettingsService().getSettings();
2320
+ const secondaryCal = settings.secondaryCalendars?.[0] || "islamic-umalqura";
2321
+ const baseIso = input.scheduledAt || (/* @__PURE__ */ new Date()).toISOString();
2322
+ const referenceIso = baseIso.slice(0, 10);
2323
+ const secondaryDate = formatSecondaryDate(
2324
+ referenceIso,
2325
+ secondaryCal,
2326
+ settings.defaultLocale || "es"
2327
+ );
2328
+ const created = await strapi.documents(CAMPAIGN_UID).create({
2329
+ data: {
2330
+ ...buildCampaignDocumentData(candidate, channels, initialStatus),
2331
+ secondaryDate,
2332
+ secondaryDateLabel: secondaryDate.formatted,
2333
+ stats: createEmptyStats(),
2334
+ metadata: candidate.metadata || {}
2335
+ }
2336
+ });
2337
+ return created;
2338
+ },
2339
+ async updateCampaign(documentIdOrSlug, input) {
2340
+ const existing = await this.findCampaignByDocIdOrSlug(documentIdOrSlug);
2341
+ if (!existing?.documentId) {
2342
+ throw new Error(`Campaign "${documentIdOrSlug}" not found`);
2343
+ }
2344
+ const nextStatus = resolveUpdatedStatus(
2345
+ existing.status || "draft",
2346
+ input.status
2347
+ );
2348
+ const rawChannels = input.channels === void 0 ? existing.channels : input.channels;
2349
+ const resolvedBodyHtml = input.bodyHtml !== void 0 ? input.bodyHtml : input.bodyText !== void 0 && (input.bodyText !== existing.bodyText || !existing.bodyHtml) ? `<p>${escapeHtml(String(input.bodyText))}</p>` : existing.bodyHtml;
2350
+ const resolvedEmailTemplateRef = input.emailTemplateRef !== void 0 ? String(input.emailTemplateRef).trim() : existing.emailTemplateRef;
2351
+ const mergedForValidation = {
2352
+ ...existing,
2353
+ ...input,
2354
+ bodyHtml: resolvedBodyHtml,
2355
+ emailTemplateRef: resolvedEmailTemplateRef,
2356
+ channels: rawChannels,
2357
+ status: nextStatus
2358
+ };
2359
+ const validation = validateCampaignInput(mergedForValidation);
2360
+ if (!validation.valid) {
2361
+ throw new Error(validation.errors.join("; "));
2362
+ }
2363
+ const channels = normalizeChannels(rawChannels);
2364
+ const merged = {
2365
+ ...mergedForValidation,
2366
+ channels
2367
+ };
2368
+ const updated = await strapi.documents(CAMPAIGN_UID).update({
2369
+ documentId: existing.documentId,
2370
+ data: buildCampaignDocumentData(merged, channels, nextStatus)
2371
+ });
2372
+ return updated || merged;
2373
+ },
2374
+ async createFromStarterTemplate(templateId) {
2375
+ const tpl = STARTER_CAMPAIGN_TEMPLATES.find(
2376
+ (item) => item.id === templateId || item.slug === templateId
2377
+ );
2378
+ if (!tpl) {
2379
+ throw new Error(`Starter campaign template "${templateId}" not found`);
2380
+ }
2381
+ const existing = await this.getCampaignBySlug(tpl.slug);
2382
+ if (existing) return existing;
2383
+ return this.createCampaign(buildPresetCampaignPayload(tpl));
2384
+ },
2385
+ async seedStarterCampaigns() {
2386
+ const settings = await this.getSettingsService().getSettings();
2387
+ const presets = settings.presetCampaigns?.length ? settings.presetCampaigns : STARTER_CAMPAIGN_TEMPLATES;
2388
+ let seededCount = 0;
2389
+ for (const preset of presets) {
2390
+ if (!preset.slug || !preset.title) continue;
2391
+ const existing = await this.getCampaignBySlug(preset.slug);
2392
+ if (existing) continue;
2393
+ await this.createCampaign(buildPresetCampaignPayload(preset));
2394
+ seededCount += 1;
2395
+ }
2396
+ return seededCount;
2397
+ },
2398
+ async resolveRecipientsForTopic(topic) {
2399
+ const settings = await this.getSettingsService().getSettings();
2400
+ if (!settings.enableInboxSubscriberBridge) return [];
2401
+ try {
2402
+ const inboxPlugin = strapi.plugin("inbox");
2403
+ const subscriberService = inboxPlugin?.service("subscriberService");
2404
+ if (subscriberService?.listSubscribers) {
2405
+ const list = await subscriberService.listSubscribers({
2406
+ status: "confirmed",
2407
+ topic
2408
+ });
2409
+ return list.map(
2410
+ (sub) => mapRawSubscriber(sub, settings.defaultLocale || "es")
2411
+ );
2412
+ }
2413
+ } catch {
2414
+ }
2415
+ return [];
2416
+ },
2417
+ async selectCampaignRecipients(campaign2, settings, options2) {
2418
+ return buildSelectedRecipients(
2419
+ campaign2,
2420
+ settings,
2421
+ options2,
2422
+ (topic) => this.resolveRecipientsForTopic(topic)
2423
+ );
2424
+ },
2425
+ async finalizeDispatchedCampaign(campaign2, secondaryDate, secondaryDateLabel, stats) {
2426
+ const finalStatus = stats.totalRecipients > 0 && stats.failedCount === stats.totalRecipients ? "failed" : "sent";
2427
+ const sentAt = (/* @__PURE__ */ new Date()).toISOString();
2428
+ let updatedCampaign = {
2429
+ ...campaign2,
2430
+ status: finalStatus,
2431
+ sentAt,
2432
+ secondaryDateLabel,
2433
+ stats
2434
+ };
2435
+ if (campaign2.documentId) {
2436
+ const updated = await strapi.documents(CAMPAIGN_UID).update({
2437
+ documentId: campaign2.documentId,
2438
+ data: {
2439
+ status: finalStatus,
2440
+ sentAt,
2441
+ secondaryDate,
2442
+ secondaryDateLabel,
2443
+ stats
2444
+ }
2445
+ });
2446
+ if (updated) updatedCampaign = updated;
2447
+ }
2448
+ strapi.eventHub?.emit("broadcasts.campaign.sent", {
2449
+ slug: updatedCampaign.slug,
2450
+ topic: updatedCampaign.topic,
2451
+ stats
2452
+ });
2453
+ return updatedCampaign;
2454
+ },
2455
+ async dispatchCampaign(documentIdOrSlug, options2 = {}) {
2456
+ const acquiredLocks = [];
2457
+ const tryAcquireBatch = (keys, label) => {
2458
+ const toAcquire = Array.from(
2459
+ new Set(keys.filter((k) => Boolean(k) && !acquiredLocks.includes(k)))
2460
+ );
2461
+ for (const key of toAcquire) {
2462
+ if (activeDispatches.has(key)) {
2463
+ throw new Error(`Campaign "${label}" is already being dispatched`);
2464
+ }
2465
+ }
2466
+ for (const key of toAcquire) {
2467
+ activeDispatches.add(key);
2468
+ acquiredLocks.push(key);
2469
+ }
2470
+ };
2471
+ const rawRef = String(documentIdOrSlug || "").trim();
2472
+ tryAcquireBatch([`req:${rawRef}`], rawRef || documentIdOrSlug);
2473
+ let campaign2 = null;
2474
+ let transitionedToSending = false;
2475
+ try {
2476
+ campaign2 = await this.findCampaignByDocIdOrSlug(rawRef);
2477
+ if (!campaign2) {
2478
+ throw new Error(`Campaign "${documentIdOrSlug}" not found`);
2479
+ }
2480
+ const canonicalKeys = [`slug:${campaign2.slug}`, `req:${campaign2.slug}`];
2481
+ if (campaign2.documentId) {
2482
+ canonicalKeys.push(
2483
+ `doc:${campaign2.documentId}`,
2484
+ `req:${campaign2.documentId}`
2485
+ );
2486
+ }
2487
+ tryAcquireBatch(canonicalKeys, campaign2.slug);
2488
+ if (campaign2.status === "cancelled") {
2489
+ throw new Error(
2490
+ `Cannot dispatch cancelled campaign "${campaign2.slug}"`
2491
+ );
2492
+ }
2493
+ if (campaign2.status === "sending") {
2494
+ throw new Error(`Campaign "${campaign2.slug}" is already sending`);
2495
+ }
2496
+ const settings = await this.getSettingsService().getSettings();
2497
+ const transport = this.getTransportService();
2498
+ const secondaryDate = computeCampaignSecondaryDate(campaign2, settings);
2499
+ const secondaryDateLabel = campaign2.secondaryDateLabel || secondaryDate.formatted;
2500
+ const { isTestDispatch, testTelegramChatId, recipients } = await this.selectCampaignRecipients(
2501
+ campaign2,
2502
+ settings,
2503
+ options2
2504
+ );
2505
+ if (!isTestDispatch && campaign2.documentId) {
2506
+ await strapi.documents(CAMPAIGN_UID).update({
2507
+ documentId: campaign2.documentId,
2508
+ data: { status: "sending" }
2509
+ });
2510
+ transitionedToSending = true;
2511
+ }
2512
+ const stats = createEmptyStats();
2513
+ const results = await dispatchAllActiveChannels({
2514
+ transport,
2515
+ campaign: campaign2,
2516
+ settings,
2517
+ recipients,
2518
+ isTestDispatch,
2519
+ testTelegramChatId,
2520
+ secondaryDateLabel,
2521
+ stats
2522
+ });
2523
+ if (isTestDispatch) {
2524
+ return { campaign: campaign2, results };
2525
+ }
2526
+ const updatedCampaign = await this.finalizeDispatchedCampaign(
2527
+ campaign2,
2528
+ secondaryDate,
2529
+ secondaryDateLabel,
2530
+ stats
2531
+ );
2532
+ return { campaign: updatedCampaign, results };
2533
+ } catch (err) {
2534
+ if (transitionedToSending && campaign2?.documentId) {
2535
+ try {
2536
+ await strapi.documents(CAMPAIGN_UID).update({
2537
+ documentId: campaign2.documentId,
2538
+ data: { status: "failed" }
2539
+ });
2540
+ } catch {
2541
+ }
2542
+ }
2543
+ throw err;
2544
+ } finally {
2545
+ for (const key of acquiredLocks) {
2546
+ activeDispatches.delete(key);
2547
+ }
2548
+ }
2549
+ }
2550
+ };
2551
+ };
2552
+ const SETTINGS_KEY = "settings";
2553
+ const MASKED_SECRET = "••••••••";
2554
+ const SECRET_MASK_RE = /^[•*]+$/;
2555
+ const DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
2556
+ const ALLOWED_SETTINGS_KEYS = /* @__PURE__ */ new Set([
2557
+ "primaryCalendar",
2558
+ "secondaryCalendars",
2559
+ "defaultLocale",
2560
+ "supportedLocales",
2561
+ "availableTopics",
2562
+ "dryRun",
2563
+ "enableInboxSubscriberBridge",
2564
+ "enableEventHubAutomations",
2565
+ "enableEmailDesignerBridge",
2566
+ "defaultEmailTemplateRef",
2567
+ "siteBaseUrl",
2568
+ "organizationName",
2569
+ "emailFromAddress",
2570
+ "emailReplyToAddress",
2571
+ "telegramEnabled",
2572
+ "telegramBotToken",
2573
+ "telegramDefaultChatId",
2574
+ "telegramTopicChatMap",
2575
+ "telegramTopicThreadMap",
2576
+ "whatsappEnabled",
2577
+ "whatsappDriver",
2578
+ "whatsappPhoneNumberId",
2579
+ "whatsappAccessToken",
2580
+ "whatsappGatewayUrl",
2581
+ "whatsappGatewaySession",
2582
+ "whatsappGatewayApiKey",
2583
+ "whatsappDefaultRecipients",
2584
+ "whatsappTopicGroupMap",
2585
+ "allowLocalhostGateways",
2586
+ "publicChannelLinks",
2587
+ "presetCampaigns",
2588
+ "presetAutomationRules"
2589
+ ]);
2590
+ const NESTED_MAP_KEYS = /* @__PURE__ */ new Set([
2591
+ "telegramTopicChatMap",
2592
+ "telegramTopicThreadMap",
2593
+ "whatsappTopicGroupMap"
2594
+ ]);
2595
+ function sanitizePlainMap(val) {
2596
+ if (!val || typeof val !== "object" || Array.isArray(val)) return {};
2597
+ const clean = {};
2598
+ for (const [k, v] of Object.entries(val)) {
2599
+ if (DANGEROUS_KEYS.has(k) || !Object.hasOwn(val, k)) continue;
2600
+ clean[k] = v;
2601
+ }
2602
+ return clean;
2603
+ }
2604
+ function pickAllowedSettings(raw) {
2605
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2606
+ return {};
2607
+ }
2608
+ const clean = {};
2609
+ for (const [key, value] of Object.entries(raw)) {
2610
+ if (DANGEROUS_KEYS.has(key) || !Object.hasOwn(raw, key) || !ALLOWED_SETTINGS_KEYS.has(key)) {
2611
+ continue;
2612
+ }
2613
+ clean[key] = NESTED_MAP_KEYS.has(key) ? sanitizePlainMap(value) : value;
2614
+ }
2615
+ return clean;
2616
+ }
2617
+ function maskSecret(val) {
2618
+ if (!val || SECRET_MASK_RE.test(val.trim())) return "";
2619
+ return MASKED_SECRET;
2620
+ }
2621
+ function resolveSecretValue(incoming, current) {
2622
+ if (incoming === void 0 || incoming === null) {
2623
+ return current ?? "";
2624
+ }
2625
+ const str = String(incoming);
2626
+ const trimmed = str.trim();
2627
+ if (trimmed.length > 0 && SECRET_MASK_RE.test(trimmed)) {
2628
+ return current ?? "";
2629
+ }
2630
+ return str;
2631
+ }
2632
+ function resolveSecondaryCalendars(incoming, fallback) {
2633
+ if (!Array.isArray(incoming)) return fallback;
2634
+ return incoming.filter(
2635
+ (cal) => isSupportedCalendar(cal)
2636
+ );
2637
+ }
2638
+ const createSettingsService = ({ strapi }) => ({
2639
+ getPluginStore() {
2640
+ return strapi.store({
2641
+ type: "plugin",
2642
+ name: PLUGIN_ID
2643
+ });
2644
+ },
2645
+ getConfigDefaults() {
2646
+ const rawConfig = pickAllowedSettings(
2647
+ strapi.config.get(`plugin::${PLUGIN_ID}`, {})
2648
+ );
2649
+ const primaryCalendar = isSupportedCalendar(
2650
+ rawConfig.primaryCalendar
2651
+ ) ? rawConfig.primaryCalendar : DEFAULT_BROADCAST_SETTINGS.primaryCalendar;
2652
+ const secondaryCalendars = resolveSecondaryCalendars(
2653
+ rawConfig.secondaryCalendars,
2654
+ DEFAULT_BROADCAST_SETTINGS.secondaryCalendars
2655
+ );
2656
+ return {
2657
+ ...DEFAULT_BROADCAST_SETTINGS,
2658
+ ...rawConfig,
2659
+ primaryCalendar,
2660
+ secondaryCalendars,
2661
+ whatsappDriver: isSupportedWhatsAppDriver(rawConfig.whatsappDriver) ? rawConfig.whatsappDriver : DEFAULT_BROADCAST_SETTINGS.whatsappDriver
2662
+ };
2663
+ },
2664
+ async getSettings() {
2665
+ const defaults = this.getConfigDefaults();
2666
+ const store = this.getPluginStore();
2667
+ const rawStored = await store.get({ key: SETTINGS_KEY });
2668
+ const stored = pickAllowedSettings(rawStored);
2669
+ if (!rawStored) {
2670
+ return defaults;
2671
+ }
2672
+ return {
2673
+ ...defaults,
2674
+ ...stored
2675
+ };
2676
+ },
2677
+ async getPublicSafeSettings() {
2678
+ const full = await this.getSettings();
2679
+ return {
2680
+ ...full,
2681
+ telegramBotToken: maskSecret(full.telegramBotToken),
2682
+ whatsappAccessToken: maskSecret(full.whatsappAccessToken),
2683
+ whatsappGatewayApiKey: maskSecret(full.whatsappGatewayApiKey)
2684
+ };
2685
+ },
2686
+ async updateSettings(rawPartial) {
2687
+ const partial = pickAllowedSettings(rawPartial);
2688
+ const current = await this.getSettings();
2689
+ const effectiveAllowLocalhost = Boolean(
2690
+ partial.allowLocalhostGateways ?? current.allowLocalhostGateways
2691
+ );
2692
+ const effectiveGatewayUrl = String(
2693
+ partial.whatsappGatewayUrl ?? current.whatsappGatewayUrl ?? ""
2694
+ ).trim();
2695
+ if (effectiveGatewayUrl) {
2696
+ const check = validateGatewayUrl(effectiveGatewayUrl, {
2697
+ allowLocalhost: effectiveAllowLocalhost
2698
+ });
2699
+ if (!check.valid) {
2700
+ throw new Error(check.errors.join("; "));
2701
+ }
2702
+ }
2703
+ const effectiveSiteBaseUrl = String(
2704
+ partial.siteBaseUrl ?? current.siteBaseUrl ?? ""
2705
+ ).trim();
2706
+ if (effectiveSiteBaseUrl && !isSafeHttpUrl(effectiveSiteBaseUrl)) {
2707
+ throw new Error("siteBaseUrl must be a valid http or https URL");
2708
+ }
2709
+ if (partial.publicChannelLinks !== void 0) {
2710
+ const linksCheck = validatePublicChannelLinks(partial.publicChannelLinks);
2711
+ if (!linksCheck.valid) {
2712
+ throw new Error(linksCheck.errors.join("; "));
2713
+ }
2714
+ }
2715
+ if (partial.whatsappGatewaySession !== void 0) {
2716
+ const candidateSession = String(partial.whatsappGatewaySession).trim();
2717
+ if (candidateSession && !isValidGatewaySession(candidateSession)) {
2718
+ throw new Error(
2719
+ "whatsappGatewaySession must be 1-64 alphanumeric, underscore, or hyphen characters"
2720
+ );
2721
+ }
2722
+ }
2723
+ const effectiveGatewaySession = String(
2724
+ partial.whatsappGatewaySession ?? current.whatsappGatewaySession ?? "default"
2725
+ ).trim() || "default";
2726
+ if (!isValidGatewaySession(effectiveGatewaySession)) {
2727
+ throw new Error(
2728
+ "whatsappGatewaySession must be 1-64 alphanumeric, underscore, or hyphen characters"
2729
+ );
2730
+ }
2731
+ const next = {
2732
+ ...current,
2733
+ ...partial,
2734
+ siteBaseUrl: effectiveSiteBaseUrl || current.siteBaseUrl,
2735
+ whatsappGatewaySession: effectiveGatewaySession,
2736
+ emailFromAddress: stripHeaderControlChars(
2737
+ partial.emailFromAddress ?? current.emailFromAddress
2738
+ ),
2739
+ emailReplyToAddress: stripHeaderControlChars(
2740
+ partial.emailReplyToAddress ?? current.emailReplyToAddress
2741
+ ),
2742
+ primaryCalendar: isSupportedCalendar(partial.primaryCalendar) ? partial.primaryCalendar : current.primaryCalendar,
2743
+ secondaryCalendars: resolveSecondaryCalendars(
2744
+ partial.secondaryCalendars,
2745
+ current.secondaryCalendars
2746
+ ),
2747
+ whatsappDriver: isSupportedWhatsAppDriver(partial.whatsappDriver) ? partial.whatsappDriver : current.whatsappDriver,
2748
+ telegramBotToken: resolveSecretValue(
2749
+ partial.telegramBotToken,
2750
+ current.telegramBotToken
2751
+ ),
2752
+ whatsappAccessToken: resolveSecretValue(
2753
+ partial.whatsappAccessToken,
2754
+ current.whatsappAccessToken
2755
+ ),
2756
+ whatsappGatewayApiKey: resolveSecretValue(
2757
+ partial.whatsappGatewayApiKey,
2758
+ current.whatsappGatewayApiKey
2759
+ )
2760
+ };
2761
+ const store = this.getPluginStore();
2762
+ await store.set({
2763
+ key: SETTINGS_KEY,
2764
+ value: next
2765
+ });
2766
+ return next;
2767
+ }
2768
+ });
2769
+ const EMAIL_DESIGNER_TEMPLATE_UID = "plugin::email-designer-5.email-designer-template";
2770
+ const KNOWN_LOCALES = ["es", "en", "ar", "tr", "fr", "pt", "de", "it"];
2771
+ function getNormalizedTags(row) {
2772
+ if (!Array.isArray(row.tags)) return [];
2773
+ return row.tags.map((t) => String(t).toLowerCase());
2774
+ }
2775
+ async function fetchRawEmailDesignerTemplates(strapi) {
2776
+ try {
2777
+ const dbQuery = strapi.db?.query?.(EMAIL_DESIGNER_TEMPLATE_UID);
2778
+ if (dbQuery?.findMany) {
2779
+ const rows = await dbQuery.findMany({
2780
+ orderBy: { templateReferenceId: "asc" },
2781
+ limit: 200
2782
+ });
2783
+ if (Array.isArray(rows)) return rows;
2784
+ }
2785
+ } catch {
2786
+ }
2787
+ try {
2788
+ const docs = await strapi.documents(
2789
+ EMAIL_DESIGNER_TEMPLATE_UID
2790
+ ).findMany({ limit: 200 });
2791
+ if (Array.isArray(docs)) return docs;
2792
+ } catch {
2793
+ }
2794
+ return [];
2795
+ }
2796
+ function toTemplateSummary(row) {
2797
+ const refId = Number(row.templateReferenceId ?? row.id ?? 0);
2798
+ const name = String(row.name || "").trim();
2799
+ if (!refId && !name) return null;
2800
+ return {
2801
+ templateReferenceId: refId,
2802
+ name: name || `Template #${refId}`,
2803
+ subject: typeof row.subject === "string" ? row.subject : void 0,
2804
+ tags: Array.isArray(row.tags) ? row.tags.map((t) => String(t)) : void 0,
2805
+ updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : void 0
2806
+ };
2807
+ }
2808
+ async function listEmailDesignerTemplatesFromDb(strapi) {
2809
+ const rows = await fetchRawEmailDesignerTemplates(strapi);
2810
+ const seen = /* @__PURE__ */ new Set();
2811
+ const summaries = [];
2812
+ for (const row of rows) {
2813
+ const summary = toTemplateSummary(row);
2814
+ if (!summary) continue;
2815
+ const key = `${summary.templateReferenceId}:${summary.name}`;
2816
+ if (seen.has(key)) continue;
2817
+ seen.add(key);
2818
+ summaries.push(summary);
2819
+ }
2820
+ return summaries.sort(
2821
+ (a, b) => a.templateReferenceId - b.templateReferenceId
2822
+ );
2823
+ }
2824
+ function findDirectLocalizedTemplate(rows, trimmedRef, normLocale) {
2825
+ const lowerRef = trimmedRef.toLowerCase();
2826
+ const expectedName = `${lowerRef}_${normLocale}`;
2827
+ const normalizedGroupTag = trimmedRef.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
2828
+ return rows.find((r) => {
2829
+ const rowName = String(r.name || "").toLowerCase();
2830
+ if (rowName === expectedName) return true;
2831
+ const rowTags = getNormalizedTags(r);
2832
+ return (rowTags.includes(lowerRef) || rowTags.includes(normalizedGroupTag)) && rowTags.includes(normLocale);
2833
+ });
2834
+ }
2835
+ function findSiblingBySuffix(rows, primary, normLocale) {
2836
+ if (typeof primary.name !== "string") return void 0;
2837
+ const suffixMatch = primary.name.match(/^(.+)_([a-z]{2,3})$/i);
2838
+ if (!suffixMatch || suffixMatch[2].toLowerCase() === normLocale) {
2839
+ return void 0;
2840
+ }
2841
+ const targetSiblingName = `${suffixMatch[1]}_${normLocale}`.toLowerCase();
2842
+ return rows.find(
2843
+ (r) => String(r.name || "").toLowerCase() === targetSiblingName
2844
+ );
2845
+ }
2846
+ function findSiblingByCategoryTags(rows, primary, normLocale) {
2847
+ const primaryTags = getNormalizedTags(primary);
2848
+ if (primaryTags.includes(normLocale)) return void 0;
2849
+ const categoryTags = primaryTags.filter(
2850
+ (t) => !KNOWN_LOCALES.includes(t) && t !== "visitor" && t !== "community"
2851
+ );
2852
+ if (categoryTags.length === 0) return void 0;
2853
+ return rows.find((r) => {
2854
+ const rTags = getNormalizedTags(r);
2855
+ return rTags.includes(normLocale) && categoryTags.some((cat) => rTags.includes(cat));
2856
+ });
2857
+ }
2858
+ function findPrimaryTemplate(rows, trimmed) {
2859
+ const numericRef = /^\d+$/.test(trimmed) ? Number(trimmed) : null;
2860
+ const lowerTrimmed = trimmed.toLowerCase();
2861
+ return rows.find(
2862
+ (r) => numericRef !== null && Number(r.templateReferenceId) === numericRef || String(r.name || "").toLowerCase() === lowerTrimmed
2863
+ );
2864
+ }
2865
+ function findSiblingTemplate(rows, primary, normLocale) {
2866
+ if (!normLocale) return void 0;
2867
+ return findSiblingBySuffix(rows, primary, normLocale) ?? findSiblingByCategoryTags(rows, primary, normLocale);
2868
+ }
2869
+ async function resolveEmailDesignerTemplateFromDb(strapi, templateRef, locale) {
2870
+ const trimmed = String(templateRef || "").trim();
2871
+ if (!trimmed) return null;
2872
+ const rows = await fetchRawEmailDesignerTemplates(strapi);
2873
+ if (rows.length === 0) return null;
2874
+ const normLocale = locale ? locale.toLowerCase() : void 0;
2875
+ if (normLocale) {
2876
+ const localizedDirect = findDirectLocalizedTemplate(
2877
+ rows,
2878
+ trimmed,
2879
+ normLocale
2880
+ );
2881
+ if (localizedDirect) return localizedDirect;
2882
+ }
2883
+ const primary = findPrimaryTemplate(rows, trimmed);
2884
+ if (!primary) return null;
2885
+ return findSiblingTemplate(rows, primary, normLocale) ?? primary;
2886
+ }
2887
+ async function trySendViaPluginService(strapi, numericTemplateId, recipientEmail, fromAddress, replyToAddress, headers, subject, templateVars) {
2888
+ if (!numericTemplateId) return null;
2889
+ try {
2890
+ const emailDesignerPlugin = strapi.plugin("email-designer-5");
2891
+ const emailDesignerService = emailDesignerPlugin?.service("email");
2892
+ if (emailDesignerService?.sendTemplatedEmail) {
2893
+ await emailDesignerService.sendTemplatedEmail(
2894
+ {
2895
+ to: stripHeaderControlChars(recipientEmail),
2896
+ from: stripHeaderControlChars(fromAddress),
2897
+ replyTo: stripHeaderControlChars(replyToAddress),
2898
+ headers
2899
+ },
2900
+ {
2901
+ templateReferenceId: numericTemplateId,
2902
+ subject: stripHeaderControlChars(subject)
2903
+ },
2904
+ templateVars
2905
+ );
2906
+ return `ed5_${numericTemplateId}_${Date.now()}`;
2907
+ }
2908
+ } catch {
2909
+ }
2910
+ return null;
2911
+ }
2912
+ async function trySendEmailViaDesigner5(params) {
2913
+ const {
2914
+ strapi,
2915
+ effectiveTemplateRef,
2916
+ locale,
2917
+ recipientEmail,
2918
+ fromAddress,
2919
+ replyToAddress,
2920
+ subject,
2921
+ text,
2922
+ headers,
2923
+ templateVars,
2924
+ interpolate
2925
+ } = params;
2926
+ const resolvedTemplate = await resolveEmailDesignerTemplateFromDb(
2927
+ strapi,
2928
+ effectiveTemplateRef,
2929
+ locale
2930
+ );
2931
+ const numericTemplateId = resolvedTemplate?.templateReferenceId ?? (/^\d+$/.test(effectiveTemplateRef) ? Number(effectiveTemplateRef) : null);
2932
+ const pluginSentId = await trySendViaPluginService(
2933
+ strapi,
2934
+ numericTemplateId,
2935
+ recipientEmail,
2936
+ fromAddress,
2937
+ replyToAddress,
2938
+ headers,
2939
+ subject,
2940
+ templateVars
2941
+ );
2942
+ if (pluginSentId) return pluginSentId;
2943
+ const emailService = strapi.plugin("email")?.service("email");
2944
+ if (!resolvedTemplate?.bodyHtml || !emailService?.send) {
2945
+ return null;
2946
+ }
2947
+ const renderedHtml = interpolate(resolvedTemplate.bodyHtml, templateVars);
2948
+ const renderedText = resolvedTemplate.bodyText ? interpolate(resolvedTemplate.bodyText, {
2949
+ ...templateVars,
2950
+ subject,
2951
+ bodyText: text
2952
+ }) : text;
2953
+ const renderedSubject = stripHeaderControlChars(
2954
+ subject || (resolvedTemplate.subject ? interpolate(resolvedTemplate.subject, { ...templateVars, subject }) : "")
2955
+ );
2956
+ await emailService.send({
2957
+ to: stripHeaderControlChars(recipientEmail),
2958
+ from: stripHeaderControlChars(fromAddress),
2959
+ replyTo: stripHeaderControlChars(replyToAddress),
2960
+ subject: renderedSubject,
2961
+ text: renderedText,
2962
+ html: renderedHtml,
2963
+ headers
2964
+ });
2965
+ return `ed5_${resolvedTemplate.templateReferenceId || effectiveTemplateRef}_${Date.now()}`;
2966
+ }
2967
+ function buildSafeListUnsubscribeHeader(rawUnsubscribeUrl) {
2968
+ if (!rawUnsubscribeUrl) return {};
2969
+ const cleaned = stripHeaderControlChars(rawUnsubscribeUrl);
2970
+ if (!cleaned || !isSafeHttpUrl(cleaned)) return {};
2971
+ return { "List-Unsubscribe": `<${cleaned}>` };
2972
+ }
2973
+ async function hasAlreadyDeliveredInDb(strapi, idempotencyKey) {
2974
+ const existing = await strapi.documents(DELIVERY_LOG_UID).findMany({
2975
+ filters: { idempotencyKey, status: { $in: ["sent", "simulated"] } },
2976
+ limit: 1
2977
+ });
2978
+ return existing.length > 0;
2979
+ }
2980
+ async function upsertDeliveryLogInDb(strapi, entry, secrets = []) {
2981
+ const sentAt = (/* @__PURE__ */ new Date()).toISOString();
2982
+ const safeEntry = {
2983
+ ...entry,
2984
+ errorMessage: entry.errorMessage ? redactSecretsFromMessage(entry.errorMessage, secrets) : void 0
2985
+ };
2986
+ const existing = await strapi.documents(DELIVERY_LOG_UID).findMany({
2987
+ filters: { idempotencyKey: safeEntry.idempotencyKey },
2988
+ limit: 1
2989
+ });
2990
+ const existingDocId = typeof existing[0]?.documentId === "string" ? existing[0].documentId : void 0;
2991
+ if (existingDocId) {
2992
+ const updated = await strapi.documents(DELIVERY_LOG_UID).update({
2993
+ documentId: existingDocId,
2994
+ data: { ...safeEntry, sentAt }
2995
+ });
2996
+ return updated ?? {
2997
+ ...safeEntry,
2998
+ documentId: existingDocId,
2999
+ sentAt
3000
+ };
3001
+ }
3002
+ const created = await strapi.documents(DELIVERY_LOG_UID).create({
3003
+ data: { ...safeEntry, sentAt }
3004
+ });
3005
+ return created;
3006
+ }
3007
+ async function listDeliveryLogsFromDb(strapi, params) {
3008
+ const filters = {};
3009
+ if (typeof params?.campaignSlug === "string" && params.campaignSlug.trim()) {
3010
+ filters.campaignSlug = params.campaignSlug.trim();
3011
+ }
3012
+ if (typeof params?.channel === "string" && SUPPORTED_CHANNELS.includes(params.channel)) {
3013
+ filters.channel = params.channel;
3014
+ }
3015
+ const docs = await strapi.documents(DELIVERY_LOG_UID).findMany({
3016
+ filters,
3017
+ limit: clampLimit(params?.limit, 100)
3018
+ });
3019
+ return docs;
3020
+ }
3021
+ function extractSecretList(settings) {
3022
+ if (!settings) return [];
3023
+ return [
3024
+ settings.telegramBotToken,
3025
+ settings.whatsappAccessToken,
3026
+ settings.whatsappGatewayApiKey
3027
+ ];
3028
+ }
3029
+ function sanitizeDispatchResult(result, secrets) {
3030
+ return {
3031
+ ...result,
3032
+ errorMessage: result.errorMessage ? redactSecretsFromMessage(result.errorMessage, secrets) : void 0
3033
+ };
3034
+ }
3035
+ async function sendLiveEmailViaProviders(strapi, payload, settings, effectiveTemplateRef) {
3036
+ const headers = buildSafeListUnsubscribeHeader(payload.unsubscribeUrl);
3037
+ const safeSubject = stripHeaderControlChars(payload.subject);
3038
+ const safeFrom = stripHeaderControlChars(settings.emailFromAddress);
3039
+ const safeReplyTo = stripHeaderControlChars(settings.emailReplyToAddress);
3040
+ const safeRecipient = stripHeaderControlChars(payload.recipientEmail);
3041
+ const inlineHtml = payload.html || `<p>${escapeHtml(payload.text)}</p>`;
3042
+ const templateVars = buildEmailTemplateVariables(
3043
+ { ...payload, subject: safeSubject },
3044
+ settings,
3045
+ inlineHtml
3046
+ );
3047
+ if (effectiveTemplateRef) {
3048
+ const externalMessageId = await trySendEmailViaDesigner5({
3049
+ strapi,
3050
+ effectiveTemplateRef,
3051
+ locale: payload.locale,
3052
+ recipientEmail: safeRecipient,
3053
+ fromAddress: safeFrom,
3054
+ replyToAddress: safeReplyTo,
3055
+ subject: safeSubject,
3056
+ text: payload.text,
3057
+ headers,
3058
+ templateVars,
3059
+ interpolate: interpolateBroadcastTemplate
3060
+ });
3061
+ if (externalMessageId) {
3062
+ return {
3063
+ driver: "email-designer-5",
3064
+ status: "sent",
3065
+ externalMessageId
3066
+ };
3067
+ }
3068
+ }
3069
+ const emailService = strapi.plugin("email")?.service("email");
3070
+ if (!emailService?.send) {
3071
+ return {
3072
+ driver: "strapi-email-fallback",
3073
+ status: "simulated",
3074
+ externalMessageId: `sim_fallback_${Date.now()}`
3075
+ };
3076
+ }
3077
+ await emailService.send({
3078
+ to: safeRecipient,
3079
+ from: safeFrom,
3080
+ replyTo: safeReplyTo,
3081
+ subject: safeSubject,
3082
+ text: payload.text,
3083
+ html: inlineHtml,
3084
+ headers
3085
+ });
3086
+ return {
3087
+ driver: "strapi-email",
3088
+ status: "sent",
3089
+ externalMessageId: `email_${Date.now()}`
3090
+ };
3091
+ }
3092
+ const createTransportService = ({ strapi }) => ({
3093
+ getSettingsService() {
3094
+ return strapi.plugin(PLUGIN_ID).service("settings");
3095
+ },
3096
+ async fetchRawEmailDesignerTemplates() {
3097
+ return fetchRawEmailDesignerTemplates(strapi);
3098
+ },
3099
+ async listEmailDesignerTemplates() {
3100
+ return listEmailDesignerTemplatesFromDb(strapi);
3101
+ },
3102
+ async resolveEmailDesignerTemplate(templateRef, locale) {
3103
+ return resolveEmailDesignerTemplateFromDb(strapi, templateRef, locale);
3104
+ },
3105
+ async hasAlreadyDelivered(idempotencyKey) {
3106
+ return hasAlreadyDeliveredInDb(strapi, idempotencyKey);
3107
+ },
3108
+ async recordDeliveryLog(entry, secrets = []) {
3109
+ return upsertDeliveryLogInDb(strapi, entry, secrets);
3110
+ },
3111
+ async listDeliveryLogs(params) {
3112
+ return listDeliveryLogsFromDb(strapi, params);
3113
+ },
3114
+ async persistResult(campaignSlug, secondaryDateLabel, result, settings) {
3115
+ const secrets = extractSecretList(settings);
3116
+ const safeResult = sanitizeDispatchResult(result, secrets);
3117
+ try {
3118
+ await this.recordDeliveryLog(
3119
+ {
3120
+ ...safeResult,
3121
+ campaignSlug,
3122
+ secondaryDateLabel
3123
+ },
3124
+ secrets
3125
+ );
3126
+ } catch (logErr) {
3127
+ const safeErr = redactSecretsFromMessage(
3128
+ logErr?.message || "log write error",
3129
+ secrets
3130
+ );
3131
+ strapi.log?.warn?.(
3132
+ `[@vicacha-devs/strapi-plugin-broadcasts] Delivery log fallback for ${safeResult.idempotencyKey}: ${safeErr}`
3133
+ );
3134
+ }
3135
+ return safeResult;
3136
+ },
3137
+ async logOutcome(p, channel, driver, recipientMasked, status, idempotencyKey, extra, settings) {
3138
+ return this.persistResult(
3139
+ p.campaignSlug,
3140
+ p.secondaryDateLabel,
3141
+ makeResult(
3142
+ channel,
3143
+ driver,
3144
+ recipientMasked,
3145
+ p.locale,
3146
+ status,
3147
+ idempotencyKey,
3148
+ extra
3149
+ ),
3150
+ settings
3151
+ );
3152
+ },
3153
+ async simulateDryRunEmail(payload, effectiveTemplateRef, recipientMasked, idempotencyKey, settings) {
3154
+ const resolvedDryRunTpl = effectiveTemplateRef ? await this.resolveEmailDesignerTemplate(
3155
+ effectiveTemplateRef,
3156
+ payload.locale
3157
+ ) : null;
3158
+ const useDesigner = Boolean(effectiveTemplateRef);
3159
+ const refSuffix = resolvedDryRunTpl?.templateReferenceId || effectiveTemplateRef;
3160
+ return this.logOutcome(
3161
+ payload,
3162
+ "email",
3163
+ useDesigner ? "email-designer-5-dryrun" : "strapi-email-dryrun",
3164
+ recipientMasked,
3165
+ "simulated",
3166
+ idempotencyKey,
3167
+ {
3168
+ externalMessageId: useDesigner ? `sim_ed5_${refSuffix}_${Date.now()}` : `sim_email_${Date.now()}`
3169
+ },
3170
+ settings
3171
+ );
3172
+ },
3173
+ async executeLiveEmailSend(payload, settings, effectiveTemplateRef, recipientMasked, idempotencyKey) {
3174
+ const sent = await sendLiveEmailViaProviders(
3175
+ strapi,
3176
+ payload,
3177
+ settings,
3178
+ effectiveTemplateRef
3179
+ );
3180
+ return this.logOutcome(
3181
+ payload,
3182
+ "email",
3183
+ sent.driver,
3184
+ recipientMasked,
3185
+ sent.status,
3186
+ idempotencyKey,
3187
+ { externalMessageId: sent.externalMessageId },
3188
+ settings
3189
+ );
3190
+ },
3191
+ async sendEmailBroadcast(payload) {
3192
+ const settings = await this.getSettingsService().getSettings();
3193
+ const idempotencyKey = computeIdempotencyKey(
3194
+ payload.campaignSlug,
3195
+ "email",
3196
+ payload.recipientEmail,
3197
+ { isTest: payload.isTestDispatch }
3198
+ );
3199
+ const recipientMasked = maskRecipientIdentifier(payload.recipientEmail);
3200
+ const effectiveTemplateRef = resolveEffectiveEmailTemplateRef(
3201
+ payload,
3202
+ settings
3203
+ );
3204
+ const defaultDriver = effectiveTemplateRef ? "email-designer-5" : "strapi-email";
3205
+ if (!payload.isTestDispatch && await this.hasAlreadyDelivered(idempotencyKey)) {
3206
+ return makeResult(
3207
+ "email",
3208
+ defaultDriver,
3209
+ recipientMasked,
3210
+ payload.locale,
3211
+ "skipped_duplicate",
3212
+ idempotencyKey
3213
+ );
3214
+ }
3215
+ if (settings.dryRun) {
3216
+ return this.simulateDryRunEmail(
3217
+ payload,
3218
+ effectiveTemplateRef,
3219
+ recipientMasked,
3220
+ idempotencyKey,
3221
+ settings
3222
+ );
3223
+ }
3224
+ try {
3225
+ return await this.executeLiveEmailSend(
3226
+ payload,
3227
+ settings,
3228
+ effectiveTemplateRef,
3229
+ recipientMasked,
3230
+ idempotencyKey
3231
+ );
3232
+ } catch (err) {
3233
+ return this.logOutcome(
3234
+ payload,
3235
+ "email",
3236
+ defaultDriver,
3237
+ recipientMasked,
3238
+ "failed",
3239
+ idempotencyKey,
3240
+ { errorMessage: err?.message || "Email delivery failed" },
3241
+ settings
3242
+ );
3243
+ }
3244
+ },
3245
+ async sendTelegramBroadcast(payload) {
3246
+ const settings = await this.getSettingsService().getSettings();
3247
+ const targetChatId = payload.chatId || settings.telegramDefaultChatId;
3248
+ const idempotencyKey = buildTelegramIdempotencyKey(payload, targetChatId);
3249
+ const recipientMasked = maskRecipientIdentifier(targetChatId);
3250
+ if (!payload.isTestDispatch && await this.hasAlreadyDelivered(idempotencyKey)) {
3251
+ return makeResult(
3252
+ "telegram",
3253
+ "telegram-bot-api",
3254
+ recipientMasked,
3255
+ payload.locale,
3256
+ "skipped_duplicate",
3257
+ idempotencyKey
3258
+ );
3259
+ }
3260
+ if (settings.dryRun || !settings.telegramBotToken) {
3261
+ return this.logOutcome(
3262
+ payload,
3263
+ "telegram",
3264
+ "telegram-bot-dryrun",
3265
+ recipientMasked,
3266
+ "simulated",
3267
+ idempotencyKey,
3268
+ { externalMessageId: `sim_tg_${Date.now()}` },
3269
+ settings
3270
+ );
3271
+ }
3272
+ try {
3273
+ const externalMessageId = await executeTelegramHttpSend(
3274
+ payload,
3275
+ targetChatId,
3276
+ settings.telegramBotToken
3277
+ );
3278
+ return this.logOutcome(
3279
+ payload,
3280
+ "telegram",
3281
+ "telegram-bot-api",
3282
+ recipientMasked,
3283
+ "sent",
3284
+ idempotencyKey,
3285
+ { externalMessageId },
3286
+ settings
3287
+ );
3288
+ } catch (err) {
3289
+ return this.logOutcome(
3290
+ payload,
3291
+ "telegram",
3292
+ "telegram-bot-api",
3293
+ recipientMasked,
3294
+ "failed",
3295
+ idempotencyKey,
3296
+ {
3297
+ errorMessage: err?.message || "Telegram delivery failed"
3298
+ },
3299
+ settings
3300
+ );
3301
+ }
3302
+ },
3303
+ async sendWhatsAppBroadcast(payload) {
3304
+ const settings = await this.getSettingsService().getSettings();
3305
+ const target = normalizeWhatsAppTarget(payload.recipientPhone);
3306
+ const driver = settings.whatsappDriver || "meta-cloud";
3307
+ const idempotencyKey = computeIdempotencyKey(
3308
+ payload.campaignSlug,
3309
+ "whatsapp",
3310
+ target.normalized,
3311
+ { isTest: payload.isTestDispatch }
3312
+ );
3313
+ const recipientMasked = maskRecipientIdentifier(target.normalized);
3314
+ if (!payload.isTestDispatch && await this.hasAlreadyDelivered(idempotencyKey)) {
3315
+ return makeResult(
3316
+ "whatsapp",
3317
+ driver,
3318
+ recipientMasked,
3319
+ payload.locale,
3320
+ "skipped_duplicate",
3321
+ idempotencyKey
3322
+ );
3323
+ }
3324
+ if (settings.dryRun || !hasWhatsAppCredentials(driver, settings)) {
3325
+ return this.logOutcome(
3326
+ payload,
3327
+ "whatsapp",
3328
+ `${driver}-dryrun`,
3329
+ recipientMasked,
3330
+ "simulated",
3331
+ idempotencyKey,
3332
+ { externalMessageId: `sim_wa_${Date.now()}` },
3333
+ settings
3334
+ );
3335
+ }
3336
+ if (driver === "meta-cloud" && target.isGroupOrChannel) {
3337
+ return this.logOutcome(
3338
+ payload,
3339
+ "whatsapp",
3340
+ "meta-cloud",
3341
+ recipientMasked,
3342
+ "failed",
3343
+ idempotencyKey,
3344
+ {
3345
+ errorMessage: "Meta WhatsApp Cloud API does not support @g.us group or @newsletter channel JIDs; use evolution or openwa gateway driver"
3346
+ },
3347
+ settings
3348
+ );
3349
+ }
3350
+ try {
3351
+ const externalMessageId = await executeWhatsAppHttpSend(
3352
+ driver,
3353
+ payload,
3354
+ settings,
3355
+ target
3356
+ );
3357
+ return this.logOutcome(
3358
+ payload,
3359
+ "whatsapp",
3360
+ driver,
3361
+ recipientMasked,
3362
+ "sent",
3363
+ idempotencyKey,
3364
+ { externalMessageId },
3365
+ settings
3366
+ );
3367
+ } catch (err) {
3368
+ return this.logOutcome(
3369
+ payload,
3370
+ "whatsapp",
3371
+ driver,
3372
+ recipientMasked,
3373
+ "failed",
3374
+ idempotencyKey,
3375
+ {
3376
+ errorMessage: err?.message || "WhatsApp delivery failed"
3377
+ },
3378
+ settings
3379
+ );
3380
+ }
3381
+ }
3382
+ });
3383
+ const services = {
3384
+ settings: createSettingsService,
3385
+ transportService: createTransportService,
3386
+ campaignService: createCampaignService,
3387
+ automationService: createAutomationService
3388
+ };
3389
+ const index = {
3390
+ register,
3391
+ bootstrap,
3392
+ destroy,
3393
+ config,
3394
+ contentTypes,
3395
+ controllers,
3396
+ routes,
3397
+ services
3398
+ };
3399
+ exports.default = index;