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