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