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