@vicacha-devs/strapi-plugin-inbox 0.0.0-stage → 1.0.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 (90) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +17 -2
  3. package/dist/admin/InboxHomePage-B5xUPD7Y.mjs +2153 -0
  4. package/dist/admin/InboxHomePage-BCHbPE2t.js +2171 -0
  5. package/dist/admin/LocalizationRoutingTab-CEXbHNe4.mjs +282 -0
  6. package/dist/admin/LocalizationRoutingTab-Dt6U-kXn.js +281 -0
  7. package/dist/admin/SettingsPage-Dif7Cq7I.mjs +819 -0
  8. package/dist/admin/SettingsPage-qB_efKCl.js +837 -0
  9. package/dist/admin/ar-CbjX4o07.mjs +268 -0
  10. package/dist/admin/ar-KedDMvsH.js +268 -0
  11. package/dist/admin/de-BrJIGJEV.mjs +268 -0
  12. package/dist/admin/de-rj5pqQ0S.js +268 -0
  13. package/dist/admin/en-BE8TARw0.mjs +268 -0
  14. package/dist/admin/en-CuTmgXZL.js +268 -0
  15. package/dist/admin/es-Berf1dRb.js +268 -0
  16. package/dist/admin/es-Ce4nMwC7.mjs +268 -0
  17. package/dist/admin/fr-CfdjWCiu.js +268 -0
  18. package/dist/admin/fr-Y3GKi6mQ.mjs +268 -0
  19. package/dist/admin/index-5e9dAMBs.mjs +822 -0
  20. package/dist/admin/index-DLfoIVZP.js +839 -0
  21. package/dist/admin/index.js +5 -0
  22. package/dist/admin/index.mjs +5 -0
  23. package/dist/admin/it-DXXnvX5J.mjs +268 -0
  24. package/dist/admin/it-DkJ3WwLA.js +268 -0
  25. package/dist/admin/pt-D7WKv-MR.js +268 -0
  26. package/dist/admin/pt-QDCJ6lOH.mjs +268 -0
  27. package/dist/admin/ru-DgYTw9aE.js +268 -0
  28. package/dist/admin/ru-DzP0H4fh.mjs +268 -0
  29. package/dist/admin/src/components/Initializer.d.ts +6 -0
  30. package/dist/admin/src/components/PluginIcon.d.ts +3 -0
  31. package/dist/admin/src/components/inbox/AlertBannersTab.d.ts +10 -0
  32. package/dist/admin/src/components/inbox/CaseworkDetailPanel.d.ts +15 -0
  33. package/dist/admin/src/components/inbox/CaseworkPayloadChecklistColumn.d.ts +12 -0
  34. package/dist/admin/src/components/inbox/CaseworkSubmissionsTab.d.ts +12 -0
  35. package/dist/admin/src/components/inbox/FormBuilderTab.d.ts +17 -0
  36. package/dist/admin/src/components/inbox/FormChecklistAndLocalePanels.d.ts +17 -0
  37. package/dist/admin/src/components/inbox/FormRoutingDrawer.d.ts +15 -0
  38. package/dist/admin/src/components/inbox/SubscribersTab.d.ts +10 -0
  39. package/dist/admin/src/components/settings/AntiSpamCaptchaTab.d.ts +9 -0
  40. package/dist/admin/src/components/settings/BridgesNotificationsTab.d.ts +8 -0
  41. package/dist/admin/src/components/settings/CaptchaProviderCard.d.ts +21 -0
  42. package/dist/admin/src/components/settings/LocalizationRoutingTab.d.ts +16 -0
  43. package/dist/admin/src/index.d.ts +21 -0
  44. package/dist/admin/src/pages/InboxHomePage.d.ts +3 -0
  45. package/dist/admin/src/pages/SettingsPage.d.ts +3 -0
  46. package/dist/admin/src/pluginId.d.ts +3 -0
  47. package/dist/admin/src/translations/cmLabels.d.ts +1 -0
  48. package/dist/admin/src/translations/cmPlaceholders.d.ts +1 -0
  49. package/dist/admin/src/utils/getTranslation.d.ts +3 -0
  50. package/dist/admin/tr-5q4NwTgF.mjs +268 -0
  51. package/dist/admin/tr-CT-24fLA.js +268 -0
  52. package/dist/admin/zh-CYhWM5Qr.mjs +268 -0
  53. package/dist/admin/zh-CmCHcNcd.js +268 -0
  54. package/dist/server/index.js +3108 -0
  55. package/dist/server/index.mjs +3108 -0
  56. package/dist/server/src/bootstrap.d.ts +5 -0
  57. package/dist/server/src/config/index.d.ts +5 -0
  58. package/dist/server/src/content-types/alert-banner/index.d.ts +73 -0
  59. package/dist/server/src/content-types/alert-banner/schema.json.d.ts +73 -0
  60. package/dist/server/src/content-types/index.d.ts +253 -0
  61. package/dist/server/src/content-types/submission/index.d.ts +111 -0
  62. package/dist/server/src/content-types/submission/schema.json.d.ts +126 -0
  63. package/dist/server/src/content-types/subscriber/index.d.ts +69 -0
  64. package/dist/server/src/content-types/subscriber/schema.json.d.ts +69 -0
  65. package/dist/server/src/controllers/caseworkHandlers.d.ts +10 -0
  66. package/dist/server/src/controllers/communityHandlers.d.ts +10 -0
  67. package/dist/server/src/controllers/formHandlers.d.ts +14 -0
  68. package/dist/server/src/controllers/inboxController.d.ts +40 -0
  69. package/dist/server/src/controllers/index.d.ts +2 -0
  70. package/dist/server/src/destroy.d.ts +5 -0
  71. package/dist/server/src/index.d.ts +2 -0
  72. package/dist/server/src/register.d.ts +5 -0
  73. package/dist/server/src/routes/admin.d.ts +12 -0
  74. package/dist/server/src/routes/content-api.d.ts +13 -0
  75. package/dist/server/src/routes/index.d.ts +26 -0
  76. package/dist/server/src/services/alertService.d.ts +16 -0
  77. package/dist/server/src/services/docHelper.d.ts +28 -0
  78. package/dist/server/src/services/formBuilderMapper.d.ts +22 -0
  79. package/dist/server/src/services/formBuilderSync.d.ts +13 -0
  80. package/dist/server/src/services/formService.d.ts +40 -0
  81. package/dist/server/src/services/index.d.ts +2 -0
  82. package/dist/server/src/services/settings.d.ts +9 -0
  83. package/dist/server/src/services/submissionEcosystemBridges.d.ts +21 -0
  84. package/dist/server/src/services/submissionFormBuilderBridge.d.ts +23 -0
  85. package/dist/server/src/services/submissionService.d.ts +58 -0
  86. package/dist/server/src/services/subscriberService.d.ts +22 -0
  87. package/dist/shared/index.d.ts +1 -0
  88. package/dist/shared/index.js +917 -0
  89. package/dist/shared/index.mjs +917 -0
  90. package/package.json +125 -3
@@ -0,0 +1,3108 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const node_crypto = require("node:crypto");
4
+ const PLUGIN_ID = "inbox";
5
+ const FORM_BUILDER_CMS_PLUGIN_ID = "strapi-plugin-form-builder-cms";
6
+ const FORM_BUILDER_CMS_FORM_UID = `plugin::${FORM_BUILDER_CMS_PLUGIN_ID}.form`;
7
+ const FORM_BUILDER_CMS_SUBMISSION_UID = `plugin::${FORM_BUILDER_CMS_PLUGIN_ID}.form-submission`;
8
+ const SUBMISSION_UID = `plugin::${PLUGIN_ID}.submission`;
9
+ const SUBSCRIBER_UID = `plugin::${PLUGIN_ID}.subscriber`;
10
+ const ALERT_BANNER_UID = `plugin::${PLUGIN_ID}.alert-banner`;
11
+ const DECORATIVE_FIELD_TYPES = [
12
+ "heading",
13
+ "paragraph",
14
+ "divider"
15
+ ];
16
+ const CAPTCHA_ENDPOINTS = {
17
+ turnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
18
+ recaptcha: "https://www.google.com/recaptcha/api/siteverify"
19
+ };
20
+ const DEFAULT_INBOX_SETTINGS = {
21
+ primaryCalendar: "gregory",
22
+ secondaryCalendars: ["islamic-umalqura"],
23
+ defaultLocale: "es",
24
+ supportedLocales: ["es", "en", "ar"],
25
+ defaultRateLimitPerHour: 15,
26
+ defaultHoneypotField: "_website_url",
27
+ defaultCaptchaProvider: "none",
28
+ turnstileSiteKey: "",
29
+ turnstileSecretKey: "",
30
+ recaptchaSiteKey: "",
31
+ recaptchaSecretKey: "",
32
+ enableDoubleOptIn: true,
33
+ availableTopics: ["announcements", "events", "courses", "newsletter"],
34
+ availableDepartments: [
35
+ "general",
36
+ "administration",
37
+ "services",
38
+ "education",
39
+ "social_aid"
40
+ ],
41
+ enableFormBuilderBridge: true,
42
+ autoSyncFormBuilderForms: true,
43
+ enableBookingBridge: true,
44
+ enableCertificateBridge: true,
45
+ defaultNotifyEmails: [],
46
+ outboundWebhookUrl: ""
47
+ };
48
+ function isValidEmail(value) {
49
+ if (/\s/.test(value))
50
+ return false;
51
+ const at = value.indexOf("@");
52
+ if (at < 1 || at !== value.lastIndexOf("@"))
53
+ return false;
54
+ const domain = value.slice(at + 1);
55
+ const dot = domain.indexOf(".");
56
+ return dot > 0 && dot < domain.length - 1;
57
+ }
58
+ function freezeOptions(items) {
59
+ return Object.freeze(items.map((item) => Object.freeze({ ...item })));
60
+ }
61
+ freezeOptions([
62
+ { value: "es", label: "es — Español (Spanish)" },
63
+ { value: "en", label: "en — English" },
64
+ { value: "ar", label: "ar — العربية (Arabic)" },
65
+ { value: "tr", label: "tr — Türkçe (Turkish)" },
66
+ { value: "fr", label: "fr — Français (French)" },
67
+ { value: "de", label: "de — Deutsch (German)" },
68
+ { value: "pt", label: "pt — Português (Portuguese)" },
69
+ { value: "it", label: "it — Italiano (Italian)" },
70
+ { value: "nl", label: "nl — Nederlands (Dutch)" },
71
+ { value: "pl", label: "pl — Polski (Polish)" },
72
+ { value: "ru", label: "ru — Русский (Russian)" },
73
+ { value: "id", label: "id — Bahasa Indonesia" },
74
+ { value: "ur", label: "ur — اردو (Urdu)" },
75
+ { value: "zh", label: "zh — 中文 (Chinese)" }
76
+ ]);
77
+ freezeOptions([
78
+ {
79
+ value: "islamic-umalqura",
80
+ label: "islamic-umalqura (Hijri Umm al-Qura)"
81
+ },
82
+ { value: "islamic-civil", label: "islamic-civil (Tabular Islamic Civil)" },
83
+ { value: "islamic-tbla", label: "islamic-tbla (Tabular Astronomical)" },
84
+ { value: "hebrew", label: "hebrew (Hebrew)" },
85
+ { value: "persian", label: "persian (Solar Hijri)" },
86
+ { value: "none", label: "none (Gregorian Only)" }
87
+ ]);
88
+ const LOCALIZED_REQUIRED_FALLBACK = {
89
+ es: (label) => `${label} es obligatorio`,
90
+ en: (label) => `${label} is required`,
91
+ ar: (label) => `${label} مطلوب`
92
+ };
93
+ function isValueEmpty(value) {
94
+ if (value === null || value === void 0) return true;
95
+ if (typeof value === "string") return value.trim().length === 0;
96
+ if (Array.isArray(value)) return value.length === 0;
97
+ if (typeof value === "boolean") return value === false;
98
+ return false;
99
+ }
100
+ function resolveRuleMessage(rule, locale, fallback) {
101
+ if (!rule) return fallback;
102
+ if (rule.localizedMessages && rule.localizedMessages[locale]) {
103
+ return rule.localizedMessages[locale];
104
+ }
105
+ if (rule.message) return rule.message;
106
+ return fallback;
107
+ }
108
+ function isValidHttpUrl(strVal) {
109
+ try {
110
+ const parsed = new URL(strVal);
111
+ return ["http:", "https:"].includes(parsed.protocol);
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ function checkFieldOptions(field, rawValue, strVal, fieldLabel) {
117
+ if (!Array.isArray(field.options) || field.options.length === 0) return null;
118
+ const allowed = new Set(field.options.map((o) => o.value));
119
+ if (field.type === "select" || field.type === "radio") {
120
+ return allowed.has(strVal) ? null : `${fieldLabel} contains an invalid selection`;
121
+ }
122
+ if (field.type === "checkbox-group") {
123
+ const values = Array.isArray(rawValue) ? rawValue.map(String) : [strVal];
124
+ return values.some((v) => !allowed.has(v)) ? `${fieldLabel} contains an invalid selection` : null;
125
+ }
126
+ return null;
127
+ }
128
+ function validateFieldTypeAndOptions(field, rawValue, strVal, fieldLabel, locale, rules) {
129
+ const fieldErrors = [];
130
+ if (field.type === "email" && !isValidEmail(strVal)) {
131
+ const emailRule = rules.find((r) => r.type === "email");
132
+ fieldErrors.push(
133
+ resolveRuleMessage(
134
+ emailRule,
135
+ locale,
136
+ `${fieldLabel} must be a valid email address`
137
+ )
138
+ );
139
+ }
140
+ if (field.type === "url" && !isValidHttpUrl(strVal)) {
141
+ const urlRule = rules.find((r) => r.type === "url");
142
+ fieldErrors.push(
143
+ resolveRuleMessage(urlRule, locale, `${fieldLabel} must be a valid URL`)
144
+ );
145
+ }
146
+ const optError = checkFieldOptions(field, rawValue, strVal, fieldLabel);
147
+ if (optError) fieldErrors.push(optError);
148
+ return fieldErrors;
149
+ }
150
+ function checkLengthRule(rule, limit, strVal, fieldLabel, locale) {
151
+ if (rule.type === "minLength" && strVal.length < limit) {
152
+ return resolveRuleMessage(
153
+ rule,
154
+ locale,
155
+ `${fieldLabel} must be at least ${limit} characters`
156
+ );
157
+ }
158
+ if (rule.type === "maxLength" && strVal.length > limit) {
159
+ return resolveRuleMessage(
160
+ rule,
161
+ locale,
162
+ `${fieldLabel} must be at most ${limit} characters`
163
+ );
164
+ }
165
+ return null;
166
+ }
167
+ function checkNumericBoundRule(rule, limit, rawValue, fieldLabel, locale) {
168
+ const num = Number(rawValue);
169
+ if (rule.type === "min" && (Number.isNaN(num) || num < limit)) {
170
+ return resolveRuleMessage(
171
+ rule,
172
+ locale,
173
+ `${fieldLabel} must be at least ${limit}`
174
+ );
175
+ }
176
+ if (rule.type === "max" && (Number.isNaN(num) || num > limit)) {
177
+ return resolveRuleMessage(
178
+ rule,
179
+ locale,
180
+ `${fieldLabel} must be at most ${limit}`
181
+ );
182
+ }
183
+ return null;
184
+ }
185
+ function checkLengthOrNumberRule(rule, rawValue, strVal, fieldLabel, locale) {
186
+ if (typeof rule.value !== "number") return null;
187
+ return checkLengthRule(rule, rule.value, strVal, fieldLabel, locale) ?? checkNumericBoundRule(rule, rule.value, rawValue, fieldLabel, locale);
188
+ }
189
+ function checkPatternOrEmailRule(rule, fieldType, strVal, fieldLabel, locale) {
190
+ if (rule.type === "pattern" && typeof rule.value === "string") {
191
+ try {
192
+ const regex = new RegExp(rule.value);
193
+ if (!regex.test(strVal)) {
194
+ return resolveRuleMessage(
195
+ rule,
196
+ locale,
197
+ `${fieldLabel} format is invalid`
198
+ );
199
+ }
200
+ } catch {
201
+ }
202
+ }
203
+ if (rule.type === "email" && fieldType !== "email" && !isValidEmail(strVal)) {
204
+ return resolveRuleMessage(
205
+ rule,
206
+ locale,
207
+ `${fieldLabel} must be a valid email address`
208
+ );
209
+ }
210
+ return null;
211
+ }
212
+ function evaluateCustomRules(field, rawValue, strVal, fieldLabel, locale, rules) {
213
+ const errors = [];
214
+ for (const rule of rules) {
215
+ if (rule.type === "required") continue;
216
+ const lenOrNumErr = checkLengthOrNumberRule(
217
+ rule,
218
+ rawValue,
219
+ strVal,
220
+ fieldLabel,
221
+ locale
222
+ );
223
+ if (lenOrNumErr) errors.push(lenOrNumErr);
224
+ const patOrEmailErr = checkPatternOrEmailRule(
225
+ rule,
226
+ field.type,
227
+ strVal,
228
+ fieldLabel,
229
+ locale
230
+ );
231
+ if (patOrEmailErr) errors.push(patOrEmailErr);
232
+ }
233
+ return errors;
234
+ }
235
+ function resolveFieldLabel(field, locale) {
236
+ return field.localizedLabels?.[locale] || field.label || field.name;
237
+ }
238
+ function buildRequiredError(rules, locale, fieldLabel) {
239
+ const reqRule = rules.find((r) => r.type === "required");
240
+ const fallbackFn = LOCALIZED_REQUIRED_FALLBACK[locale] || LOCALIZED_REQUIRED_FALLBACK.en;
241
+ return resolveRuleMessage(reqRule, locale, fallbackFn(fieldLabel));
242
+ }
243
+ function validateSingleField(field, data, locale) {
244
+ const rawValue = data[field.name] ?? field.defaultValue;
245
+ const fieldLabel = resolveFieldLabel(field, locale);
246
+ const rules = field.validation || [];
247
+ if (field.required && isValueEmpty(rawValue)) {
248
+ return {
249
+ fieldErrors: [buildRequiredError(rules, locale, fieldLabel)],
250
+ cleanedValue: null
251
+ };
252
+ }
253
+ if (isValueEmpty(rawValue)) {
254
+ return { fieldErrors: [], cleanedValue: rawValue ?? null };
255
+ }
256
+ const strVal = typeof rawValue === "string" ? rawValue.trim() : String(rawValue);
257
+ const fieldErrors = [
258
+ ...validateFieldTypeAndOptions(
259
+ field,
260
+ rawValue,
261
+ strVal,
262
+ fieldLabel,
263
+ locale,
264
+ rules
265
+ ),
266
+ ...evaluateCustomRules(field, rawValue, strVal, fieldLabel, locale, rules)
267
+ ];
268
+ return {
269
+ fieldErrors,
270
+ cleanedValue: typeof rawValue === "string" ? strVal : rawValue
271
+ };
272
+ }
273
+ function validateFormSubmission(fields, data, locale = "en") {
274
+ const errors = {};
275
+ const cleanedData = {};
276
+ for (const field of fields) {
277
+ if (DECORATIVE_FIELD_TYPES.includes(field.type)) continue;
278
+ const { fieldErrors, cleanedValue } = validateSingleField(
279
+ field,
280
+ data,
281
+ locale
282
+ );
283
+ if (fieldErrors.length > 0) {
284
+ errors[field.name] = fieldErrors;
285
+ } else {
286
+ cleanedData[field.name] = cleanedValue;
287
+ }
288
+ }
289
+ return {
290
+ valid: Object.keys(errors).length === 0,
291
+ errors,
292
+ cleanedData
293
+ };
294
+ }
295
+ function isHoneypotTriggered(payload, honeypotField = "_website_url") {
296
+ if (!honeypotField) return false;
297
+ const val = payload[honeypotField];
298
+ return typeof val === "string" && val.trim().length > 0;
299
+ }
300
+ class SlidingWindowRateLimiter {
301
+ hits = /* @__PURE__ */ new Map();
302
+ check(ip, bucketKey, limitPerHour, nowMs = Date.now()) {
303
+ if (limitPerHour <= 0) {
304
+ return { allowed: true, remaining: 999, retryAfterSeconds: 0 };
305
+ }
306
+ const windowMs = 60 * 60 * 1e3;
307
+ const key = `${bucketKey}::${ip || "unknown"}`;
308
+ const cutoff = nowMs - windowMs;
309
+ const previous = (this.hits.get(key) || []).filter((ts) => ts > cutoff);
310
+ if (previous.length >= limitPerHour) {
311
+ const oldest = previous[0] ?? nowMs;
312
+ const retryAfterSeconds = Math.max(
313
+ 1,
314
+ Math.ceil((oldest + windowMs - nowMs) / 1e3)
315
+ );
316
+ this.hits.set(key, previous);
317
+ return {
318
+ allowed: false,
319
+ remaining: 0,
320
+ retryAfterSeconds
321
+ };
322
+ }
323
+ previous.push(nowMs);
324
+ this.hits.set(key, previous);
325
+ return {
326
+ allowed: true,
327
+ remaining: Math.max(0, limitPerHour - previous.length),
328
+ retryAfterSeconds: 0
329
+ };
330
+ }
331
+ reset() {
332
+ this.hits.clear();
333
+ }
334
+ }
335
+ const defaultRateLimiter = new SlidingWindowRateLimiter();
336
+ function resolveFetchRunner(fetchImpl) {
337
+ return fetchImpl ?? globalThis.fetch;
338
+ }
339
+ async function postCaptchaVerify(runner, url, params) {
340
+ try {
341
+ const res = await runner(url, {
342
+ method: "POST",
343
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
344
+ body: params.toString()
345
+ });
346
+ return await res.json();
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+ async function verifyCaptchaToken(provider, secret, token, ip = "", fetchImpl) {
352
+ if (provider === "none") return true;
353
+ const url = CAPTCHA_ENDPOINTS[provider];
354
+ const trimmedSecret = (secret || "").trim();
355
+ const trimmedToken = (token || "").trim();
356
+ const runner = resolveFetchRunner(fetchImpl);
357
+ if (!url || !trimmedSecret || !trimmedToken || !runner) return false;
358
+ const params = new URLSearchParams({
359
+ secret: trimmedSecret,
360
+ response: trimmedToken
361
+ });
362
+ if (ip) params.append("remoteip", ip);
363
+ const json = await postCaptchaVerify(runner, url, params);
364
+ return Boolean(json?.success);
365
+ }
366
+ async function testCaptchaSecretKey(provider, secret, fetchImpl) {
367
+ if (provider === "none") return { ok: true };
368
+ const url = CAPTCHA_ENDPOINTS[provider];
369
+ if (!url) return { ok: false, reason: "Unknown CAPTCHA provider" };
370
+ const trimmedSecret = (secret || "").trim();
371
+ if (!trimmedSecret) return { ok: false, reason: "Secret key is empty" };
372
+ const runner = resolveFetchRunner(fetchImpl);
373
+ if (!runner) return { ok: false, reason: "Fetch implementation unavailable" };
374
+ const params = new URLSearchParams({
375
+ secret: trimmedSecret,
376
+ response: "test-token"
377
+ });
378
+ const json = await postCaptchaVerify(runner, url, params);
379
+ if (!json) {
380
+ return { ok: false, reason: "Could not reach the CAPTCHA provider" };
381
+ }
382
+ const codes = json["error-codes"] || [];
383
+ if (codes.includes("invalid-input-secret") || codes.includes("missing-input-secret")) {
384
+ return { ok: false, reason: "Secret key is invalid" };
385
+ }
386
+ return { ok: true };
387
+ }
388
+ const STARTER_FORM_TEMPLATES = [
389
+ {
390
+ id: "general-contact",
391
+ name: "General Contact & Inquiry",
392
+ description: "Multi-language contact form with subject routing and anti-spam protection.",
393
+ form: {
394
+ title: "General Contact",
395
+ slug: "general-contact",
396
+ description: "Send us a message and our team will respond promptly.",
397
+ category: "contact",
398
+ active: true,
399
+ publishedAt: "2026-01-01T00:00:00.000Z",
400
+ translations: {
401
+ es: {
402
+ title: "Contacto General",
403
+ description: "Envíanos un mensaje y nuestro equipo te responderá pronto.",
404
+ submitButtonLabel: "Enviar mensaje",
405
+ successMessage: "Gracias por contactarnos. Hemos recibido tu mensaje."
406
+ },
407
+ en: {
408
+ title: "General Contact",
409
+ description: "Send us a message and our team will respond promptly.",
410
+ submitButtonLabel: "Send message",
411
+ successMessage: "Thank you for reaching out. We have received your message."
412
+ },
413
+ ar: {
414
+ title: "الاتصال العام",
415
+ description: "أرسل لنا رسالة وسيرد فريقنا في أقرب وقت.",
416
+ submitButtonLabel: "إرسال الرسالة",
417
+ successMessage: "شكراً لتواصلك معنا. لقد استلمنا رسالتك."
418
+ }
419
+ },
420
+ fields: [
421
+ {
422
+ id: "fld_full_name",
423
+ type: "text",
424
+ name: "fullName",
425
+ label: "Full Name",
426
+ required: true,
427
+ width: "half",
428
+ localizedLabels: {
429
+ es: "Nombre completo",
430
+ en: "Full Name",
431
+ ar: "الاسم الكامل"
432
+ },
433
+ validation: [{ type: "minLength", value: 2 }]
434
+ },
435
+ {
436
+ id: "fld_email",
437
+ type: "email",
438
+ name: "email",
439
+ label: "Email Address",
440
+ required: true,
441
+ width: "half",
442
+ localizedLabels: {
443
+ es: "Correo electrónico",
444
+ en: "Email Address",
445
+ ar: "البريد الإلكتروني"
446
+ }
447
+ },
448
+ {
449
+ id: "fld_phone",
450
+ type: "phone",
451
+ name: "phone",
452
+ label: "Phone / WhatsApp",
453
+ required: false,
454
+ width: "half",
455
+ localizedLabels: {
456
+ es: "Teléfono / WhatsApp",
457
+ en: "Phone / WhatsApp",
458
+ ar: "الهاتف / واتساب"
459
+ }
460
+ },
461
+ {
462
+ id: "fld_subject",
463
+ type: "select",
464
+ name: "subject",
465
+ label: "Subject",
466
+ required: true,
467
+ width: "half",
468
+ localizedLabels: {
469
+ es: "Asunto",
470
+ en: "Subject",
471
+ ar: "الموضوع"
472
+ },
473
+ options: [
474
+ {
475
+ value: "general",
476
+ label: "General Inquiry",
477
+ localizedLabels: {
478
+ es: "Consulta general",
479
+ en: "General Inquiry",
480
+ ar: "استفسار عام"
481
+ }
482
+ },
483
+ {
484
+ value: "visit",
485
+ label: "Guided Visit",
486
+ localizedLabels: {
487
+ es: "Visita guiada",
488
+ en: "Guided Visit",
489
+ ar: "زيارة إرشادية"
490
+ }
491
+ },
492
+ {
493
+ value: "support",
494
+ label: "Support & Assistance",
495
+ localizedLabels: {
496
+ es: "Apoyo y asistencia",
497
+ en: "Support & Assistance",
498
+ ar: "الدعم والمساعدة"
499
+ }
500
+ }
501
+ ]
502
+ },
503
+ {
504
+ id: "fld_message",
505
+ type: "textarea",
506
+ name: "message",
507
+ label: "Message",
508
+ required: true,
509
+ width: "full",
510
+ localizedLabels: {
511
+ es: "Mensaje",
512
+ en: "Message",
513
+ ar: "الرسالة"
514
+ },
515
+ validation: [
516
+ { type: "minLength", value: 10 },
517
+ { type: "maxLength", value: 2e3 }
518
+ ]
519
+ }
520
+ ],
521
+ settings: {
522
+ honeypotField: "_website_url",
523
+ rateLimitPerHour: 15,
524
+ captchaProvider: "none",
525
+ defaultPriority: "normal",
526
+ department: "general"
527
+ }
528
+ }
529
+ },
530
+ {
531
+ id: "service-desk-application",
532
+ name: "Service-Desk Casework & Document Checklist",
533
+ description: "Casework intake form with document verification checklist, booking bridge, and certificate bridge.",
534
+ form: {
535
+ title: "Formal Service Request",
536
+ slug: "service-request",
537
+ description: "Submit an official service application and required supporting documents.",
538
+ category: "service_desk",
539
+ active: true,
540
+ publishedAt: "2026-01-01T00:00:00.000Z",
541
+ translations: {
542
+ es: {
543
+ title: "Solicitud Formal de Servicio",
544
+ description: "Envía una solicitud oficial de servicio con los documentos requeridos.",
545
+ submitButtonLabel: "Radicar solicitud",
546
+ successMessage: "Tu solicitud ha sido radicada con éxito."
547
+ },
548
+ en: {
549
+ title: "Formal Service Request",
550
+ description: "Submit an official service application and required supporting documents.",
551
+ submitButtonLabel: "Submit application",
552
+ successMessage: "Your application has been registered."
553
+ },
554
+ ar: {
555
+ title: "طلب خدمة رسمي",
556
+ description: "قدم طلب خدمة رسمي مع المستندات المطلوبة.",
557
+ submitButtonLabel: "تقديم الطلب",
558
+ successMessage: "تم تسجيل طلبك بنجاح."
559
+ }
560
+ },
561
+ fields: [
562
+ {
563
+ id: "fld_applicant_name",
564
+ type: "text",
565
+ name: "applicantName",
566
+ label: "Applicant Full Name",
567
+ required: true,
568
+ width: "half",
569
+ localizedLabels: {
570
+ es: "Nombre completo del solicitante",
571
+ en: "Applicant Full Name",
572
+ ar: "الاسم الكامل لمقدم الطلب"
573
+ }
574
+ },
575
+ {
576
+ id: "fld_applicant_email",
577
+ type: "email",
578
+ name: "applicantEmail",
579
+ label: "Applicant Email",
580
+ required: true,
581
+ width: "half",
582
+ localizedLabels: {
583
+ es: "Correo del solicitante",
584
+ en: "Applicant Email",
585
+ ar: "البريد الإلكتروني لمقدم الطلب"
586
+ }
587
+ },
588
+ {
589
+ id: "fld_applicant_phone",
590
+ type: "phone",
591
+ name: "applicantPhone",
592
+ label: "Contact Phone",
593
+ required: true,
594
+ width: "half",
595
+ localizedLabels: {
596
+ es: "Teléfono de contacto",
597
+ en: "Contact Phone",
598
+ ar: "هاتف الاتصال"
599
+ }
600
+ },
601
+ {
602
+ id: "fld_preferred_date",
603
+ type: "date",
604
+ name: "preferredDate",
605
+ label: "Preferred Appointment Date",
606
+ required: false,
607
+ width: "half",
608
+ localizedLabels: {
609
+ es: "Fecha preferida de cita",
610
+ en: "Preferred Appointment Date",
611
+ ar: "تاريخ الموعد المفضل"
612
+ }
613
+ },
614
+ {
615
+ id: "fld_details",
616
+ type: "textarea",
617
+ name: "details",
618
+ label: "Case Details & Notes",
619
+ required: true,
620
+ width: "full",
621
+ localizedLabels: {
622
+ es: "Detalles del caso y observaciones",
623
+ en: "Case Details & Notes",
624
+ ar: "تفاصيل الطلب والملاحظات"
625
+ }
626
+ }
627
+ ],
628
+ checklistTemplate: [
629
+ {
630
+ id: "chk_id_document",
631
+ label: "Government ID / Passport Copy",
632
+ required: true,
633
+ verified: false,
634
+ localizedLabels: {
635
+ es: "Copia de documento de identidad / Pasaporte",
636
+ en: "Government ID / Passport Copy",
637
+ ar: "نسخة من بطاقة الهوية أو جواز السفر"
638
+ }
639
+ },
640
+ {
641
+ id: "chk_supporting_form",
642
+ label: "Signed Application Form",
643
+ required: true,
644
+ verified: false,
645
+ localizedLabels: {
646
+ es: "Formulario de solicitud firmado",
647
+ en: "Signed Application Form",
648
+ ar: "نموذج الطلب الموقع"
649
+ }
650
+ }
651
+ ],
652
+ settings: {
653
+ honeypotField: "_website_url",
654
+ rateLimitPerHour: 10,
655
+ captchaProvider: "none",
656
+ enableBookingBridge: true,
657
+ enableCertificateBridge: true,
658
+ defaultPriority: "normal",
659
+ department: "services"
660
+ }
661
+ }
662
+ }
663
+ ];
664
+ function localizeFormForLocale(form, locale) {
665
+ const tr = form.translations?.[locale];
666
+ const localizedFields = (form.fields || []).map((field) => ({
667
+ ...field,
668
+ label: field.localizedLabels?.[locale] || field.label,
669
+ placeholder: field.localizedPlaceholders?.[locale] || field.placeholder,
670
+ helpText: field.localizedHelpTexts?.[locale] || field.helpText,
671
+ options: field.options?.map((opt) => ({
672
+ ...opt,
673
+ label: opt.localizedLabels?.[locale] || opt.label
674
+ }))
675
+ }));
676
+ const localizedChecklist = form.checklistTemplate?.map((item) => ({
677
+ ...item,
678
+ label: item.localizedLabels?.[locale] || item.label
679
+ }));
680
+ return {
681
+ ...form,
682
+ title: tr?.title || form.title,
683
+ description: tr?.description || form.description,
684
+ fields: localizedFields,
685
+ checklistTemplate: localizedChecklist,
686
+ settings: {
687
+ ...form.settings,
688
+ submitButtonLabel: tr?.submitButtonLabel || form.settings?.localizedSubmitLabels?.[locale] || form.settings?.submitButtonLabel,
689
+ successMessage: tr?.successMessage || form.settings?.localizedSuccessMessages?.[locale] || form.settings?.successMessage
690
+ }
691
+ };
692
+ }
693
+ const FORMULA_PREFIXES = ["=", "+", "-", "@", " ", "\r"];
694
+ function escapeCsvCell(value) {
695
+ if (value === null || value === void 0) return "";
696
+ const raw = typeof value === "object" ? JSON.stringify(value) : String(value);
697
+ const sanitized = raw.length > 0 && FORMULA_PREFIXES.includes(raw[0]) ? `'${raw}` : raw;
698
+ if (sanitized.includes('"') || sanitized.includes(",") || sanitized.includes("\n") || sanitized.includes("\r")) {
699
+ return `"${sanitized.replace(/"/g, '""')}"`;
700
+ }
701
+ return sanitized;
702
+ }
703
+ function collectDataFieldNames(submissions, fields) {
704
+ const dataFieldNames = [];
705
+ const seen = /* @__PURE__ */ new Set();
706
+ for (const field of fields) {
707
+ if (!DECORATIVE_FIELD_TYPES.includes(field.type) && !seen.has(field.name)) {
708
+ seen.add(field.name);
709
+ dataFieldNames.push(field.name);
710
+ }
711
+ }
712
+ for (const sub of submissions) {
713
+ for (const key of Object.keys(sub.data || {})) {
714
+ if (!seen.has(key)) {
715
+ seen.add(key);
716
+ dataFieldNames.push(key);
717
+ }
718
+ }
719
+ }
720
+ return dataFieldNames;
721
+ }
722
+ function getBaseSubmissionValues(sub) {
723
+ return [
724
+ sub.referenceCode,
725
+ sub.formSlug,
726
+ sub.status,
727
+ sub.priority,
728
+ sub.department ?? "",
729
+ sub.locale,
730
+ sub.applicantName ?? "",
731
+ sub.applicantEmail ?? "",
732
+ sub.applicantPhone ?? "",
733
+ sub.submittedAt,
734
+ sub.secondaryDate?.formatted ?? "",
735
+ sub.linkedBookingId ?? "",
736
+ sub.linkedCertificateNumber ?? ""
737
+ ];
738
+ }
739
+ const BASE_CSV_HEADERS = [
740
+ "referenceCode",
741
+ "formSlug",
742
+ "status",
743
+ "priority",
744
+ "department",
745
+ "locale",
746
+ "applicantName",
747
+ "applicantEmail",
748
+ "applicantPhone",
749
+ "submittedAt",
750
+ "secondaryDate",
751
+ "linkedBookingId",
752
+ "linkedCertificateNumber"
753
+ ];
754
+ function submissionsToCsv(submissions, fields = []) {
755
+ const dataFieldNames = collectDataFieldNames(submissions, fields);
756
+ const headers = [...BASE_CSV_HEADERS, ...dataFieldNames];
757
+ const rows = [headers.map(escapeCsvCell).join(",")];
758
+ for (const sub of submissions) {
759
+ const baseValues = getBaseSubmissionValues(sub);
760
+ const dynamicValues = dataFieldNames.map((name) => sub.data?.[name] ?? "");
761
+ rows.push([...baseValues, ...dynamicValues].map(escapeCsvCell).join(","));
762
+ }
763
+ return rows.join("\n");
764
+ }
765
+ function convertIsoDateToCalendar(isoDateString, calendar = "islamic-umalqura", locale = "en") {
766
+ if (!isoDateString) {
767
+ return { calendar, formatted: "" };
768
+ }
769
+ const date = new Date(isoDateString);
770
+ if (Number.isNaN(date.getTime())) {
771
+ return { calendar, formatted: isoDateString };
772
+ }
773
+ try {
774
+ const formatter = new Intl.DateTimeFormat(`${locale}-u-ca-${calendar}`, {
775
+ year: "numeric",
776
+ month: "short",
777
+ day: "numeric"
778
+ });
779
+ const parts = formatter.formatToParts(date);
780
+ const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
781
+ const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
782
+ const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
783
+ return {
784
+ calendar,
785
+ formatted: formatter.format(date),
786
+ year,
787
+ month,
788
+ day
789
+ };
790
+ } catch {
791
+ return {
792
+ calendar,
793
+ formatted: date.toISOString().slice(0, 10)
794
+ };
795
+ }
796
+ }
797
+ let mirroringToFormBuilderCms = false;
798
+ function generateReferenceCode(now = /* @__PURE__ */ new Date()) {
799
+ const datePart = now.toISOString().slice(0, 10).replace(/-/g, "");
800
+ const randPart = node_crypto.randomBytes(3).toString("hex").toUpperCase();
801
+ return `INB-${datePart}-${randPart}`;
802
+ }
803
+ async function mirrorSubmissionToFormBuilderCms(strapi, params) {
804
+ const { settings: settings2, form, cleanedData, ip, userAgent } = params;
805
+ if (settings2.enableFormBuilderBridge === false || !form.formBuilderId || typeof strapi.db?.query !== "function" || !(strapi.contentTypes?.[FORM_BUILDER_CMS_SUBMISSION_UID] || Boolean(
806
+ strapi.__hasFormBuilderCms
807
+ ))) {
808
+ return null;
809
+ }
810
+ try {
811
+ mirroringToFormBuilderCms = true;
812
+ const fieldsSnapshot = (form.fields || []).filter((f) => !DECORATIVE_FIELD_TYPES.includes(f.type)).map((f, idx) => ({
813
+ name: f.name,
814
+ label: f.label,
815
+ type: f.type,
816
+ order: idx,
817
+ options: f.options
818
+ }));
819
+ const cmsSub = await strapi.db.query(FORM_BUILDER_CMS_SUBMISSION_UID).create({
820
+ data: {
821
+ form: form.formBuilderId,
822
+ data: cleanedData,
823
+ fields: fieldsSnapshot,
824
+ ipAddress: ip,
825
+ userAgent,
826
+ status: "new"
827
+ }
828
+ });
829
+ return cmsSub?.id ? Number(cmsSub.id) : null;
830
+ } catch {
831
+ return null;
832
+ } finally {
833
+ mirroringToFormBuilderCms = false;
834
+ }
835
+ }
836
+ function extractFormRelationObject(raw) {
837
+ if (!raw || typeof raw !== "object") return null;
838
+ if (Array.isArray(raw)) {
839
+ return raw.length > 0 ? extractFormRelationObject(raw[0]) : null;
840
+ }
841
+ const obj = raw;
842
+ if (Array.isArray(obj.connect) && obj.connect.length > 0) {
843
+ return extractFormRelationObject(obj.connect[0]);
844
+ }
845
+ if (Array.isArray(obj.set) && obj.set.length > 0) {
846
+ return extractFormRelationObject(obj.set[0]);
847
+ }
848
+ return obj;
849
+ }
850
+ function resolveCmsSubmissionFormRef(cmsSubmission, meta) {
851
+ let formId = meta?.formId ?? null;
852
+ let formSlug = meta?.formSlug ?? "";
853
+ const rawFormRel = cmsSubmission.form;
854
+ if (typeof rawFormRel === "number") {
855
+ formId = rawFormRel;
856
+ } else if (typeof rawFormRel === "string" && /^\d+$/.test(rawFormRel.trim())) {
857
+ formId = Number(rawFormRel.trim());
858
+ } else {
859
+ const relObj = extractFormRelationObject(rawFormRel);
860
+ if (relObj?.id) formId = Number(relObj.id);
861
+ if (relObj?.slug) formSlug = String(relObj.slug);
862
+ }
863
+ return { formId, formSlug };
864
+ }
865
+ async function fetchPopulatedCmsSubmissionFormRef(strapi, cmsSubId) {
866
+ if (cmsSubId === null || typeof strapi.db?.query !== "function") {
867
+ return { formId: null, formSlug: "" };
868
+ }
869
+ try {
870
+ const populated = await strapi.db.query(FORM_BUILDER_CMS_SUBMISSION_UID).findOne({
871
+ where: { id: cmsSubId },
872
+ populate: ["form"]
873
+ });
874
+ return populated ? resolveCmsSubmissionFormRef(populated) : { formId: null, formSlug: "" };
875
+ } catch {
876
+ return { formId: null, formSlug: "" };
877
+ }
878
+ }
879
+ async function resolveTargetInboxForm(strapi, formService2, formId, formSlug) {
880
+ if (formId !== null && typeof strapi.db?.query === "function") {
881
+ const cmsForm = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).findOne({ where: { id: formId } });
882
+ if (cmsForm) {
883
+ const synced = await formService2.syncFormFromBuilderCms(cmsForm);
884
+ if (synced) return synced;
885
+ }
886
+ }
887
+ return formSlug ? formService2.findBySlug(formSlug) : null;
888
+ }
889
+ function firstNonEmptyString(...values) {
890
+ for (const val of values) {
891
+ if (val !== void 0 && val !== null) {
892
+ const str = String(val).trim();
893
+ if (str.length > 0) return str;
894
+ }
895
+ }
896
+ return void 0;
897
+ }
898
+ function extractApplicantDetails(rawData) {
899
+ const firstLast = [rawData.firstName, rawData.lastName].filter(Boolean).join(" ").trim();
900
+ return {
901
+ applicantName: firstNonEmptyString(
902
+ rawData.applicantName,
903
+ rawData.fullName,
904
+ rawData.name,
905
+ firstLast
906
+ ),
907
+ applicantEmail: firstNonEmptyString(rawData.applicantEmail, rawData.email),
908
+ applicantPhone: firstNonEmptyString(
909
+ rawData.applicantPhone,
910
+ rawData.phone,
911
+ rawData.tel
912
+ )
913
+ };
914
+ }
915
+ function extractRawSubmissionData(cmsSubmission) {
916
+ const raw = cmsSubmission.data;
917
+ if (raw && typeof raw === "object") {
918
+ return raw;
919
+ }
920
+ if (typeof raw === "string") {
921
+ try {
922
+ const parsed = JSON.parse(raw);
923
+ if (parsed && typeof parsed === "object")
924
+ return parsed;
925
+ } catch {
926
+ return {};
927
+ }
928
+ }
929
+ return {};
930
+ }
931
+ function parseTimestampValue(raw) {
932
+ if (typeof raw === "number") return new Date(raw);
933
+ if (raw instanceof Date) return raw;
934
+ if (typeof raw === "string") {
935
+ const trimmed = raw.trim();
936
+ if (!trimmed) return null;
937
+ return /^\d+$/.test(trimmed) ? new Date(Number(trimmed)) : new Date(trimmed);
938
+ }
939
+ return null;
940
+ }
941
+ function resolveIngestionTimestamp(cmsSubmission, nowMs) {
942
+ const raw = cmsSubmission.createdAt ?? cmsSubmission.created_at;
943
+ const parsed = parseTimestampValue(raw);
944
+ if (parsed && !Number.isNaN(parsed.getTime())) {
945
+ return parsed.toISOString();
946
+ }
947
+ return new Date(nowMs ?? Date.now()).toISOString();
948
+ }
949
+ function buildIngestedSubmissionPayload(params) {
950
+ const { cmsSubmission, inboxForm, formSlug, cmsSubId, settings: settings2, nowMs } = params;
951
+ const rawData = extractRawSubmissionData(cmsSubmission);
952
+ const applicant = extractApplicantDetails(rawData);
953
+ const locale = firstNonEmptyString(
954
+ rawData.locale,
955
+ settings2.defaultLocale,
956
+ "es"
957
+ );
958
+ const nowIso = resolveIngestionTimestamp(cmsSubmission, nowMs);
959
+ const secondaryCal = firstNonEmptyString(
960
+ settings2.secondaryCalendars?.[0],
961
+ "islamic-umalqura"
962
+ );
963
+ const converted = convertIsoDateToCalendar(
964
+ nowIso.slice(0, 10),
965
+ secondaryCal,
966
+ locale
967
+ );
968
+ const templateItems = inboxForm?.checklistTemplate;
969
+ const checklistItems = (templateItems ? templateItems : []).map((item) => ({
970
+ ...item,
971
+ verified: false
972
+ }));
973
+ const resolvedSlug = firstNonEmptyString(
974
+ inboxForm?.slug,
975
+ formSlug,
976
+ "visual-form"
977
+ );
978
+ return {
979
+ resolvedSlug,
980
+ submissionData: {
981
+ referenceCode: generateReferenceCode(new Date(nowIso)),
982
+ formSlug: resolvedSlug,
983
+ formTitle: firstNonEmptyString(inboxForm?.title, resolvedSlug),
984
+ category: firstNonEmptyString(
985
+ inboxForm?.category,
986
+ "contact"
987
+ ),
988
+ status: "new",
989
+ priority: firstNonEmptyString(
990
+ inboxForm?.settings?.defaultPriority,
991
+ "normal"
992
+ ),
993
+ department: firstNonEmptyString(
994
+ inboxForm?.settings?.department,
995
+ "general"
996
+ ),
997
+ locale,
998
+ ...applicant,
999
+ data: rawData,
1000
+ checklistItems,
1001
+ formBuilderSubmissionId: cmsSubId,
1002
+ ipAddress: firstNonEmptyString(cmsSubmission.ipAddress, "127.0.0.1"),
1003
+ userAgent: firstNonEmptyString(cmsSubmission.userAgent) || "",
1004
+ submittedAt: nowIso,
1005
+ secondaryDate: {
1006
+ formatted: converted.formatted,
1007
+ year: converted.year,
1008
+ month: converted.month,
1009
+ day: converted.day
1010
+ }
1011
+ }
1012
+ };
1013
+ }
1014
+ async function findExistingByCmsSubId(strapi, cmsSubId) {
1015
+ if (cmsSubId === null) return null;
1016
+ const existingSub = await strapi.documents(SUBMISSION_UID).findMany({
1017
+ filters: { formBuilderSubmissionId: cmsSubId },
1018
+ limit: 1
1019
+ });
1020
+ return existingSub && existingSub.length > 0 ? existingSub[0] : null;
1021
+ }
1022
+ async function resolveSubmissionFormContext(strapi, formService2, cmsSubmission, cmsSubId, meta) {
1023
+ let { formId, formSlug } = resolveCmsSubmissionFormRef(cmsSubmission, meta);
1024
+ if (formId === null && !formSlug) {
1025
+ const populatedRef = await fetchPopulatedCmsSubmissionFormRef(
1026
+ strapi,
1027
+ cmsSubId
1028
+ );
1029
+ formId = populatedRef.formId;
1030
+ formSlug = populatedRef.formSlug;
1031
+ }
1032
+ const inboxForm = await resolveTargetInboxForm(
1033
+ strapi,
1034
+ formService2,
1035
+ formId,
1036
+ formSlug
1037
+ );
1038
+ return { formSlug, inboxForm };
1039
+ }
1040
+ async function ingestFormBuilderSubmission(strapi, cmsSubmission, meta) {
1041
+ if (mirroringToFormBuilderCms || !cmsSubmission) return null;
1042
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
1043
+ const formService2 = strapi.plugin(PLUGIN_ID).service("formService");
1044
+ const settings2 = await settingsService.getSettings();
1045
+ if (settings2.enableFormBuilderBridge === false) return null;
1046
+ const cmsSubId = cmsSubmission.id ? Number(cmsSubmission.id) : null;
1047
+ const existing = await findExistingByCmsSubId(strapi, cmsSubId);
1048
+ if (existing) return existing;
1049
+ const { formSlug, inboxForm } = await resolveSubmissionFormContext(
1050
+ strapi,
1051
+ formService2,
1052
+ cmsSubmission,
1053
+ cmsSubId,
1054
+ meta
1055
+ );
1056
+ const { resolvedSlug, submissionData } = buildIngestedSubmissionPayload({
1057
+ cmsSubmission,
1058
+ inboxForm,
1059
+ formSlug,
1060
+ cmsSubId,
1061
+ settings: settings2,
1062
+ nowMs: meta?.nowMs
1063
+ });
1064
+ const created = await strapi.documents(SUBMISSION_UID).create({
1065
+ data: submissionData
1066
+ });
1067
+ strapi.eventHub?.emit("inbox.submission.created", {
1068
+ submission: created,
1069
+ formSlug: resolvedSlug,
1070
+ source: "strapi-plugin-form-builder-cms"
1071
+ });
1072
+ return created;
1073
+ }
1074
+ function canQueryFormBuilderSubmissions(strapi) {
1075
+ return typeof strapi.db?.query === "function" && Boolean(
1076
+ strapi.contentTypes?.[FORM_BUILDER_CMS_SUBMISSION_UID] || strapi.__hasFormBuilderCms
1077
+ );
1078
+ }
1079
+ async function syncSubmissionsFromFormBuilderCms(strapi) {
1080
+ if (mirroringToFormBuilderCms || !canQueryFormBuilderSubmissions(strapi))
1081
+ return 0;
1082
+ try {
1083
+ const cmsSubmissions = await strapi.db.query(FORM_BUILDER_CMS_SUBMISSION_UID).findMany({
1084
+ populate: ["form"],
1085
+ orderBy: { createdAt: "asc" }
1086
+ });
1087
+ let syncedCount = 0;
1088
+ for (const cmsSub of cmsSubmissions || []) {
1089
+ const sub = await ingestFormBuilderSubmission(strapi, cmsSub);
1090
+ if (sub) syncedCount += 1;
1091
+ }
1092
+ return syncedCount;
1093
+ } catch {
1094
+ return 0;
1095
+ }
1096
+ }
1097
+ async function registerPluginPermissions(strapi) {
1098
+ if (!strapi.admin?.services?.permission?.actionProvider?.registerMany) return;
1099
+ await strapi.admin.services.permission.actionProvider.registerMany([
1100
+ {
1101
+ section: "plugins",
1102
+ displayName: "Manage Forms & Builder",
1103
+ uid: "forms.manage",
1104
+ pluginName: PLUGIN_ID
1105
+ },
1106
+ {
1107
+ section: "plugins",
1108
+ displayName: "Manage Submissions & Casework",
1109
+ uid: "submissions.manage",
1110
+ pluginName: PLUGIN_ID
1111
+ },
1112
+ {
1113
+ section: "plugins",
1114
+ displayName: "Manage Subscribers & Alert Banners",
1115
+ uid: "subscribers.manage",
1116
+ pluginName: PLUGIN_ID
1117
+ },
1118
+ {
1119
+ section: "plugins",
1120
+ displayName: "Manage Inbox Settings",
1121
+ uid: "settings.manage",
1122
+ pluginName: PLUGIN_ID
1123
+ }
1124
+ ]);
1125
+ }
1126
+ function mergePresetFieldOptions(existingOptions, presetOptions) {
1127
+ if (!existingOptions) return void 0;
1128
+ return existingOptions.map((eo) => {
1129
+ const po = presetOptions?.find((o) => o.value === eo.value);
1130
+ const localizedLabels = eo.localizedLabels || po?.localizedLabels;
1131
+ return localizedLabels ? { ...eo, localizedLabels } : { ...eo };
1132
+ });
1133
+ }
1134
+ function mergePresetField(ef, presetFields) {
1135
+ const pf = presetFields?.find((f) => f.id === ef.id || f.name === ef.name);
1136
+ if (!pf) return ef;
1137
+ return {
1138
+ ...pf,
1139
+ ...ef,
1140
+ localizedLabels: ef.localizedLabels || pf.localizedLabels,
1141
+ localizedPlaceholders: ef.localizedPlaceholders || pf.localizedPlaceholders,
1142
+ localizedHelpTexts: ef.localizedHelpTexts || pf.localizedHelpTexts,
1143
+ options: mergePresetFieldOptions(ef.options, pf.options)
1144
+ };
1145
+ }
1146
+ function pickFirst(...vals) {
1147
+ for (const v of vals) {
1148
+ if (v !== void 0 && v !== null) return v;
1149
+ }
1150
+ return vals[vals.length - 1];
1151
+ }
1152
+ function pickNonDefault(val, fallback, defaultVal) {
1153
+ if (val && val !== defaultVal) return val;
1154
+ return fallback || defaultVal;
1155
+ }
1156
+ function mergePresetSettings(existingSettings, presetSettings) {
1157
+ return {
1158
+ ...pickFirst(presetSettings, {}),
1159
+ ...pickFirst(existingSettings, {}),
1160
+ department: pickNonDefault(
1161
+ existingSettings?.department,
1162
+ presetSettings?.department,
1163
+ "general"
1164
+ ),
1165
+ defaultPriority: pickNonDefault(
1166
+ existingSettings?.defaultPriority,
1167
+ presetSettings?.defaultPriority,
1168
+ "normal"
1169
+ ),
1170
+ enableBookingBridge: pickFirst(
1171
+ existingSettings?.enableBookingBridge,
1172
+ presetSettings?.enableBookingBridge
1173
+ ),
1174
+ enableCertificateBridge: pickFirst(
1175
+ existingSettings?.enableCertificateBridge,
1176
+ presetSettings?.enableCertificateBridge
1177
+ ),
1178
+ defaultBookingServiceSlug: pickFirst(
1179
+ existingSettings?.defaultBookingServiceSlug,
1180
+ presetSettings?.defaultBookingServiceSlug
1181
+ ),
1182
+ defaultCertificateTemplateId: pickFirst(
1183
+ existingSettings?.defaultCertificateTemplateId,
1184
+ presetSettings?.defaultCertificateTemplateId
1185
+ )
1186
+ };
1187
+ }
1188
+ function needsPresetBackfill(existing, preset) {
1189
+ const missingChecklist = Boolean(preset.checklistTemplate?.length) && !existing.checklistTemplate?.length;
1190
+ const missingTranslations = Boolean(
1191
+ preset.translations && Object.keys(preset.translations).length > 0
1192
+ ) && !Object.keys(existing.translations || {}).length;
1193
+ const missingCategory = Boolean(preset.category && preset.category !== "contact") && existing.category === "contact";
1194
+ return missingChecklist || missingTranslations || missingCategory;
1195
+ }
1196
+ function buildMergedPresetForm(existing, preset) {
1197
+ const baseFields = existing.fields?.length ? existing.fields : pickFirst(preset.fields, []);
1198
+ const mergedFields = baseFields.map(
1199
+ (ef) => mergePresetField(ef, preset.fields)
1200
+ );
1201
+ const checklistTemplate = existing.checklistTemplate?.length ? existing.checklistTemplate : pickFirst(preset.checklistTemplate, []);
1202
+ const translations = Object.keys(existing.translations || {}).length ? existing.translations : pickFirst(preset.translations, {});
1203
+ return {
1204
+ ...preset,
1205
+ ...existing,
1206
+ slug: existing.slug,
1207
+ title: existing.title || preset.title,
1208
+ category: pickNonDefault(existing.category, preset.category, "contact"),
1209
+ fields: mergedFields,
1210
+ checklistTemplate,
1211
+ translations,
1212
+ settings: mergePresetSettings(existing.settings, preset.settings)
1213
+ };
1214
+ }
1215
+ async function seedSinglePresetForm(formService2, preset) {
1216
+ if (!preset?.slug || !preset?.title) return;
1217
+ const existing = await formService2.findBySlug(preset.slug);
1218
+ if (!existing) {
1219
+ await formService2.saveForm(preset);
1220
+ return;
1221
+ }
1222
+ if (needsPresetBackfill(existing, preset)) {
1223
+ await formService2.saveForm(buildMergedPresetForm(existing, preset));
1224
+ }
1225
+ }
1226
+ async function seedPresetForms(strapi, formService2) {
1227
+ try {
1228
+ const rawConfig = strapi.config.get(
1229
+ `plugin::${PLUGIN_ID}`,
1230
+ {}
1231
+ );
1232
+ const presetForms = Array.isArray(rawConfig?.presetForms) ? rawConfig.presetForms : [];
1233
+ for (const preset of presetForms) {
1234
+ await seedSinglePresetForm(formService2, preset);
1235
+ }
1236
+ } catch (err) {
1237
+ strapi.log?.warn?.(
1238
+ `[${PLUGIN_ID}] Could not seed presetForms: ${err.message}`
1239
+ );
1240
+ }
1241
+ }
1242
+ async function preserveCmsFormInboxSettings(strapi, event) {
1243
+ const data = event.params?.data;
1244
+ const where = event.params?.where;
1245
+ if (!data || !where) return;
1246
+ const incomingSettings = data.settings;
1247
+ if (!incomingSettings || incomingSettings.inbox) return;
1248
+ const existing = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).findOne({ where });
1249
+ const prevInbox = existing?.settings?.inbox;
1250
+ if (!prevInbox) return;
1251
+ incomingSettings.inbox = prevInbox;
1252
+ if (data.publishedData && typeof data.publishedData === "object") {
1253
+ const pd = data.publishedData;
1254
+ pd.settings = {
1255
+ ...pd.settings,
1256
+ inbox: prevInbox
1257
+ };
1258
+ }
1259
+ }
1260
+ function extractLifecycleFormId(event) {
1261
+ const dataForm = event.params?.data?.form;
1262
+ const resultForm = event.result?.form;
1263
+ const fromData = dataForm !== void 0 ? resolveCmsSubmissionFormRef({ form: dataForm }).formId : null;
1264
+ if (fromData !== null) return fromData;
1265
+ const fromResult = resultForm !== void 0 ? resolveCmsSubmissionFormRef({ form: resultForm }).formId : null;
1266
+ return fromResult ?? void 0;
1267
+ }
1268
+ function subscribeFormBuilderLifecycles(strapi, formService2, submissionService2) {
1269
+ if (!strapi.db?.lifecycles?.subscribe) return;
1270
+ if (strapi.contentTypes?.[FORM_BUILDER_CMS_FORM_UID]) {
1271
+ strapi.db.lifecycles.subscribe({
1272
+ models: [FORM_BUILDER_CMS_FORM_UID],
1273
+ async beforeUpdate(event) {
1274
+ await preserveCmsFormInboxSettings(strapi, event);
1275
+ },
1276
+ async afterCreate(event) {
1277
+ if (event.result) {
1278
+ await formService2.syncFormFromBuilderCms(event.result);
1279
+ }
1280
+ }
1281
+ });
1282
+ }
1283
+ if (strapi.contentTypes?.[FORM_BUILDER_CMS_SUBMISSION_UID]) {
1284
+ strapi.db.lifecycles.subscribe({
1285
+ models: [FORM_BUILDER_CMS_SUBMISSION_UID],
1286
+ async afterCreate(event) {
1287
+ if (event.result) {
1288
+ await submissionService2.ingestFormBuilderSubmission(event.result, {
1289
+ formId: extractLifecycleFormId(event)
1290
+ });
1291
+ }
1292
+ }
1293
+ });
1294
+ }
1295
+ }
1296
+ const bootstrap = async ({ strapi }) => {
1297
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
1298
+ const formService2 = strapi.plugin(PLUGIN_ID).service("formService");
1299
+ const submissionService2 = strapi.plugin(PLUGIN_ID).service("submissionService");
1300
+ const settings2 = await settingsService.getSettings();
1301
+ await registerPluginPermissions(strapi);
1302
+ await seedPresetForms(strapi, formService2);
1303
+ try {
1304
+ if (settings2.enableFormBuilderBridge !== false && settings2.autoSyncFormBuilderForms !== false) {
1305
+ await formService2.syncWithFormBuilderCms();
1306
+ }
1307
+ subscribeFormBuilderLifecycles(strapi, formService2, submissionService2);
1308
+ } catch (err) {
1309
+ strapi.log?.warn?.(
1310
+ `[${PLUGIN_ID}] Could not initialize Form Builder CMS bridge: ${err.message}`
1311
+ );
1312
+ }
1313
+ };
1314
+ const config = {
1315
+ default: DEFAULT_INBOX_SETTINGS,
1316
+ validator() {
1317
+ }
1318
+ };
1319
+ const kind$2 = "collectionType";
1320
+ const collectionName$2 = "inbox_alert_banners";
1321
+ const info$2 = { "singularName": "alert-banner", "pluralName": "alert-banners", "displayName": "Inbox Alert Banner", "description": "Time-bounded multi-language urgent announcement ticker / banner" };
1322
+ const options$2 = { "draftAndPublish": false };
1323
+ const pluginOptions$2 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
1324
+ const attributes$2 = { "code": { "type": "string", "required": true, "unique": true }, "title": { "type": "string", "required": true }, "message": { "type": "text", "required": true }, "severity": { "type": "enumeration", "enum": ["info", "warning", "urgent", "celebration"], "default": "info", "required": true }, "ctaLabel": { "type": "string" }, "ctaUrl": { "type": "string" }, "dismissible": { "type": "boolean", "default": true }, "active": { "type": "boolean", "default": true }, "priority": { "type": "integer", "default": 10 }, "startsAt": { "type": "datetime" }, "endsAt": { "type": "datetime" }, "translations": { "type": "json", "default": {} } };
1325
+ const schema$2 = {
1326
+ kind: kind$2,
1327
+ collectionName: collectionName$2,
1328
+ info: info$2,
1329
+ options: options$2,
1330
+ pluginOptions: pluginOptions$2,
1331
+ attributes: attributes$2
1332
+ };
1333
+ const alertBanner = {
1334
+ schema: schema$2
1335
+ };
1336
+ const kind$1 = "collectionType";
1337
+ const collectionName$1 = "inbox_submissions";
1338
+ const info$1 = { "singularName": "submission", "pluralName": "submissions", "displayName": "Inbox Submission", "description": "Form submission & Service-Desk Casework ticket" };
1339
+ const options$1 = { "draftAndPublish": false };
1340
+ const pluginOptions$1 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
1341
+ const attributes$1 = { "referenceCode": { "type": "string", "required": true, "unique": true }, "formSlug": { "type": "string", "required": true }, "formTitle": { "type": "string" }, "category": { "type": "enumeration", "enum": ["contact", "service_desk", "inquiry", "application", "volunteer", "custom"], "default": "contact" }, "status": { "type": "enumeration", "enum": ["new", "read", "under_review", "scheduled", "completed", "rejected", "archived"], "default": "new", "required": true }, "priority": { "type": "enumeration", "enum": ["low", "normal", "high", "urgent"], "default": "normal", "required": true }, "department": { "type": "string", "default": "general" }, "locale": { "type": "string", "default": "es" }, "applicantName": { "type": "string" }, "applicantEmail": { "type": "email" }, "applicantPhone": { "type": "string" }, "data": { "type": "json", "required": true, "default": {} }, "checklistItems": { "type": "json", "default": [] }, "staffNotes": { "type": "text" }, "assignedTo": { "type": "string" }, "linkedBookingId": { "type": "string" }, "linkedCertificateNumber": { "type": "string" }, "formBuilderSubmissionId": { "type": "integer" }, "ipAddress": { "type": "string", "private": true }, "userAgent": { "type": "string", "private": true }, "submittedAt": { "type": "datetime", "required": true }, "secondaryDate": { "type": "json" } };
1342
+ const schema$1 = {
1343
+ kind: kind$1,
1344
+ collectionName: collectionName$1,
1345
+ info: info$1,
1346
+ options: options$1,
1347
+ pluginOptions: pluginOptions$1,
1348
+ attributes: attributes$1
1349
+ };
1350
+ const submission = {
1351
+ schema: schema$1
1352
+ };
1353
+ const kind = "collectionType";
1354
+ const collectionName = "inbox_subscribers";
1355
+ const info = { "singularName": "subscriber", "pluralName": "subscribers", "displayName": "Inbox Subscriber", "description": "Double Opt-In newsletter and community topic subscriber" };
1356
+ const options = { "draftAndPublish": false };
1357
+ const pluginOptions = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
1358
+ const attributes = { "email": { "type": "email", "required": true, "unique": true }, "fullName": { "type": "string" }, "phone": { "type": "string" }, "locale": { "type": "string", "default": "es" }, "topics": { "type": "json", "default": ["announcements"] }, "status": { "type": "enumeration", "enum": ["pending", "confirmed", "unsubscribed"], "default": "pending", "required": true }, "confirmToken": { "type": "string", "private": true }, "unsubscribeToken": { "type": "string", "required": true, "private": true }, "confirmedAt": { "type": "datetime" }, "unsubscribedAt": { "type": "datetime" }, "sourceFormSlug": { "type": "string" } };
1359
+ const schema = {
1360
+ kind,
1361
+ collectionName,
1362
+ info,
1363
+ options,
1364
+ pluginOptions,
1365
+ attributes
1366
+ };
1367
+ const subscriber = {
1368
+ schema
1369
+ };
1370
+ const contentTypes = {
1371
+ submission,
1372
+ subscriber,
1373
+ "alert-banner": alertBanner
1374
+ };
1375
+ const createCaseworkHandlers = (strapi) => ({
1376
+ async listSubmissions(ctx) {
1377
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1378
+ const data = await svc.listSubmissions({
1379
+ formSlug: ctx.query?.formSlug,
1380
+ status: ctx.query?.status,
1381
+ priority: ctx.query?.priority,
1382
+ department: ctx.query?.department
1383
+ });
1384
+ ctx.body = { data };
1385
+ },
1386
+ async updateCasework(ctx) {
1387
+ const documentId = ctx.params?.documentId || "";
1388
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1389
+ const data = await svc.updateCasework(
1390
+ documentId,
1391
+ ctx.request.body || {}
1392
+ );
1393
+ ctx.body = { data };
1394
+ },
1395
+ async scheduleBooking(ctx) {
1396
+ const documentId = ctx.params?.documentId || "";
1397
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1398
+ const body = ctx.request.body || {};
1399
+ const data = await svc.scheduleBookingFromSubmission(documentId, body);
1400
+ ctx.body = { data };
1401
+ },
1402
+ async issueCertificate(ctx) {
1403
+ const documentId = ctx.params?.documentId || "";
1404
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1405
+ const body = ctx.request.body || {};
1406
+ const data = await svc.issueCertificateFromSubmission(documentId, body);
1407
+ ctx.body = { data };
1408
+ },
1409
+ async exportCsv(ctx) {
1410
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1411
+ const csv = await svc.exportCsv(ctx.query?.formSlug);
1412
+ ctx.set?.("Content-Type", "text/csv; charset=utf-8");
1413
+ ctx.body = csv;
1414
+ },
1415
+ async testCaptcha(ctx) {
1416
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1417
+ const body = ctx.request.body || {};
1418
+ const data = await svc.testCaptchaSecret(body.provider, body.secret);
1419
+ ctx.body = { data };
1420
+ }
1421
+ });
1422
+ const createCommunityHandlers = (strapi) => ({
1423
+ async subscribe(ctx) {
1424
+ const svc = strapi.plugin(PLUGIN_ID).service("subscriberService");
1425
+ try {
1426
+ const body = ctx.request.body || {};
1427
+ const subscriber2 = await svc.subscribe(body);
1428
+ ctx.status = 201;
1429
+ ctx.body = {
1430
+ data: {
1431
+ email: subscriber2.email,
1432
+ status: subscriber2.status,
1433
+ topics: subscriber2.topics,
1434
+ locale: subscriber2.locale
1435
+ }
1436
+ };
1437
+ } catch (err) {
1438
+ ctx.status = 400;
1439
+ ctx.body = { error: err.message };
1440
+ }
1441
+ },
1442
+ async confirmSubscription(ctx) {
1443
+ const token = ctx.params?.token || ctx.query?.token || (ctx.request.body?.token ?? "");
1444
+ const svc = strapi.plugin(PLUGIN_ID).service("subscriberService");
1445
+ try {
1446
+ const subscriber2 = await svc.confirmSubscription(token);
1447
+ ctx.body = {
1448
+ data: {
1449
+ email: subscriber2.email,
1450
+ status: subscriber2.status,
1451
+ confirmedAt: subscriber2.confirmedAt
1452
+ }
1453
+ };
1454
+ } catch (err) {
1455
+ ctx.status = 404;
1456
+ ctx.body = { error: err.message };
1457
+ }
1458
+ },
1459
+ async unsubscribe(ctx) {
1460
+ const token = ctx.params?.token || ctx.query?.token || (ctx.request.body?.token ?? "");
1461
+ const svc = strapi.plugin(PLUGIN_ID).service("subscriberService");
1462
+ try {
1463
+ const subscriber2 = await svc.unsubscribe(token);
1464
+ ctx.body = {
1465
+ data: {
1466
+ email: subscriber2.email,
1467
+ status: subscriber2.status,
1468
+ unsubscribedAt: subscriber2.unsubscribedAt
1469
+ }
1470
+ };
1471
+ } catch (err) {
1472
+ ctx.status = 404;
1473
+ ctx.body = { error: err.message };
1474
+ }
1475
+ },
1476
+ async listSubscribers(ctx) {
1477
+ const svc = strapi.plugin(PLUGIN_ID).service("subscriberService");
1478
+ const data = await svc.listSubscribers({
1479
+ status: ctx.query?.status,
1480
+ topic: ctx.query?.topic,
1481
+ locale: ctx.query?.locale
1482
+ });
1483
+ ctx.body = { data };
1484
+ },
1485
+ async getActiveAlerts(ctx) {
1486
+ const svc = strapi.plugin(PLUGIN_ID).service("alertService");
1487
+ const data = await svc.getActiveAlerts({
1488
+ locale: ctx.query?.locale,
1489
+ nowIso: ctx.query?.now
1490
+ });
1491
+ ctx.body = { data };
1492
+ },
1493
+ async upsertAlertBanner(ctx) {
1494
+ const svc = strapi.plugin(PLUGIN_ID).service("alertService");
1495
+ const body = ctx.request.body || {};
1496
+ const data = await svc.upsertAlertBanner(body);
1497
+ ctx.body = { data };
1498
+ }
1499
+ });
1500
+ function extractPlainObject(val) {
1501
+ return val && typeof val === "object" && !Array.isArray(val) ? val : null;
1502
+ }
1503
+ function normalizeSubmitPayload(rawBody) {
1504
+ const nestedData = extractPlainObject(rawBody.data) || extractPlainObject(rawBody.payload);
1505
+ return nestedData ? { ...rawBody, ...nestedData } : rawBody;
1506
+ }
1507
+ function resolveClientIp(ctx) {
1508
+ const forwarded = ctx.request.headers?.["x-forwarded-for"]?.split(",")[0]?.trim();
1509
+ return forwarded || ctx.request.ip || "127.0.0.1";
1510
+ }
1511
+ const ERROR_STATUS_MAP = {
1512
+ NotFoundError: 404,
1513
+ TooManyRequestsError: 429,
1514
+ ForbiddenError: 403
1515
+ };
1516
+ const createFormHandlers = (strapi) => ({
1517
+ async getSettings(ctx) {
1518
+ const svc = strapi.plugin(PLUGIN_ID).service("settings");
1519
+ const data = await svc.getSettings();
1520
+ ctx.body = { data };
1521
+ },
1522
+ async updateSettings(ctx) {
1523
+ const svc = strapi.plugin(PLUGIN_ID).service("settings");
1524
+ const data = await svc.updateSettings(
1525
+ ctx.request.body || {}
1526
+ );
1527
+ ctx.body = { data };
1528
+ },
1529
+ async listTemplates(ctx) {
1530
+ ctx.body = { data: STARTER_FORM_TEMPLATES };
1531
+ },
1532
+ async listFormBuilderForms(ctx) {
1533
+ const svc = strapi.plugin(PLUGIN_ID).service("formService");
1534
+ const data = await svc.listFormBuilderForms();
1535
+ ctx.body = { data };
1536
+ },
1537
+ async syncFormBuilder(ctx) {
1538
+ const svc = strapi.plugin(PLUGIN_ID).service("formService");
1539
+ const data = await svc.syncWithFormBuilderCms();
1540
+ ctx.body = { data };
1541
+ },
1542
+ async listForms(ctx) {
1543
+ const svc = strapi.plugin(PLUGIN_ID).service("formService");
1544
+ const data = await svc.listForms({
1545
+ category: ctx.query?.category,
1546
+ activeOnly: ctx.query?.activeOnly !== "false",
1547
+ locale: ctx.query?.locale,
1548
+ publicSafe: ctx.query?.admin !== "true"
1549
+ });
1550
+ ctx.body = { data };
1551
+ },
1552
+ async getFormBySlug(ctx) {
1553
+ const slug = ctx.params?.slug || "";
1554
+ const svc = strapi.plugin(PLUGIN_ID).service("formService");
1555
+ const form = await svc.findBySlug(slug, {
1556
+ locale: ctx.query?.locale,
1557
+ publicSafe: true
1558
+ });
1559
+ if (!form || form.active === false) {
1560
+ ctx.status = 404;
1561
+ ctx.body = { error: `Form "${slug}" not found` };
1562
+ return;
1563
+ }
1564
+ ctx.body = { data: form };
1565
+ },
1566
+ async saveForm(ctx) {
1567
+ const svc = strapi.plugin(PLUGIN_ID).service("formService");
1568
+ const body = ctx.request.body || {};
1569
+ const data = await svc.saveForm(body);
1570
+ ctx.body = { data };
1571
+ },
1572
+ async createFromTemplate(ctx) {
1573
+ const svc = strapi.plugin(PLUGIN_ID).service("formService");
1574
+ const body = ctx.request.body || {};
1575
+ const data = await svc.createFromTemplate(body.templateId, body.overrides);
1576
+ ctx.body = { data };
1577
+ },
1578
+ async submitForm(ctx) {
1579
+ const slug = ctx.params?.slug || "";
1580
+ const svc = strapi.plugin(PLUGIN_ID).service("submissionService");
1581
+ try {
1582
+ const rawBody = ctx.request.body || {};
1583
+ const payload = normalizeSubmitPayload(rawBody);
1584
+ const data = await svc.submitForm(slug, payload, {
1585
+ ip: resolveClientIp(ctx),
1586
+ userAgent: ctx.request.headers?.["user-agent"] || "",
1587
+ locale: rawBody.locale || ctx.query?.locale,
1588
+ captchaToken: rawBody.captchaToken || payload.captchaToken
1589
+ });
1590
+ ctx.status = 201;
1591
+ ctx.body = { data };
1592
+ } catch (err) {
1593
+ const error = err;
1594
+ ctx.status = ERROR_STATUS_MAP[error.name] ?? 400;
1595
+ ctx.body = {
1596
+ error: error.message,
1597
+ details: error.details || {}
1598
+ };
1599
+ }
1600
+ }
1601
+ });
1602
+ const inboxController = ({ strapi }) => ({
1603
+ ...createFormHandlers(strapi),
1604
+ ...createCaseworkHandlers(strapi),
1605
+ ...createCommunityHandlers(strapi)
1606
+ });
1607
+ const controllers = {
1608
+ inboxController
1609
+ };
1610
+ const destroy = ({ strapi }) => {
1611
+ strapi.log.debug("[@vicacha-devs/strapi-plugin-inbox] Destroyed");
1612
+ };
1613
+ const register = ({ strapi }) => {
1614
+ strapi.log.debug(
1615
+ "[@vicacha-devs/strapi-plugin-inbox] Registered inbox & form builder plugin"
1616
+ );
1617
+ };
1618
+ const admin = {
1619
+ type: "admin",
1620
+ routes: [
1621
+ {
1622
+ method: "GET",
1623
+ path: "/settings",
1624
+ handler: "inboxController.getSettings",
1625
+ config: { policies: [] }
1626
+ },
1627
+ {
1628
+ method: "PUT",
1629
+ path: "/settings",
1630
+ handler: "inboxController.updateSettings",
1631
+ config: { policies: [] }
1632
+ },
1633
+ {
1634
+ method: "GET",
1635
+ path: "/templates",
1636
+ handler: "inboxController.listTemplates",
1637
+ config: { policies: [] }
1638
+ },
1639
+ {
1640
+ method: "GET",
1641
+ path: "/form-builder-forms",
1642
+ handler: "inboxController.listFormBuilderForms",
1643
+ config: { policies: [] }
1644
+ },
1645
+ {
1646
+ method: "POST",
1647
+ path: "/forms/sync-builder",
1648
+ handler: "inboxController.syncFormBuilder",
1649
+ config: { policies: [] }
1650
+ },
1651
+ {
1652
+ method: "GET",
1653
+ path: "/forms",
1654
+ handler: "inboxController.listForms",
1655
+ config: { policies: [] }
1656
+ },
1657
+ {
1658
+ method: "POST",
1659
+ path: "/forms",
1660
+ handler: "inboxController.saveForm",
1661
+ config: { policies: [] }
1662
+ },
1663
+ {
1664
+ method: "POST",
1665
+ path: "/forms/from-template",
1666
+ handler: "inboxController.createFromTemplate",
1667
+ config: { policies: [] }
1668
+ },
1669
+ {
1670
+ method: "GET",
1671
+ path: "/submissions",
1672
+ handler: "inboxController.listSubmissions",
1673
+ config: { policies: [] }
1674
+ },
1675
+ {
1676
+ method: "PUT",
1677
+ path: "/submissions/:documentId/casework",
1678
+ handler: "inboxController.updateCasework",
1679
+ config: { policies: [] }
1680
+ },
1681
+ {
1682
+ method: "POST",
1683
+ path: "/submissions/:documentId/schedule-booking",
1684
+ handler: "inboxController.scheduleBooking",
1685
+ config: { policies: [] }
1686
+ },
1687
+ {
1688
+ method: "POST",
1689
+ path: "/submissions/:documentId/issue-certificate",
1690
+ handler: "inboxController.issueCertificate",
1691
+ config: { policies: [] }
1692
+ },
1693
+ {
1694
+ method: "GET",
1695
+ path: "/submissions/export-csv",
1696
+ handler: "inboxController.exportCsv",
1697
+ config: { policies: [] }
1698
+ },
1699
+ {
1700
+ method: "POST",
1701
+ path: "/captcha/test",
1702
+ handler: "inboxController.testCaptcha",
1703
+ config: { policies: [] }
1704
+ },
1705
+ {
1706
+ method: "GET",
1707
+ path: "/subscribers",
1708
+ handler: "inboxController.listSubscribers",
1709
+ config: { policies: [] }
1710
+ },
1711
+ {
1712
+ method: "GET",
1713
+ path: "/alerts",
1714
+ handler: "inboxController.getActiveAlerts",
1715
+ config: { policies: [] }
1716
+ },
1717
+ {
1718
+ method: "POST",
1719
+ path: "/alerts",
1720
+ handler: "inboxController.upsertAlertBanner",
1721
+ config: { policies: [] }
1722
+ }
1723
+ ]
1724
+ };
1725
+ const contentApi = {
1726
+ type: "content-api",
1727
+ routes: [
1728
+ {
1729
+ method: "GET",
1730
+ path: "/forms",
1731
+ handler: "inboxController.listForms",
1732
+ config: {
1733
+ policies: [],
1734
+ auth: false
1735
+ }
1736
+ },
1737
+ {
1738
+ method: "GET",
1739
+ path: "/forms/:slug",
1740
+ handler: "inboxController.getFormBySlug",
1741
+ config: {
1742
+ policies: [],
1743
+ auth: false
1744
+ }
1745
+ },
1746
+ {
1747
+ method: "POST",
1748
+ path: "/forms/:slug/submit",
1749
+ handler: "inboxController.submitForm",
1750
+ config: {
1751
+ policies: [],
1752
+ auth: false
1753
+ }
1754
+ },
1755
+ {
1756
+ method: "POST",
1757
+ path: "/subscribers",
1758
+ handler: "inboxController.subscribe",
1759
+ config: {
1760
+ policies: [],
1761
+ auth: false
1762
+ }
1763
+ },
1764
+ {
1765
+ method: "GET",
1766
+ path: "/subscribers/confirm/:token",
1767
+ handler: "inboxController.confirmSubscription",
1768
+ config: {
1769
+ policies: [],
1770
+ auth: false
1771
+ }
1772
+ },
1773
+ {
1774
+ method: "GET",
1775
+ path: "/subscribers/unsubscribe/:token",
1776
+ handler: "inboxController.unsubscribe",
1777
+ config: {
1778
+ policies: [],
1779
+ auth: false
1780
+ }
1781
+ },
1782
+ {
1783
+ method: "GET",
1784
+ path: "/alerts",
1785
+ handler: "inboxController.getActiveAlerts",
1786
+ config: {
1787
+ policies: [],
1788
+ auth: false
1789
+ }
1790
+ }
1791
+ ]
1792
+ };
1793
+ const routes = {
1794
+ "content-api": contentApi,
1795
+ admin
1796
+ };
1797
+ function getDocuments(strapi, uid) {
1798
+ return strapi.documents(uid);
1799
+ }
1800
+ function buildAlertBannerPayload(input) {
1801
+ return {
1802
+ code: input.code,
1803
+ title: input.title,
1804
+ message: input.message,
1805
+ severity: input.severity ?? "info",
1806
+ ctaLabel: input.ctaLabel ?? "",
1807
+ ctaUrl: input.ctaUrl ?? "",
1808
+ dismissible: input.dismissible ?? true,
1809
+ active: input.active ?? true,
1810
+ priority: input.priority ?? 10,
1811
+ startsAt: input.startsAt ?? null,
1812
+ endsAt: input.endsAt ?? null,
1813
+ translations: input.translations ?? {}
1814
+ };
1815
+ }
1816
+ const alertService = ({ strapi }) => ({
1817
+ async getActiveAlerts(params) {
1818
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
1819
+ const settings2 = await settingsService.getSettings();
1820
+ const locale = params?.locale || settings2.defaultLocale || "es";
1821
+ const nowMs = new Date(params?.nowIso || Date.now()).getTime();
1822
+ const docs = await getDocuments(strapi, ALERT_BANNER_UID).findMany({
1823
+ filters: { active: true },
1824
+ limit: 50
1825
+ });
1826
+ const activeInWindow = docs.filter((banner) => {
1827
+ if (!banner.active) return false;
1828
+ if (banner.startsAt) {
1829
+ const startMs = new Date(banner.startsAt).getTime();
1830
+ if (!Number.isNaN(startMs) && nowMs < startMs) return false;
1831
+ }
1832
+ if (banner.endsAt) {
1833
+ const endMs = new Date(banner.endsAt).getTime();
1834
+ if (!Number.isNaN(endMs) && nowMs > endMs) return false;
1835
+ }
1836
+ return true;
1837
+ });
1838
+ activeInWindow.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
1839
+ const secondaryCal = settings2.secondaryCalendars?.[0] || "islamic-umalqura";
1840
+ const refDateIso = new Date(nowMs).toISOString().slice(0, 10);
1841
+ const secondaryDate = convertIsoDateToCalendar(
1842
+ refDateIso,
1843
+ secondaryCal,
1844
+ locale
1845
+ );
1846
+ return activeInWindow.map((banner) => {
1847
+ const tr = banner.translations?.[locale];
1848
+ return {
1849
+ ...banner,
1850
+ title: tr?.title || banner.title,
1851
+ message: tr?.message || banner.message,
1852
+ ctaLabel: tr?.ctaLabel || banner.ctaLabel,
1853
+ ctaUrl: tr?.ctaUrl || banner.ctaUrl,
1854
+ secondaryDateLabel: secondaryDate.formatted
1855
+ };
1856
+ });
1857
+ },
1858
+ async upsertAlertBanner(input) {
1859
+ const existingDocs = await getDocuments(strapi, ALERT_BANNER_UID).findMany(
1860
+ {
1861
+ filters: { code: input.code },
1862
+ limit: 1
1863
+ }
1864
+ );
1865
+ const existing = existingDocs[0] || null;
1866
+ const payload = buildAlertBannerPayload(input);
1867
+ if (existing?.documentId) {
1868
+ const updated = await getDocuments(strapi, ALERT_BANNER_UID).update({
1869
+ documentId: existing.documentId,
1870
+ data: payload
1871
+ });
1872
+ return updated;
1873
+ }
1874
+ const created = await getDocuments(strapi, ALERT_BANNER_UID).create({
1875
+ data: payload
1876
+ });
1877
+ return created;
1878
+ }
1879
+ });
1880
+ function sanitizePublicForm(form, locale) {
1881
+ const localized = locale ? localizeFormForLocale(form, locale) : { ...form };
1882
+ const safeSettings = { ...localized.settings };
1883
+ delete safeSettings.captchaSecretKey;
1884
+ delete safeSettings.notifyEmails;
1885
+ delete safeSettings.webhookUrl;
1886
+ return {
1887
+ ...localized,
1888
+ settings: safeSettings
1889
+ };
1890
+ }
1891
+ function pick(...values) {
1892
+ for (const v of values) {
1893
+ if (v !== void 0 && v !== null && v !== "") return v;
1894
+ }
1895
+ return values[values.length - 1];
1896
+ }
1897
+ function pickDef(...values) {
1898
+ for (const v of values) {
1899
+ if (v !== void 0 && v !== null) return v;
1900
+ }
1901
+ return values[values.length - 1];
1902
+ }
1903
+ function asObj(val) {
1904
+ return val && typeof val === "object" ? val : {};
1905
+ }
1906
+ function resolveFieldWidth(width) {
1907
+ if (width === "half" || width === "third") return width;
1908
+ return "full";
1909
+ }
1910
+ function mapFieldToCms(f, idx) {
1911
+ const mapped = {
1912
+ id: pick(f.id, `fld_${idx + 1}`),
1913
+ type: f.type === "file" ? "text" : f.type,
1914
+ name: f.name,
1915
+ label: f.label,
1916
+ placeholder: pick(f.placeholder, ""),
1917
+ helpText: pick(f.helpText, ""),
1918
+ defaultValue: f.defaultValue,
1919
+ required: Boolean(f.required),
1920
+ order: idx,
1921
+ width: resolveFieldWidth(f.width),
1922
+ options: (f.options ?? []).map(
1923
+ (o) => o.localizedLabels ? { label: o.label, value: o.value, localizedLabels: o.localizedLabels } : { label: o.label, value: o.value }
1924
+ ),
1925
+ validation: (f.validation ?? []).map(
1926
+ (v) => v.localizedMessages ? {
1927
+ type: v.type,
1928
+ value: v.value,
1929
+ message: v.message,
1930
+ localizedMessages: v.localizedMessages
1931
+ } : { type: v.type, value: v.value, message: v.message }
1932
+ )
1933
+ };
1934
+ if (f.localizedLabels) mapped.localizedLabels = f.localizedLabels;
1935
+ if (f.localizedPlaceholders)
1936
+ mapped.localizedPlaceholders = f.localizedPlaceholders;
1937
+ if (f.localizedHelpTexts) mapped.localizedHelpTexts = f.localizedHelpTexts;
1938
+ return mapped;
1939
+ }
1940
+ function buildInboxMetadata(form, existingInbox) {
1941
+ const fs = form.settings;
1942
+ return {
1943
+ ...existingInbox,
1944
+ category: pick(form.category, existingInbox.category, "contact"),
1945
+ checklistTemplate: pickDef(
1946
+ form.checklistTemplate,
1947
+ existingInbox.checklistTemplate,
1948
+ []
1949
+ ),
1950
+ translations: pickDef(form.translations, existingInbox.translations, {}),
1951
+ active: pickDef(form.active, existingInbox.active, true),
1952
+ submissionCount: pickDef(
1953
+ form.submissionCount,
1954
+ existingInbox.submissionCount,
1955
+ 0
1956
+ ),
1957
+ department: pickDef(fs?.department, existingInbox.department, "general"),
1958
+ defaultPriority: pickDef(
1959
+ fs?.defaultPriority,
1960
+ existingInbox.defaultPriority,
1961
+ "normal"
1962
+ ),
1963
+ honeypotField: pickDef(fs?.honeypotField, existingInbox.honeypotField),
1964
+ webhookUrl: pickDef(fs?.webhookUrl, existingInbox.webhookUrl),
1965
+ enableBookingBridge: pickDef(
1966
+ fs?.enableBookingBridge,
1967
+ existingInbox.enableBookingBridge
1968
+ ),
1969
+ defaultBookingServiceSlug: pickDef(
1970
+ fs?.defaultBookingServiceSlug,
1971
+ existingInbox.defaultBookingServiceSlug
1972
+ ),
1973
+ enableCertificateBridge: pickDef(
1974
+ fs?.enableCertificateBridge,
1975
+ existingInbox.enableCertificateBridge
1976
+ ),
1977
+ defaultCertificateTemplateId: pickDef(
1978
+ fs?.defaultCertificateTemplateId,
1979
+ existingInbox.defaultCertificateTemplateId
1980
+ ),
1981
+ localizedSubmitLabels: pickDef(
1982
+ fs?.localizedSubmitLabels,
1983
+ existingInbox.localizedSubmitLabels
1984
+ ),
1985
+ localizedSuccessMessages: pickDef(
1986
+ fs?.localizedSuccessMessages,
1987
+ existingInbox.localizedSuccessMessages
1988
+ )
1989
+ };
1990
+ }
1991
+ function buildCmsSettings(form, existingSettings, inboxMeta) {
1992
+ const fs = form.settings;
1993
+ const existingCaptcha = asObj(existingSettings.captcha);
1994
+ return {
1995
+ ...existingSettings,
1996
+ submitButtonText: pick(
1997
+ fs?.submitButtonLabel,
1998
+ existingSettings.submitButtonText,
1999
+ "Submit"
2000
+ ),
2001
+ successMessage: pick(
2002
+ fs?.successMessage,
2003
+ existingSettings.successMessage,
2004
+ "Form submitted successfully"
2005
+ ),
2006
+ enableHoneypot: Boolean(pickDef(fs?.honeypotField, true)),
2007
+ enableRateLimit: Boolean(pickDef(fs?.rateLimitPerHour, true)),
2008
+ maxSubmissionsPerHour: pickDef(
2009
+ fs?.rateLimitPerHour,
2010
+ existingSettings.maxSubmissionsPerHour,
2011
+ 60
2012
+ ),
2013
+ notificationEmails: pickDef(
2014
+ fs?.notifyEmails,
2015
+ existingSettings.notificationEmails,
2016
+ []
2017
+ ),
2018
+ redirectUrl: pickDef(
2019
+ fs?.redirectUrl,
2020
+ existingSettings.redirectUrl,
2021
+ ""
2022
+ ),
2023
+ customCss: pickDef(existingSettings.customCss, ""),
2024
+ publicPage: pickDef(existingSettings.publicPage, true),
2025
+ captcha: {
2026
+ provider: pick(
2027
+ fs?.captchaProvider,
2028
+ existingCaptcha.provider,
2029
+ "none"
2030
+ ),
2031
+ siteKey: pickDef(
2032
+ fs?.captchaSiteKey,
2033
+ existingCaptcha.siteKey,
2034
+ ""
2035
+ ),
2036
+ secretKey: pickDef(
2037
+ fs?.captchaSecretKey,
2038
+ existingCaptcha.secretKey,
2039
+ ""
2040
+ )
2041
+ },
2042
+ inbox: inboxMeta
2043
+ };
2044
+ }
2045
+ function toFormBuilderCmsPayload(form, existingCms) {
2046
+ const existingSettings = asObj(existingCms?.settings);
2047
+ const existingInbox = asObj(existingSettings.inbox);
2048
+ const fields = (form.fields ?? []).map(mapFieldToCms);
2049
+ const inboxMeta = buildInboxMetadata(form, existingInbox);
2050
+ const settings2 = buildCmsSettings(form, existingSettings, inboxMeta);
2051
+ const conditionalLogic = Array.isArray(existingCms?.conditionalLogic) ? existingCms.conditionalLogic : [];
2052
+ const publishedAt = form.publishedAt !== void 0 ? form.publishedAt : pickDef(
2053
+ existingCms?.publishedAt,
2054
+ (/* @__PURE__ */ new Date()).toISOString()
2055
+ );
2056
+ const description = pickDef(
2057
+ form.description,
2058
+ existingCms?.description,
2059
+ ""
2060
+ );
2061
+ return {
2062
+ title: form.title,
2063
+ slug: pick(form.formBuilderSlug, form.slug),
2064
+ description,
2065
+ fields,
2066
+ conditionalLogic,
2067
+ settings: settings2,
2068
+ publishedAt,
2069
+ publishedData: {
2070
+ title: form.title,
2071
+ description,
2072
+ fields,
2073
+ conditionalLogic,
2074
+ settings: settings2
2075
+ }
2076
+ };
2077
+ }
2078
+ function mapCmsOption(opt, prevOptions) {
2079
+ if (typeof opt === "string") {
2080
+ const value2 = opt.toLowerCase().replace(/[^a-z0-9]+/g, "_");
2081
+ const prevLoc2 = prevOptions?.find(
2082
+ (po) => po.value === value2
2083
+ )?.localizedLabels;
2084
+ return prevLoc2 ? { label: opt, value: value2, localizedLabels: prevLoc2 } : { label: opt, value: value2 };
2085
+ }
2086
+ const label = String(pick(opt.label, opt.value, ""));
2087
+ const value = String(pick(opt.value, opt.label, ""));
2088
+ const prevLoc = prevOptions?.find(
2089
+ (po) => po.value === value
2090
+ )?.localizedLabels;
2091
+ const optLoc = pickDef(
2092
+ opt.localizedLabels,
2093
+ prevLoc
2094
+ );
2095
+ return optLoc ? { label, value, localizedLabels: optLoc } : { label, value };
2096
+ }
2097
+ function resolveOptionalString(raw, fallback) {
2098
+ return raw !== void 0 ? String(pickDef(raw, "")) : fallback;
2099
+ }
2100
+ function mapCmsFieldToInbox(cf, index, existingFields) {
2101
+ const fieldName = String(pick(cf.name, `field_${index + 1}`));
2102
+ const fieldId = String(pick(cf.id, `fld_${fieldName}`));
2103
+ const prev = existingFields.find(
2104
+ (ef) => ef.id === fieldId || ef.name === fieldName
2105
+ );
2106
+ const rawOpts = Array.isArray(cf.options) ? cf.options : void 0;
2107
+ const mergedOptions = rawOpts?.map((opt) => mapCmsOption(opt, prev?.options));
2108
+ const width = resolveFieldWidth(
2109
+ pick(cf.width, prev?.width)
2110
+ );
2111
+ return {
2112
+ ...prev,
2113
+ id: fieldId,
2114
+ type: pick(cf.type, prev?.type, "text"),
2115
+ name: fieldName,
2116
+ label: String(pick(cf.label, prev?.label, fieldName)),
2117
+ placeholder: resolveOptionalString(cf.placeholder, prev?.placeholder),
2118
+ helpText: resolveOptionalString(cf.helpText, prev?.helpText),
2119
+ defaultValue: pickDef(cf.defaultValue, prev?.defaultValue),
2120
+ required: Boolean(pickDef(cf.required, prev?.required, false)),
2121
+ width,
2122
+ ...mergedOptions ? { options: mergedOptions } : {},
2123
+ validation: Array.isArray(cf.validation) ? cf.validation : prev?.validation,
2124
+ localizedLabels: pick(
2125
+ cf.localizedLabels,
2126
+ prev?.localizedLabels
2127
+ ),
2128
+ localizedPlaceholders: pick(
2129
+ cf.localizedPlaceholders,
2130
+ prev?.localizedPlaceholders
2131
+ ),
2132
+ localizedHelpTexts: pick(
2133
+ cf.localizedHelpTexts,
2134
+ prev?.localizedHelpTexts
2135
+ )
2136
+ };
2137
+ }
2138
+ function buildInboxSettingsFromCms(cmsSettings, inboxMeta, prevSettings) {
2139
+ const cmsCaptcha = asObj(cmsSettings.captcha);
2140
+ const rateLimit = typeof cmsSettings.maxSubmissionsPerHour === "number" ? cmsSettings.maxSubmissionsPerHour : prevSettings.rateLimitPerHour;
2141
+ const notifyEmails = Array.isArray(cmsSettings.notificationEmails) ? cmsSettings.notificationEmails : prevSettings.notifyEmails;
2142
+ return {
2143
+ ...prevSettings,
2144
+ submitButtonLabel: pick(
2145
+ cmsSettings.submitButtonText,
2146
+ prevSettings.submitButtonLabel
2147
+ ),
2148
+ localizedSubmitLabels: pick(
2149
+ inboxMeta.localizedSubmitLabels,
2150
+ prevSettings.localizedSubmitLabels
2151
+ ),
2152
+ successMessage: pick(
2153
+ cmsSettings.successMessage,
2154
+ prevSettings.successMessage
2155
+ ),
2156
+ localizedSuccessMessages: pick(
2157
+ inboxMeta.localizedSuccessMessages,
2158
+ prevSettings.localizedSuccessMessages
2159
+ ),
2160
+ redirectUrl: pickDef(
2161
+ cmsSettings.redirectUrl,
2162
+ prevSettings.redirectUrl
2163
+ ),
2164
+ honeypotField: pickDef(inboxMeta.honeypotField, prevSettings.honeypotField),
2165
+ rateLimitPerHour: rateLimit,
2166
+ notifyEmails,
2167
+ webhookUrl: pickDef(inboxMeta.webhookUrl, prevSettings.webhookUrl),
2168
+ captchaProvider: pick(
2169
+ cmsCaptcha.provider,
2170
+ prevSettings.captchaProvider
2171
+ ),
2172
+ captchaSiteKey: pickDef(
2173
+ cmsCaptcha.siteKey,
2174
+ prevSettings.captchaSiteKey
2175
+ ),
2176
+ captchaSecretKey: pickDef(
2177
+ cmsCaptcha.secretKey,
2178
+ prevSettings.captchaSecretKey
2179
+ ),
2180
+ department: pick(inboxMeta.department, prevSettings.department, "general"),
2181
+ defaultPriority: pick(
2182
+ inboxMeta.defaultPriority,
2183
+ prevSettings.defaultPriority,
2184
+ "normal"
2185
+ ),
2186
+ enableBookingBridge: pickDef(
2187
+ inboxMeta.enableBookingBridge,
2188
+ prevSettings.enableBookingBridge
2189
+ ),
2190
+ defaultBookingServiceSlug: pickDef(
2191
+ inboxMeta.defaultBookingServiceSlug,
2192
+ prevSettings.defaultBookingServiceSlug
2193
+ ),
2194
+ enableCertificateBridge: pickDef(
2195
+ inboxMeta.enableCertificateBridge,
2196
+ prevSettings.enableCertificateBridge
2197
+ ),
2198
+ defaultCertificateTemplateId: pickDef(
2199
+ inboxMeta.defaultCertificateTemplateId,
2200
+ prevSettings.defaultCertificateTemplateId
2201
+ )
2202
+ };
2203
+ }
2204
+ function resolveCmsDocId(cmsForm, existing, cmsId) {
2205
+ if (cmsForm.documentId) return String(cmsForm.documentId);
2206
+ if (existing?.documentId) return existing.documentId;
2207
+ return cmsId ? `cms_doc_${cmsId}` : void 0;
2208
+ }
2209
+ function fromFormBuilderCmsRow(cmsForm, existing) {
2210
+ const cmsSettings = asObj(cmsForm.settings);
2211
+ const publishedSettings = asObj(asObj(cmsForm.publishedData).settings);
2212
+ const inboxMeta = pick(
2213
+ cmsSettings.inbox,
2214
+ publishedSettings.inbox,
2215
+ {}
2216
+ );
2217
+ const rawFields = Array.isArray(cmsForm.fields) ? cmsForm.fields : [];
2218
+ const existingFields = pickDef(existing?.fields, []);
2219
+ const mergedFields = rawFields.map(
2220
+ (cf, index) => mapCmsFieldToInbox(cf, index, existingFields)
2221
+ );
2222
+ const mergedSettings = buildInboxSettingsFromCms(
2223
+ cmsSettings,
2224
+ inboxMeta,
2225
+ pickDef(existing?.settings, {})
2226
+ );
2227
+ const cmsSlug = String(pick(cmsForm.slug, existing?.slug, "form"));
2228
+ const cmsId = cmsForm.id ? Number(cmsForm.id) : pickDef(existing?.formBuilderId, null);
2229
+ const submissionCount = typeof cmsForm.submissionCount === "number" ? cmsForm.submissionCount : pickDef(inboxMeta.submissionCount, existing?.submissionCount, 0);
2230
+ return {
2231
+ documentId: resolveCmsDocId(cmsForm, existing, cmsId),
2232
+ title: String(pick(cmsForm.title, existing?.title, "Untitled Form")),
2233
+ slug: cmsSlug,
2234
+ description: pickDef(
2235
+ resolveOptionalString(cmsForm.description, existing?.description),
2236
+ ""
2237
+ ),
2238
+ category: pick(inboxMeta.category, existing?.category, "contact"),
2239
+ fields: mergedFields.length > 0 ? mergedFields : existingFields,
2240
+ checklistTemplate: pick(
2241
+ inboxMeta.checklistTemplate,
2242
+ existing?.checklistTemplate,
2243
+ []
2244
+ ),
2245
+ settings: mergedSettings,
2246
+ translations: pick(inboxMeta.translations, existing?.translations, {}),
2247
+ active: pickDef(inboxMeta.active, existing?.active, true),
2248
+ submissionCount,
2249
+ formBuilderId: cmsId,
2250
+ formBuilderSlug: cmsSlug,
2251
+ publishedAt: pickDef(
2252
+ cmsForm.publishedAt,
2253
+ existing?.publishedAt,
2254
+ (/* @__PURE__ */ new Date()).toISOString()
2255
+ )
2256
+ };
2257
+ }
2258
+ function isFormBuilderCmsAvailable(strapi) {
2259
+ const hasContentType = Boolean(
2260
+ strapi.contentTypes?.[FORM_BUILDER_CMS_FORM_UID]
2261
+ );
2262
+ const hasDbQuery = typeof strapi.db?.query === "function";
2263
+ return hasDbQuery && (hasContentType || Boolean(
2264
+ strapi.__hasFormBuilderCms
2265
+ ));
2266
+ }
2267
+ async function countSubmissionsForForm(strapi, formId, slug) {
2268
+ let cmsCount;
2269
+ let inboxCount;
2270
+ try {
2271
+ cmsCount = await strapi.db.query(FORM_BUILDER_CMS_SUBMISSION_UID).count({ where: { form: formId } });
2272
+ } catch {
2273
+ cmsCount = 0;
2274
+ }
2275
+ try {
2276
+ const inboxSubs = await strapi.documents(SUBMISSION_UID).findMany({
2277
+ filters: { formSlug: slug },
2278
+ limit: 1e3
2279
+ });
2280
+ inboxCount = Array.isArray(inboxSubs) ? inboxSubs.length : 0;
2281
+ } catch {
2282
+ inboxCount = 0;
2283
+ }
2284
+ return Math.max(cmsCount, inboxCount);
2285
+ }
2286
+ async function listFormBuilderForms(strapi) {
2287
+ if (!isFormBuilderCmsAvailable(strapi)) {
2288
+ return { available: false, forms: [] };
2289
+ }
2290
+ const cmsForms = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).findMany({ orderBy: { createdAt: "desc" } });
2291
+ const summaries = await Promise.all(
2292
+ (cmsForms || []).map(async (cf) => {
2293
+ const parsed = fromFormBuilderCmsRow(cf);
2294
+ const id = Number(cf.id);
2295
+ const slug = parsed.slug;
2296
+ const submissionCount = await countSubmissionsForForm(strapi, id, slug);
2297
+ return {
2298
+ id,
2299
+ documentId: parsed.documentId,
2300
+ slug,
2301
+ title: parsed.title,
2302
+ description: parsed.description || "",
2303
+ fieldCount: parsed.fields.length,
2304
+ submissionCount: Math.max(submissionCount, parsed.submissionCount || 0),
2305
+ publishedAt: parsed.publishedAt || null,
2306
+ linkedInboxSlug: slug,
2307
+ linkedCategory: parsed.category
2308
+ };
2309
+ })
2310
+ );
2311
+ return { available: true, forms: summaries };
2312
+ }
2313
+ async function syncFormToBuilderCms(strapi, form) {
2314
+ if (!isFormBuilderCmsAvailable(strapi)) return null;
2315
+ const query = strapi.db.query(FORM_BUILDER_CMS_FORM_UID);
2316
+ let existingCms = null;
2317
+ if (form.formBuilderId) {
2318
+ existingCms = await query.findOne({ where: { id: form.formBuilderId } });
2319
+ }
2320
+ if (!existingCms && form.formBuilderSlug) {
2321
+ existingCms = await query.findOne({
2322
+ where: { slug: form.formBuilderSlug }
2323
+ });
2324
+ }
2325
+ if (!existingCms) {
2326
+ existingCms = await query.findOne({ where: { slug: form.slug } });
2327
+ }
2328
+ const payload = toFormBuilderCmsPayload(form, existingCms);
2329
+ let savedCms;
2330
+ if (existingCms?.id) {
2331
+ savedCms = await query.update({
2332
+ where: { id: existingCms.id },
2333
+ data: {
2334
+ ...payload,
2335
+ slug: existingCms.slug || payload.slug
2336
+ }
2337
+ });
2338
+ } else {
2339
+ savedCms = await query.create({
2340
+ data: payload
2341
+ });
2342
+ }
2343
+ return {
2344
+ formBuilderId: Number(savedCms.id),
2345
+ formBuilderSlug: String(savedCms.slug || form.slug)
2346
+ };
2347
+ }
2348
+ function shouldBackfillStarterTemplate(parsed, starterTpl) {
2349
+ if (!starterTpl) return false;
2350
+ const hasTranslations = Object.keys(parsed.translations || {}).length > 0;
2351
+ if (hasTranslations) return false;
2352
+ return !parsed.checklistTemplate?.length || !starterTpl.checklistTemplate?.length;
2353
+ }
2354
+ function pickNonDefaultSetting(val, fallback, defaultVal) {
2355
+ if (val && val !== defaultVal) return val;
2356
+ return fallback || defaultVal;
2357
+ }
2358
+ function mergeStarterSettings(parsedSettings, starterSettings) {
2359
+ const baseStarter = starterSettings ?? {};
2360
+ const baseParsed = parsedSettings ?? {};
2361
+ return {
2362
+ ...baseStarter,
2363
+ ...baseParsed,
2364
+ department: pickNonDefaultSetting(
2365
+ baseParsed.department,
2366
+ baseStarter.department,
2367
+ "general"
2368
+ ),
2369
+ defaultPriority: pickNonDefaultSetting(
2370
+ baseParsed.defaultPriority,
2371
+ baseStarter.defaultPriority,
2372
+ "normal"
2373
+ ),
2374
+ enableBookingBridge: baseParsed.enableBookingBridge ?? baseStarter.enableBookingBridge,
2375
+ enableCertificateBridge: baseParsed.enableCertificateBridge ?? baseStarter.enableCertificateBridge
2376
+ };
2377
+ }
2378
+ function enrichFormWithStarterTemplate(parsed, starterTpl) {
2379
+ if (!starterTpl) return parsed;
2380
+ const category = parsed.category && parsed.category !== "contact" ? parsed.category : starterTpl.category;
2381
+ const checklistTemplate = parsed.checklistTemplate?.length ? parsed.checklistTemplate : starterTpl.checklistTemplate || [];
2382
+ const translations = Object.keys(parsed.translations || {}).length ? parsed.translations : starterTpl.translations || {};
2383
+ return {
2384
+ ...parsed,
2385
+ category,
2386
+ checklistTemplate,
2387
+ translations,
2388
+ settings: mergeStarterSettings(parsed.settings, starterTpl.settings)
2389
+ };
2390
+ }
2391
+ async function syncFormFromBuilderCms(strapi, cmsForm) {
2392
+ if (!cmsForm) return null;
2393
+ const parsed = fromFormBuilderCmsRow(cmsForm);
2394
+ const rawSettings = cmsForm.settings || {};
2395
+ const starterTpl = STARTER_FORM_TEMPLATES.find(
2396
+ (t) => t.form.slug === parsed.slug || t.id === parsed.slug
2397
+ )?.form;
2398
+ const needsBackfill = !rawSettings.inbox || shouldBackfillStarterTemplate(parsed, starterTpl);
2399
+ if (needsBackfill && cmsForm.id && isFormBuilderCmsAvailable(strapi)) {
2400
+ const enriched = enrichFormWithStarterTemplate(parsed, starterTpl);
2401
+ const payload = toFormBuilderCmsPayload(enriched, cmsForm);
2402
+ const updated = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).update({
2403
+ where: { id: cmsForm.id },
2404
+ data: {
2405
+ settings: payload.settings,
2406
+ publishedData: payload.publishedData
2407
+ }
2408
+ });
2409
+ return fromFormBuilderCmsRow(updated || cmsForm, enriched);
2410
+ }
2411
+ return parsed;
2412
+ }
2413
+ function pickVal(...values) {
2414
+ for (const v of values) {
2415
+ if (v !== void 0 && v !== null && v !== "") return v;
2416
+ }
2417
+ return values[values.length - 1];
2418
+ }
2419
+ function pickDefVal(...values) {
2420
+ for (const v of values) {
2421
+ if (v !== void 0 && v !== null) return v;
2422
+ }
2423
+ return values[values.length - 1];
2424
+ }
2425
+ async function findExistingCmsFormRow(query, input) {
2426
+ if (input.formBuilderId) {
2427
+ const byId = await query.findOne({ where: { id: input.formBuilderId } });
2428
+ if (byId) return byId;
2429
+ }
2430
+ if (input.slug) {
2431
+ const bySlug = await query.findOne({ where: { slug: input.slug } });
2432
+ if (bySlug) return bySlug;
2433
+ }
2434
+ return null;
2435
+ }
2436
+ function buildMergedFormRecord(input, existing) {
2437
+ const publishedAt = input.publishedAt !== void 0 ? input.publishedAt : pickDefVal(existing?.publishedAt, (/* @__PURE__ */ new Date()).toISOString());
2438
+ return {
2439
+ documentId: existing?.documentId,
2440
+ title: pickVal(input.title, existing?.title, "Untitled Form"),
2441
+ slug: pickVal(input.slug, existing?.slug, "form"),
2442
+ description: pickDefVal(input.description, existing?.description, ""),
2443
+ category: pickVal(input.category, existing?.category, "contact"),
2444
+ fields: pickVal(input.fields, existing?.fields, []),
2445
+ checklistTemplate: pickDefVal(
2446
+ input.checklistTemplate,
2447
+ existing?.checklistTemplate,
2448
+ []
2449
+ ),
2450
+ settings: {
2451
+ ...pickDefVal(existing?.settings, {}),
2452
+ ...pickDefVal(input.settings, {})
2453
+ },
2454
+ translations: pickDefVal(input.translations, existing?.translations, {}),
2455
+ active: pickDefVal(input.active, existing?.active, true),
2456
+ submissionCount: pickDefVal(
2457
+ input.submissionCount,
2458
+ existing?.submissionCount,
2459
+ 0
2460
+ ),
2461
+ formBuilderId: pickDefVal(
2462
+ input.formBuilderId,
2463
+ existing?.formBuilderId,
2464
+ null
2465
+ ),
2466
+ formBuilderSlug: pickDefVal(
2467
+ input.formBuilderSlug,
2468
+ existing?.formBuilderSlug,
2469
+ input.slug
2470
+ ),
2471
+ publishedAt
2472
+ };
2473
+ }
2474
+ const formService = ({ strapi }) => ({
2475
+ isFormBuilderCmsAvailable() {
2476
+ return isFormBuilderCmsAvailable(strapi);
2477
+ },
2478
+ async listFormBuilderForms() {
2479
+ return listFormBuilderForms(strapi);
2480
+ },
2481
+ async syncFormToBuilderCms(form) {
2482
+ return syncFormToBuilderCms(strapi, form);
2483
+ },
2484
+ async syncFormFromBuilderCms(cmsForm) {
2485
+ return syncFormFromBuilderCms(strapi, cmsForm);
2486
+ },
2487
+ async syncWithFormBuilderCms() {
2488
+ if (!this.isFormBuilderCmsAvailable()) {
2489
+ return {
2490
+ available: false,
2491
+ syncedToBuilder: 0,
2492
+ syncedFromBuilder: 0,
2493
+ forms: []
2494
+ };
2495
+ }
2496
+ const cmsForms = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).findMany({ orderBy: { createdAt: "desc" } });
2497
+ let syncedFromBuilder = 0;
2498
+ for (const cmsForm of cmsForms || []) {
2499
+ const synced = await this.syncFormFromBuilderCms(cmsForm);
2500
+ if (synced) syncedFromBuilder += 1;
2501
+ }
2502
+ await syncSubmissionsFromFormBuilderCms(strapi);
2503
+ const forms = await this.listForms({ activeOnly: false });
2504
+ return {
2505
+ available: true,
2506
+ syncedToBuilder: 0,
2507
+ syncedFromBuilder,
2508
+ forms
2509
+ };
2510
+ },
2511
+ async listForms(params) {
2512
+ if (!this.isFormBuilderCmsAvailable()) return [];
2513
+ const cmsRows = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).findMany({ orderBy: { createdAt: "desc" } });
2514
+ const allForms = await Promise.all(
2515
+ (cmsRows || []).map(async (row) => {
2516
+ const parsed = fromFormBuilderCmsRow(row);
2517
+ const count = await countSubmissionsForForm(
2518
+ strapi,
2519
+ Number(row.id),
2520
+ parsed.slug
2521
+ );
2522
+ return {
2523
+ ...parsed,
2524
+ submissionCount: Math.max(count, parsed.submissionCount || 0)
2525
+ };
2526
+ })
2527
+ );
2528
+ const filtered = allForms.filter((doc) => {
2529
+ if (params?.category && doc.category !== params.category) return false;
2530
+ if (params?.activeOnly !== false && doc.active === false) return false;
2531
+ return true;
2532
+ });
2533
+ return filtered.map(
2534
+ (doc) => params?.publicSafe ? sanitizePublicForm(doc, params?.locale) : params?.locale ? localizeFormForLocale(doc, params.locale) : doc
2535
+ );
2536
+ },
2537
+ async findBySlug(slug, options2) {
2538
+ if (!this.isFormBuilderCmsAvailable()) return null;
2539
+ const cmsRow = await strapi.db.query(FORM_BUILDER_CMS_FORM_UID).findOne({ where: { slug } });
2540
+ if (!cmsRow) return null;
2541
+ const form = fromFormBuilderCmsRow(cmsRow);
2542
+ const count = await countSubmissionsForForm(
2543
+ strapi,
2544
+ Number(cmsRow.id),
2545
+ form.slug
2546
+ );
2547
+ form.submissionCount = Math.max(count, form.submissionCount || 0);
2548
+ if (options2?.publicSafe) {
2549
+ return sanitizePublicForm(form, options2?.locale);
2550
+ }
2551
+ if (options2?.locale) {
2552
+ return localizeFormForLocale(form, options2.locale);
2553
+ }
2554
+ return form;
2555
+ },
2556
+ async saveForm(input) {
2557
+ const query = strapi.db.query(FORM_BUILDER_CMS_FORM_UID);
2558
+ const existingRow = await findExistingCmsFormRow(query, input);
2559
+ const existing = existingRow ? fromFormBuilderCmsRow(existingRow) : null;
2560
+ const mergedForm = buildMergedFormRecord(input, existing);
2561
+ const payload = toFormBuilderCmsPayload(mergedForm, existingRow);
2562
+ const savedRow = existingRow?.id ? await query.update({
2563
+ where: { id: existingRow.id },
2564
+ data: {
2565
+ ...payload,
2566
+ slug: existingRow.slug || payload.slug
2567
+ }
2568
+ }) : await query.create({ data: payload });
2569
+ return fromFormBuilderCmsRow(savedRow, mergedForm);
2570
+ },
2571
+ async createFromTemplate(templateId, overrides) {
2572
+ const tpl = STARTER_FORM_TEMPLATES.find((t) => t.id === templateId);
2573
+ if (!tpl) {
2574
+ throw new Error(`Starter template "${templateId}" not found`);
2575
+ }
2576
+ return this.saveForm({
2577
+ ...tpl.form,
2578
+ ...overrides,
2579
+ slug: overrides?.slug || tpl.form.slug,
2580
+ title: overrides?.title || tpl.form.title
2581
+ });
2582
+ }
2583
+ });
2584
+ const settings = ({ strapi }) => ({
2585
+ async getSettings() {
2586
+ const pluginStore = strapi.store({
2587
+ type: "plugin",
2588
+ name: PLUGIN_ID
2589
+ });
2590
+ const configDefaults = strapi.config.get(
2591
+ `plugin::${PLUGIN_ID}`,
2592
+ {}
2593
+ );
2594
+ const stored = await pluginStore.get({
2595
+ key: "settings"
2596
+ });
2597
+ return {
2598
+ ...DEFAULT_INBOX_SETTINGS,
2599
+ ...configDefaults,
2600
+ ...stored
2601
+ };
2602
+ },
2603
+ async updateSettings(patch) {
2604
+ const current = await this.getSettings();
2605
+ const updated = {
2606
+ ...current,
2607
+ ...patch
2608
+ };
2609
+ const pluginStore = strapi.store({
2610
+ type: "plugin",
2611
+ name: PLUGIN_ID
2612
+ });
2613
+ await pluginStore.set({
2614
+ key: "settings",
2615
+ value: updated
2616
+ });
2617
+ return updated;
2618
+ }
2619
+ });
2620
+ async function tryCreatePluginBooking(strapi, existing, bookingInput, fallbackId) {
2621
+ try {
2622
+ const bookingsPlugin = strapi.plugin("bookings");
2623
+ const bookingSvc = bookingsPlugin?.service("bookingService");
2624
+ if (!bookingSvc?.createBooking) return fallbackId;
2625
+ const createdBooking = await bookingSvc.createBooking({
2626
+ serviceSlug: bookingInput.serviceSlug,
2627
+ date: bookingInput.date,
2628
+ startTime: bookingInput.startTime,
2629
+ endTime: bookingInput.endTime ?? "11:00",
2630
+ requesterName: existing.applicantName ?? "Applicant",
2631
+ requesterEmail: existing.applicantEmail ?? "noreply@example.org",
2632
+ requesterPhone: existing.applicantPhone ?? "",
2633
+ notes: bookingInput.notes ?? `Casework Ref: ${existing.referenceCode}`
2634
+ });
2635
+ return createdBooking?.referenceCode || createdBooking?.documentId || fallbackId;
2636
+ } catch {
2637
+ return fallbackId;
2638
+ }
2639
+ }
2640
+ async function scheduleBookingFromSubmission(strapi, documentId, bookingInput) {
2641
+ const existing = await getDocuments(strapi, SUBMISSION_UID).findOne({
2642
+ documentId
2643
+ });
2644
+ if (!existing) {
2645
+ throw new Error(`Submission "${documentId}" not found`);
2646
+ }
2647
+ const bookingId = await tryCreatePluginBooking(
2648
+ strapi,
2649
+ existing,
2650
+ bookingInput,
2651
+ `BKG-${existing.referenceCode}`
2652
+ );
2653
+ const updated = await getDocuments(strapi, SUBMISSION_UID).update({
2654
+ documentId,
2655
+ data: {
2656
+ status: "scheduled",
2657
+ linkedBookingId: bookingId
2658
+ }
2659
+ });
2660
+ return { submission: updated, bookingId };
2661
+ }
2662
+ function assertRequiredChecklistVerified(existing, requireVerified) {
2663
+ if (requireVerified === false) return;
2664
+ const unverifiedRequired = (existing.checklistItems || []).filter(
2665
+ (item) => item.required && !item.verified
2666
+ );
2667
+ if (unverifiedRequired.length > 0) {
2668
+ throw new Error(
2669
+ `Cannot issue certificate: ${unverifiedRequired.length} required document(s) are not yet verified`
2670
+ );
2671
+ }
2672
+ }
2673
+ async function tryIssuePluginCertificate(strapi, existing, certInput, fallbackCertNumber) {
2674
+ try {
2675
+ const certsPlugin = strapi.plugin("certificates");
2676
+ const certSvc = certsPlugin?.service("certificateService");
2677
+ if (!certSvc?.issueCertificate) return fallbackCertNumber;
2678
+ const issued = await certSvc.issueCertificate({
2679
+ templateSlug: certInput?.templateSlug || "official-service-certificate",
2680
+ recipientName: certInput?.recipientName || existing.applicantName || "Applicant",
2681
+ recipientEmail: existing.applicantEmail,
2682
+ metadata: {
2683
+ submissionReference: existing.referenceCode,
2684
+ formSlug: existing.formSlug
2685
+ }
2686
+ });
2687
+ return issued?.certificateNumber || fallbackCertNumber;
2688
+ } catch {
2689
+ return fallbackCertNumber;
2690
+ }
2691
+ }
2692
+ async function issueCertificateFromSubmission(strapi, documentId, certInput) {
2693
+ const existing = await getDocuments(strapi, SUBMISSION_UID).findOne({
2694
+ documentId
2695
+ });
2696
+ if (!existing) {
2697
+ throw new Error(`Submission "${documentId}" not found`);
2698
+ }
2699
+ assertRequiredChecklistVerified(
2700
+ existing,
2701
+ certInput?.requireChecklistVerified
2702
+ );
2703
+ const certificateNumber = await tryIssuePluginCertificate(
2704
+ strapi,
2705
+ existing,
2706
+ certInput,
2707
+ `CERT-${existing.referenceCode}`
2708
+ );
2709
+ const updated = await getDocuments(strapi, SUBMISSION_UID).update({
2710
+ documentId,
2711
+ data: {
2712
+ status: "completed",
2713
+ linkedCertificateNumber: certificateNumber
2714
+ }
2715
+ });
2716
+ return { submission: updated, certificateNumber };
2717
+ }
2718
+ async function exportSubmissionsCsv(strapi, submissions, formSlug) {
2719
+ let fields = [];
2720
+ if (formSlug) {
2721
+ const formService2 = strapi.plugin(PLUGIN_ID).service("formService");
2722
+ const form = await formService2.findBySlug(formSlug);
2723
+ fields = form?.fields || [];
2724
+ }
2725
+ return submissionsToCsv(submissions, fields);
2726
+ }
2727
+ function firstStr(...vals) {
2728
+ for (const val of vals) {
2729
+ if (typeof val === "string" && val.trim().length > 0) return val.trim();
2730
+ }
2731
+ return "";
2732
+ }
2733
+ function enforceHoneypotAndRateLimit(form, settings2, payload, ip, nowMs) {
2734
+ const honeypotField = firstStr(form.settings?.honeypotField, settings2.defaultHoneypotField) || "_website_url";
2735
+ if (isHoneypotTriggered(payload, honeypotField)) {
2736
+ const err = new Error("Bot submission rejected by honeypot");
2737
+ err.name = "ForbiddenError";
2738
+ throw err;
2739
+ }
2740
+ const rateLimit = form.settings?.rateLimitPerHour ?? settings2.defaultRateLimitPerHour ?? 15;
2741
+ const limitCheck = defaultRateLimiter.check(ip, form.slug, rateLimit, nowMs);
2742
+ if (!limitCheck.allowed) {
2743
+ const err = new Error(
2744
+ `Rate limit exceeded. Try again in ${limitCheck.retryAfterSeconds}s`
2745
+ );
2746
+ err.name = "TooManyRequestsError";
2747
+ throw err;
2748
+ }
2749
+ }
2750
+ async function enforceCaptchaVerification(form, settings2, payload, ip, meta) {
2751
+ const captchaProvider = form.settings?.captchaProvider || settings2.defaultCaptchaProvider || "none";
2752
+ if (captchaProvider === "none") return;
2753
+ const providerSecret = captchaProvider === "turnstile" ? settings2.turnstileSecretKey : settings2.recaptchaSecretKey;
2754
+ const secret = firstStr(form.settings?.captchaSecretKey, providerSecret);
2755
+ const token = firstStr(
2756
+ meta?.captchaToken,
2757
+ payload.captchaToken,
2758
+ payload["cf-turnstile-response"],
2759
+ payload["g-recaptcha-response"]
2760
+ );
2761
+ const verified = await verifyCaptchaToken(
2762
+ captchaProvider,
2763
+ secret,
2764
+ token,
2765
+ ip,
2766
+ meta?.fetchImpl
2767
+ );
2768
+ if (!verified) {
2769
+ const err = new Error("CAPTCHA verification failed");
2770
+ err.name = "ValidationError";
2771
+ throw err;
2772
+ }
2773
+ }
2774
+ function validateOrThrow(form, payload, locale) {
2775
+ const validation = validateFormSubmission(form.fields || [], payload, locale);
2776
+ if (!validation.valid) {
2777
+ const firstField = Object.keys(validation.errors)[0];
2778
+ const firstError = validation.errors[firstField]?.[0] || "Validation failed";
2779
+ const err = new Error(firstError);
2780
+ err.name = "ValidationError";
2781
+ err.details = validation.errors;
2782
+ throw err;
2783
+ }
2784
+ return validation.cleanedData;
2785
+ }
2786
+ function buildSubmissionDataRecord(params) {
2787
+ const {
2788
+ form,
2789
+ settings: settings2,
2790
+ cleaned,
2791
+ payload,
2792
+ locale,
2793
+ ip,
2794
+ userAgent,
2795
+ nowMs,
2796
+ mirroredCmsSubmissionId
2797
+ } = params;
2798
+ const applicantName = firstStr(
2799
+ cleaned.applicantName,
2800
+ cleaned.fullName,
2801
+ cleaned.name,
2802
+ payload.applicantName
2803
+ );
2804
+ const applicantEmail = firstStr(
2805
+ cleaned.applicantEmail,
2806
+ cleaned.email,
2807
+ payload.applicantEmail
2808
+ );
2809
+ const applicantPhone = firstStr(
2810
+ cleaned.applicantPhone,
2811
+ cleaned.phone,
2812
+ payload.applicantPhone
2813
+ );
2814
+ const timestamp = nowMs ?? Date.now();
2815
+ const nowIso = new Date(timestamp).toISOString();
2816
+ const secondaryCal = settings2.secondaryCalendars?.[0] ?? "islamic-umalqura";
2817
+ const converted = convertIsoDateToCalendar(
2818
+ nowIso.slice(0, 10),
2819
+ secondaryCal,
2820
+ locale
2821
+ );
2822
+ const checklistItems = (form.checklistTemplate ?? []).map(
2823
+ (item) => ({
2824
+ ...item,
2825
+ verified: false
2826
+ })
2827
+ );
2828
+ return {
2829
+ referenceCode: generateReferenceCode(new Date(timestamp)),
2830
+ formSlug: form.slug,
2831
+ formTitle: form.title,
2832
+ category: form.category ?? "contact",
2833
+ status: "new",
2834
+ priority: form.settings?.defaultPriority ?? "normal",
2835
+ department: form.settings?.department ?? "general",
2836
+ locale,
2837
+ applicantName: applicantName || void 0,
2838
+ applicantEmail: applicantEmail || void 0,
2839
+ applicantPhone: applicantPhone || void 0,
2840
+ data: cleaned,
2841
+ checklistItems,
2842
+ formBuilderSubmissionId: mirroredCmsSubmissionId,
2843
+ ipAddress: ip,
2844
+ userAgent,
2845
+ submittedAt: nowIso,
2846
+ secondaryDate: {
2847
+ formatted: converted.formatted,
2848
+ year: converted.year,
2849
+ month: converted.month,
2850
+ day: converted.day
2851
+ }
2852
+ };
2853
+ }
2854
+ const submissionService = ({ strapi }) => ({
2855
+ async testCaptchaSecret(provider, secret, fetchImpl) {
2856
+ return testCaptchaSecretKey(provider, secret, fetchImpl);
2857
+ },
2858
+ async submitForm(slug, payload, meta) {
2859
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
2860
+ const formService2 = strapi.plugin(PLUGIN_ID).service("formService");
2861
+ const settings2 = await settingsService.getSettings();
2862
+ const form = await formService2.findBySlug(slug);
2863
+ if (!form || form.active === false) {
2864
+ const err = new Error(`Active form "${slug}" not found`);
2865
+ err.name = "NotFoundError";
2866
+ throw err;
2867
+ }
2868
+ const locale = firstStr(meta?.locale, payload.locale, settings2.defaultLocale) || "es";
2869
+ const ip = firstStr(meta?.ip) || "127.0.0.1";
2870
+ const userAgent = firstStr(meta?.userAgent);
2871
+ enforceHoneypotAndRateLimit(form, settings2, payload, ip, meta?.nowMs);
2872
+ await enforceCaptchaVerification(form, settings2, payload, ip, meta);
2873
+ const cleaned = validateOrThrow(form, payload, locale);
2874
+ const mirroredCmsSubmissionId = await mirrorSubmissionToFormBuilderCms(
2875
+ strapi,
2876
+ {
2877
+ settings: settings2,
2878
+ form,
2879
+ cleanedData: cleaned,
2880
+ ip,
2881
+ userAgent
2882
+ }
2883
+ );
2884
+ const submissionData = buildSubmissionDataRecord({
2885
+ form,
2886
+ settings: settings2,
2887
+ cleaned,
2888
+ payload,
2889
+ locale,
2890
+ ip,
2891
+ userAgent,
2892
+ nowMs: meta?.nowMs,
2893
+ mirroredCmsSubmissionId
2894
+ });
2895
+ const created = await getDocuments(strapi, SUBMISSION_UID).create({
2896
+ data: submissionData
2897
+ });
2898
+ strapi.eventHub?.emit("inbox.submission.created", {
2899
+ submission: created,
2900
+ formSlug: form.slug
2901
+ });
2902
+ return created;
2903
+ },
2904
+ async ingestFormBuilderSubmission(cmsSubmission, meta) {
2905
+ return ingestFormBuilderSubmission(strapi, cmsSubmission, meta);
2906
+ },
2907
+ async listSubmissions(filters) {
2908
+ await syncSubmissionsFromFormBuilderCms(strapi);
2909
+ const queryFilters = {};
2910
+ if (filters?.formSlug) queryFilters.formSlug = filters.formSlug;
2911
+ if (filters?.status) queryFilters.status = filters.status;
2912
+ if (filters?.priority) queryFilters.priority = filters.priority;
2913
+ if (filters?.department) queryFilters.department = filters.department;
2914
+ const docs = await getDocuments(strapi, SUBMISSION_UID).findMany({
2915
+ filters: queryFilters,
2916
+ limit: filters?.limit || 100
2917
+ });
2918
+ return docs;
2919
+ },
2920
+ async updateCasework(documentId, patch) {
2921
+ const existing = await getDocuments(strapi, SUBMISSION_UID).findOne({
2922
+ documentId
2923
+ });
2924
+ if (!existing) {
2925
+ throw new Error(`Submission "${documentId}" not found`);
2926
+ }
2927
+ const updated = await getDocuments(strapi, SUBMISSION_UID).update({
2928
+ documentId,
2929
+ data: {
2930
+ ...patch.status ? { status: patch.status } : {},
2931
+ ...patch.priority ? { priority: patch.priority } : {},
2932
+ ...patch.department !== void 0 ? { department: patch.department } : {},
2933
+ ...patch.staffNotes !== void 0 ? { staffNotes: patch.staffNotes } : {},
2934
+ ...patch.assignedTo !== void 0 ? { assignedTo: patch.assignedTo } : {},
2935
+ ...patch.checklistItems ? { checklistItems: patch.checklistItems } : {}
2936
+ }
2937
+ });
2938
+ strapi.eventHub?.emit("inbox.submission.updated", {
2939
+ submission: updated
2940
+ });
2941
+ return updated;
2942
+ },
2943
+ async scheduleBookingFromSubmission(documentId, bookingInput) {
2944
+ return scheduleBookingFromSubmission(strapi, documentId, bookingInput);
2945
+ },
2946
+ async issueCertificateFromSubmission(documentId, certInput) {
2947
+ return issueCertificateFromSubmission(strapi, documentId, certInput);
2948
+ },
2949
+ async exportCsv(formSlug) {
2950
+ const submissions = await this.listSubmissions(
2951
+ formSlug ? { formSlug, limit: 1e3 } : { limit: 1e3 }
2952
+ );
2953
+ return exportSubmissionsCsv(strapi, submissions, formSlug);
2954
+ }
2955
+ });
2956
+ function normalizeSubscriberTopics(topics) {
2957
+ return Array.isArray(topics) && topics.length > 0 ? Array.from(new Set(topics)) : ["announcements"];
2958
+ }
2959
+ function pickSubscriberStr(...values) {
2960
+ for (const val of values) {
2961
+ if (val && val.trim().length > 0) return val;
2962
+ }
2963
+ return "";
2964
+ }
2965
+ function buildSubscriberPayload(email, input, existing, settings2) {
2966
+ const doubleOptIn = settings2.enableDoubleOptIn !== false;
2967
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
2968
+ const status = doubleOptIn ? "pending" : "confirmed";
2969
+ return {
2970
+ email,
2971
+ fullName: pickSubscriberStr(input.fullName, existing?.fullName),
2972
+ phone: pickSubscriberStr(input.phone, existing?.phone),
2973
+ locale: pickSubscriberStr(input.locale, settings2.defaultLocale, "es"),
2974
+ topics: normalizeSubscriberTopics(input.topics),
2975
+ status,
2976
+ confirmToken: doubleOptIn ? node_crypto.randomBytes(20).toString("hex") : null,
2977
+ unsubscribeToken: pickSubscriberStr(
2978
+ existing?.unsubscribeToken,
2979
+ node_crypto.randomBytes(20).toString("hex")
2980
+ ),
2981
+ confirmedAt: doubleOptIn ? null : nowIso,
2982
+ unsubscribedAt: null,
2983
+ sourceFormSlug: pickSubscriberStr(
2984
+ input.sourceFormSlug,
2985
+ existing?.sourceFormSlug,
2986
+ "newsletter"
2987
+ )
2988
+ };
2989
+ }
2990
+ const subscriberService = ({ strapi }) => ({
2991
+ async subscribe(input) {
2992
+ const email = (input.email || "").trim().toLowerCase();
2993
+ if (!isValidEmail(email)) {
2994
+ const err = new Error("A valid email address is required to subscribe");
2995
+ err.name = "ValidationError";
2996
+ throw err;
2997
+ }
2998
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
2999
+ const settings2 = await settingsService.getSettings();
3000
+ const existingDocs = await getDocuments(strapi, SUBSCRIBER_UID).findMany({
3001
+ filters: { email },
3002
+ limit: 1
3003
+ });
3004
+ const existing = existingDocs[0] || null;
3005
+ const payload = buildSubscriberPayload(email, input, existing, settings2);
3006
+ if (existing?.documentId) {
3007
+ const updated = await getDocuments(strapi, SUBSCRIBER_UID).update({
3008
+ documentId: existing.documentId,
3009
+ data: payload
3010
+ });
3011
+ return updated;
3012
+ }
3013
+ const created = await getDocuments(strapi, SUBSCRIBER_UID).create({
3014
+ data: payload
3015
+ });
3016
+ strapi.eventHub?.emit("inbox.subscriber.created", {
3017
+ subscriber: created
3018
+ });
3019
+ return created;
3020
+ },
3021
+ async confirmSubscription(token) {
3022
+ const trimmed = (token || "").trim();
3023
+ if (!trimmed) {
3024
+ throw new Error("Confirmation token is required");
3025
+ }
3026
+ const docs = await getDocuments(strapi, SUBSCRIBER_UID).findMany({
3027
+ filters: { confirmToken: trimmed },
3028
+ limit: 1
3029
+ });
3030
+ const subscriber2 = docs[0];
3031
+ if (!subscriber2 || !subscriber2.documentId) {
3032
+ const err = new Error("Invalid or expired confirmation token");
3033
+ err.name = "NotFoundError";
3034
+ throw err;
3035
+ }
3036
+ const updated = await getDocuments(strapi, SUBSCRIBER_UID).update({
3037
+ documentId: subscriber2.documentId,
3038
+ data: {
3039
+ status: "confirmed",
3040
+ confirmToken: null,
3041
+ confirmedAt: (/* @__PURE__ */ new Date()).toISOString(),
3042
+ unsubscribedAt: null
3043
+ }
3044
+ });
3045
+ strapi.eventHub?.emit("inbox.subscriber.confirmed", {
3046
+ subscriber: updated
3047
+ });
3048
+ return updated;
3049
+ },
3050
+ async unsubscribe(token) {
3051
+ const trimmed = (token || "").trim();
3052
+ if (!trimmed) {
3053
+ throw new Error("Unsubscribe token is required");
3054
+ }
3055
+ const docs = await getDocuments(strapi, SUBSCRIBER_UID).findMany({
3056
+ filters: { unsubscribeToken: trimmed },
3057
+ limit: 1
3058
+ });
3059
+ const subscriber2 = docs[0];
3060
+ if (!subscriber2 || !subscriber2.documentId) {
3061
+ const err = new Error("Invalid unsubscribe token");
3062
+ err.name = "NotFoundError";
3063
+ throw err;
3064
+ }
3065
+ const updated = await getDocuments(strapi, SUBSCRIBER_UID).update({
3066
+ documentId: subscriber2.documentId,
3067
+ data: {
3068
+ status: "unsubscribed",
3069
+ unsubscribedAt: (/* @__PURE__ */ new Date()).toISOString()
3070
+ }
3071
+ });
3072
+ strapi.eventHub?.emit("inbox.subscriber.unsubscribed", {
3073
+ subscriber: updated
3074
+ });
3075
+ return updated;
3076
+ },
3077
+ async listSubscribers(filters) {
3078
+ const queryFilters = {};
3079
+ if (filters?.status) queryFilters.status = filters.status;
3080
+ if (filters?.locale) queryFilters.locale = filters.locale;
3081
+ const docs = await getDocuments(strapi, SUBSCRIBER_UID).findMany({
3082
+ filters: queryFilters,
3083
+ limit: 500
3084
+ });
3085
+ if (filters?.topic) {
3086
+ return docs.filter((sub) => (sub.topics || []).includes(filters.topic));
3087
+ }
3088
+ return docs;
3089
+ }
3090
+ });
3091
+ const services = {
3092
+ settings,
3093
+ formService,
3094
+ submissionService,
3095
+ subscriberService,
3096
+ alertService
3097
+ };
3098
+ const serverEntry = {
3099
+ register,
3100
+ bootstrap,
3101
+ destroy,
3102
+ config,
3103
+ contentTypes,
3104
+ controllers,
3105
+ routes,
3106
+ services
3107
+ };
3108
+ exports.default = serverEntry;