@nextlyhq/plugin-form-builder 0.0.2-alpha.65 → 0.0.2-alpha.67
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/README.md +7 -7
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/index.js +30 -7
- package/dist/admin/index.js.map +1 -1
- package/dist/{chunk-W6NLZFHH.js → chunk-X5WLJLAR.js} +131 -117
- package/dist/chunk-X5WLJLAR.js.map +1 -0
- package/dist/index.d.ts +132 -183
- package/dist/index.js +1736 -1609
- package/dist/index.js.map +1 -1
- package/dist/{types-hLfLe81K.d.ts → types-Bo4lk9BD.d.ts} +7 -88
- package/package.json +14 -14
- package/dist/chunk-W6NLZFHH.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -30,14 +30,15 @@ import {
|
|
|
30
30
|
parseRedirectReference,
|
|
31
31
|
pickedDocumentField,
|
|
32
32
|
pluralize,
|
|
33
|
-
toTitleCase
|
|
34
|
-
|
|
33
|
+
toTitleCase,
|
|
34
|
+
warnIfWebhooksConfigured
|
|
35
|
+
} from "./chunk-X5WLJLAR.js";
|
|
35
36
|
|
|
36
37
|
// src/plugin.ts
|
|
37
38
|
import { createRequire } from "module";
|
|
38
39
|
import {
|
|
39
40
|
definePlugin,
|
|
40
|
-
NextlyError as
|
|
41
|
+
NextlyError as NextlyError4
|
|
41
42
|
} from "@nextlyhq/plugin-sdk";
|
|
42
43
|
|
|
43
44
|
// src/collections/forms.ts
|
|
@@ -74,16 +75,13 @@ function applyRedirectPattern(pattern, document) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// src/collections/access-defaults.ts
|
|
77
|
-
var
|
|
78
|
-
var administrative = ({ roles }) => roles.includes("admin") || roles.includes("super-admin");
|
|
78
|
+
var permitted = ({ permissions, collection, operation }) => permissions.includes(`${collection}:${operation}`);
|
|
79
79
|
function accessWithDefaults(overrides, defaults = {}) {
|
|
80
80
|
return {
|
|
81
|
-
read: overrides?.read ?? defaults.read ??
|
|
82
|
-
create: overrides?.create ?? defaults.create ??
|
|
83
|
-
update: overrides?.update ??
|
|
84
|
-
|
|
85
|
-
// remains the backstop underneath it.
|
|
86
|
-
delete: overrides?.delete ?? administrative
|
|
81
|
+
read: overrides?.read ?? defaults.read ?? permitted,
|
|
82
|
+
create: overrides?.create ?? defaults.create ?? permitted,
|
|
83
|
+
update: overrides?.update ?? permitted,
|
|
84
|
+
delete: overrides?.delete ?? permitted
|
|
87
85
|
};
|
|
88
86
|
}
|
|
89
87
|
|
|
@@ -787,7 +785,8 @@ function submissionsCollection(pluginConfig) {
|
|
|
787
785
|
...overrides.admin
|
|
788
786
|
},
|
|
789
787
|
// Access control with sensible defaults
|
|
790
|
-
// Public can create (submit forms),
|
|
788
|
+
// Public can create (submit forms); reading, updating and deleting need the
|
|
789
|
+
// matching permission on the caller's role (`read-form-submissions`, ...).
|
|
791
790
|
// `create: true` — a visitor submits without an account. Reading, updating
|
|
792
791
|
// and deleting are the shared defaults.
|
|
793
792
|
access: accessWithDefaults(accessOverrides, { create: true }),
|
|
@@ -1134,18 +1133,32 @@ function validateFormData(data, fields) {
|
|
|
1134
1133
|
const schema = generateZodSchema(fields);
|
|
1135
1134
|
return schema.safeParse(transformed);
|
|
1136
1135
|
}
|
|
1137
|
-
function
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1136
|
+
function isBlank(value) {
|
|
1137
|
+
return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
1138
|
+
}
|
|
1139
|
+
function getValidationIssues(result, submitted = {}) {
|
|
1140
|
+
if (result.success) return [];
|
|
1141
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1142
|
+
const issues = [];
|
|
1143
|
+
for (const issue of result.error.issues) {
|
|
1144
|
+
const path = issue.path.join(".");
|
|
1145
|
+
if (seen.has(path)) continue;
|
|
1146
|
+
seen.add(path);
|
|
1147
|
+
issues.push({
|
|
1148
|
+
path,
|
|
1149
|
+
// Read at the top level, which is the only depth a form field has: a
|
|
1150
|
+
// declared field is one key, so a dotted path can only come from inside a
|
|
1151
|
+
// value the visitor did supply.
|
|
1152
|
+
code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
|
|
1153
|
+
message: issue.message
|
|
1154
|
+
});
|
|
1147
1155
|
}
|
|
1148
|
-
return
|
|
1156
|
+
return issues;
|
|
1157
|
+
}
|
|
1158
|
+
function getValidationErrors(result) {
|
|
1159
|
+
return Object.fromEntries(
|
|
1160
|
+
getValidationIssues(result).map((issue) => [issue.path, issue.message])
|
|
1161
|
+
);
|
|
1149
1162
|
}
|
|
1150
1163
|
|
|
1151
1164
|
// src/handlers/prepare-submission.ts
|
|
@@ -1203,1724 +1216,1846 @@ function prepareSubmission({
|
|
|
1203
1216
|
}
|
|
1204
1217
|
const result = generateZodSchema(fields).safeParse(transformed);
|
|
1205
1218
|
if (!result.success) {
|
|
1206
|
-
return {
|
|
1219
|
+
return {
|
|
1220
|
+
data: transformed,
|
|
1221
|
+
validationErrors: getValidationErrors(result),
|
|
1222
|
+
// The RAW submission, not the transformed payload. Transformation
|
|
1223
|
+
// normalises a value before the schema sees it, so a required
|
|
1224
|
+
// multi-select sent a scalar `0` arrives here as `[]` and would read as
|
|
1225
|
+
// blank: the visitor answered, and would be told they had not.
|
|
1226
|
+
validationIssues: getValidationIssues(result, data)
|
|
1227
|
+
};
|
|
1207
1228
|
}
|
|
1208
1229
|
return { data: result.data };
|
|
1209
1230
|
}
|
|
1210
1231
|
|
|
1211
|
-
// src/
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1232
|
+
// src/handlers/spam-detection.ts
|
|
1233
|
+
import { RateLimiter, resolveRateLimitStore } from "nextly";
|
|
1234
|
+
async function checkSpam(options) {
|
|
1235
|
+
const { data, ipAddress, formSlug, config } = options;
|
|
1236
|
+
if (config.honeypot) {
|
|
1237
|
+
const honeypotResult = checkHoneypot(data);
|
|
1238
|
+
if (honeypotResult.isSpam) {
|
|
1239
|
+
return honeypotResult;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (config.rateLimit && ipAddress) {
|
|
1243
|
+
const rateLimitResult = await checkRateLimit(
|
|
1244
|
+
ipAddress,
|
|
1245
|
+
formSlug,
|
|
1246
|
+
config.rateLimit
|
|
1247
|
+
);
|
|
1248
|
+
if (rateLimitResult.isSpam) {
|
|
1249
|
+
return rateLimitResult;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return { isSpam: false };
|
|
1253
|
+
}
|
|
1254
|
+
var HONEYPOT_FIELDS = [
|
|
1255
|
+
"__honeypot",
|
|
1256
|
+
"_honeypot",
|
|
1257
|
+
"honeypot",
|
|
1258
|
+
"__hp",
|
|
1259
|
+
"_hp",
|
|
1260
|
+
"website",
|
|
1261
|
+
// Common honeypot field name
|
|
1262
|
+
"url_field",
|
|
1263
|
+
// Another common honeypot
|
|
1264
|
+
"fax_number"
|
|
1265
|
+
// Rarely used by humans
|
|
1266
|
+
];
|
|
1267
|
+
function checkHoneypot(data) {
|
|
1268
|
+
for (const fieldName of HONEYPOT_FIELDS) {
|
|
1269
|
+
const value = data[fieldName];
|
|
1270
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
1271
|
+
let raw;
|
|
1272
|
+
if (typeof value === "object") {
|
|
1273
|
+
raw = JSON.stringify(value);
|
|
1274
|
+
} else {
|
|
1275
|
+
raw = String(value);
|
|
1233
1276
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
textarea: options.fields?.textarea ?? true,
|
|
1242
|
-
select: options.fields?.select ?? true,
|
|
1243
|
-
checkbox: options.fields?.checkbox ?? true,
|
|
1244
|
-
radio: options.fields?.radio ?? true,
|
|
1245
|
-
file: options.fields?.file ?? true,
|
|
1246
|
-
date: options.fields?.date ?? true,
|
|
1247
|
-
time: options.fields?.time ?? true,
|
|
1248
|
-
hidden: options.fields?.hidden ?? true
|
|
1249
|
-
},
|
|
1250
|
-
redirectRelationships: normalizeRedirectRelationships(
|
|
1251
|
-
options.redirectRelationships
|
|
1252
|
-
),
|
|
1253
|
-
// Filled at `init` from each collection's own metadata. Empty until then,
|
|
1254
|
-
// and a slug that stays absent reads as unknown rather than as not
|
|
1255
|
-
// localized — see `ResolvedFormBuilderConfig`.
|
|
1256
|
-
redirectTargetLocalization: {},
|
|
1257
|
-
beforeEmail: options.beforeEmail,
|
|
1258
|
-
notifications: {
|
|
1259
|
-
defaultFrom: options.notifications?.defaultFrom,
|
|
1260
|
-
defaultToEmail: options.notifications?.defaultToEmail,
|
|
1261
|
-
enabled: options.notifications?.enabled ?? true
|
|
1262
|
-
},
|
|
1263
|
-
spamProtection: {
|
|
1264
|
-
honeypot: options.spamProtection?.honeypot ?? true,
|
|
1265
|
-
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
1266
|
-
rateLimit: {
|
|
1267
|
-
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
1268
|
-
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
1277
|
+
const stringValue = raw.trim();
|
|
1278
|
+
if (stringValue.length > 0) {
|
|
1279
|
+
return {
|
|
1280
|
+
isSpam: true,
|
|
1281
|
+
reason: "honeypot",
|
|
1282
|
+
details: `Honeypot field "${fieldName}" was filled`
|
|
1283
|
+
};
|
|
1269
1284
|
}
|
|
1270
|
-
},
|
|
1271
|
-
uploads: {
|
|
1272
|
-
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
1273
|
-
// 10 MB
|
|
1274
|
-
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
1275
|
-
"image/*",
|
|
1276
|
-
"application/pdf",
|
|
1277
|
-
"text/*"
|
|
1278
|
-
],
|
|
1279
|
-
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
1280
|
-
},
|
|
1281
|
-
features: {
|
|
1282
|
-
builder: options.features?.builder ?? true,
|
|
1283
|
-
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
1284
|
-
fileUploads: options.features?.fileUploads ?? true
|
|
1285
1285
|
}
|
|
1286
|
+
}
|
|
1287
|
+
return { isSpam: false };
|
|
1288
|
+
}
|
|
1289
|
+
async function checkRateLimit(ipAddress, formSlug, config) {
|
|
1290
|
+
const limiter = new RateLimiter(resolveRateLimitStore());
|
|
1291
|
+
const result = await limiter.check(
|
|
1292
|
+
`form-submit:${formSlug}:${ipAddress}`,
|
|
1293
|
+
config.maxSubmissions,
|
|
1294
|
+
config.windowMs
|
|
1295
|
+
);
|
|
1296
|
+
if (result.allowed) return { isSpam: false };
|
|
1297
|
+
return {
|
|
1298
|
+
isSpam: true,
|
|
1299
|
+
reason: "rate_limit",
|
|
1300
|
+
details: `Rate limit exceeded: ${config.maxSubmissions} per ${config.windowMs}ms`
|
|
1286
1301
|
};
|
|
1287
1302
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1303
|
+
|
|
1304
|
+
// src/routes/public-forms.ts
|
|
1305
|
+
import {
|
|
1306
|
+
buildPaginatedResponse,
|
|
1307
|
+
clampLimit,
|
|
1308
|
+
formAvailability as formAvailability2,
|
|
1309
|
+
NextlyError as NextlyError3,
|
|
1310
|
+
NO_SUCH_FORM as NO_SUCH_FORM2,
|
|
1311
|
+
respondAction,
|
|
1312
|
+
respondDoc,
|
|
1313
|
+
respondList,
|
|
1314
|
+
trustedClientIp
|
|
1315
|
+
} from "nextly";
|
|
1316
|
+
|
|
1317
|
+
// src/handlers/submit-form.ts
|
|
1318
|
+
import { isLocaleSelector } from "@nextlyhq/plugin-sdk";
|
|
1319
|
+
import { formAvailability, NextlyError as NextlyError2, NO_SUCH_FORM } from "nextly";
|
|
1320
|
+
async function submitForm(options, context) {
|
|
1321
|
+
const { formSlug, data, metadata, request } = options;
|
|
1322
|
+
const access = options.access ?? { as: "system" };
|
|
1323
|
+
const locale = visitorLocale(options.locale);
|
|
1324
|
+
const targetRead = { access, locale, request };
|
|
1325
|
+
const { pluginContext, pluginConfig } = context;
|
|
1326
|
+
const { collections } = pluginContext.services;
|
|
1327
|
+
const { logger } = pluginContext;
|
|
1328
|
+
try {
|
|
1329
|
+
const form = await fetchFormBySlug(
|
|
1330
|
+
formSlug,
|
|
1331
|
+
pluginConfig,
|
|
1332
|
+
pluginContext,
|
|
1333
|
+
access
|
|
1334
|
+
);
|
|
1335
|
+
if (!form) {
|
|
1336
|
+
logger.warn?.("Form submission attempted for non-existent form", {
|
|
1337
|
+
formSlug
|
|
1338
|
+
});
|
|
1339
|
+
return { success: false, outcome: "no-such-form", error: NO_SUCH_FORM };
|
|
1340
|
+
}
|
|
1341
|
+
const redirectFor = () => resolveRedirectUrl(form, pluginConfig, pluginContext, targetRead);
|
|
1342
|
+
const availability = formAvailability(form);
|
|
1343
|
+
if (availability.kind !== "open") {
|
|
1344
|
+
logger.info?.("Form submission rejected - form not accepting", {
|
|
1345
|
+
formSlug,
|
|
1346
|
+
status: form.status,
|
|
1347
|
+
availability: availability.kind
|
|
1348
|
+
});
|
|
1349
|
+
return {
|
|
1350
|
+
success: false,
|
|
1351
|
+
outcome: availability.kind === "closed" ? "closed" : "no-such-form",
|
|
1352
|
+
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
const settings = normalizeFormSettings(form.settings);
|
|
1356
|
+
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
1357
|
+
logger.warn?.(
|
|
1358
|
+
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
1359
|
+
{ formSlug }
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
|
|
1363
|
+
const existing = await collections.count(
|
|
1364
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
1333
1365
|
{
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
1339
|
-
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
1340
|
-
const url2 = new URL(req.url);
|
|
1341
|
-
const format = url2.searchParams.get("format") ?? "json";
|
|
1342
|
-
const formId = url2.searchParams.get("form");
|
|
1343
|
-
const status = url2.searchParams.get("status");
|
|
1344
|
-
const where = {};
|
|
1345
|
-
if (formId) where.form = { equals: formId };
|
|
1346
|
-
if (status) where.status = { equals: status };
|
|
1347
|
-
else where.status = { not_equals: "spam" };
|
|
1348
|
-
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
1349
|
-
let form;
|
|
1350
|
-
if (format === "csv") {
|
|
1351
|
-
if (!formId) {
|
|
1352
|
-
return Response.json(
|
|
1353
|
-
{
|
|
1354
|
-
error: {
|
|
1355
|
-
code: "VALIDATION_ERROR",
|
|
1356
|
-
message: "CSV export requires a form parameter."
|
|
1357
|
-
}
|
|
1358
|
-
},
|
|
1359
|
-
{ status: 400 }
|
|
1360
|
-
);
|
|
1361
|
-
}
|
|
1362
|
-
const formsSlugDeclared = resolvedConfig.formOverrides.slug;
|
|
1363
|
-
const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
|
|
1364
|
-
const formResult = await ctx.services.collections.listEntries(
|
|
1365
|
-
formsSlug,
|
|
1366
|
-
{ where: { id: { equals: formId } } },
|
|
1367
|
-
opts
|
|
1368
|
-
);
|
|
1369
|
-
form = formResult.data[0];
|
|
1370
|
-
if (!form) {
|
|
1371
|
-
return Response.json(
|
|
1372
|
-
{ error: { code: "NOT_FOUND", message: "Form not found." } },
|
|
1373
|
-
{ status: 404 }
|
|
1374
|
-
);
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
const MAX_EXPORT_ROWS = 5e4;
|
|
1378
|
-
const items = [];
|
|
1379
|
-
const pageSize = 200;
|
|
1380
|
-
let truncated = false;
|
|
1381
|
-
for (let page = 1; ; page += 1) {
|
|
1382
|
-
const result = await ctx.services.collections.listEntries(
|
|
1383
|
-
slug,
|
|
1384
|
-
{ where, pagination: { limit: pageSize, page } },
|
|
1385
|
-
opts
|
|
1386
|
-
);
|
|
1387
|
-
items.push(...result.data);
|
|
1388
|
-
const pageWasFull = result.data.length === pageSize;
|
|
1389
|
-
if (items.length >= MAX_EXPORT_ROWS) {
|
|
1390
|
-
truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
|
|
1391
|
-
items.length = MAX_EXPORT_ROWS;
|
|
1392
|
-
break;
|
|
1393
|
-
}
|
|
1394
|
-
if (!pageWasFull) break;
|
|
1395
|
-
}
|
|
1396
|
-
const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
|
|
1397
|
-
if (format !== "csv" || !form) {
|
|
1398
|
-
return Response.json({ items }, { headers: truncationHeaders });
|
|
1399
|
-
}
|
|
1400
|
-
const csv = exportToCSV(
|
|
1401
|
-
asSubmissionDocuments(items),
|
|
1402
|
-
asFormDocument(form)
|
|
1403
|
-
);
|
|
1404
|
-
const filename = generateExportFilename(
|
|
1405
|
-
typeof form.slug === "string" ? form.slug : "form",
|
|
1406
|
-
"csv"
|
|
1407
|
-
);
|
|
1408
|
-
return new Response(csv, {
|
|
1409
|
-
headers: {
|
|
1410
|
-
"Content-Type": "text/csv; charset=utf-8",
|
|
1411
|
-
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
1412
|
-
...truncationHeaders
|
|
1413
|
-
}
|
|
1414
|
-
});
|
|
1366
|
+
where: {
|
|
1367
|
+
form: { equals: form.id },
|
|
1368
|
+
ipAddress: { equals: metadata.ipAddress },
|
|
1369
|
+
status: { not_equals: "spam" }
|
|
1415
1370
|
}
|
|
1416
1371
|
},
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
method: "GET",
|
|
1423
|
-
path: "/builder-config",
|
|
1424
|
-
// Derived from the forms slug like the menu entry: with an
|
|
1425
|
-
// overridden slug, users hold read-<slug>, not read-forms.
|
|
1426
|
-
requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
|
|
1427
|
-
handler: (_req, ctx) => Promise.resolve(
|
|
1428
|
-
Response.json({
|
|
1429
|
-
fields: resolvedConfig.fields,
|
|
1430
|
-
// Notification defaults the builder surfaces: defaultToEmail
|
|
1431
|
-
// seeds a new form's first rule, defaultFrom renders as the
|
|
1432
|
-
// inherited sender placeholder.
|
|
1433
|
-
notifications: {
|
|
1434
|
-
defaultFrom: resolvedConfig.notifications.defaultFrom,
|
|
1435
|
-
defaultToEmail: resolvedConfig.notifications.defaultToEmail
|
|
1436
|
-
},
|
|
1437
|
-
// Spam defaults the Settings tab surfaces, so per-form
|
|
1438
|
-
// override selects can show what "inherit" resolves to.
|
|
1439
|
-
spamProtection: {
|
|
1440
|
-
honeypot: resolvedConfig.spamProtection.honeypot,
|
|
1441
|
-
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
|
|
1442
|
-
},
|
|
1443
|
-
// Runtime-resolved collection slugs (through ctx.self, so a
|
|
1444
|
-
// framework .rename() is honored too) — admin components
|
|
1445
|
-
// never hardcode "forms"/"form-submissions".
|
|
1446
|
-
// Names only. A pattern may be a function, which does not
|
|
1447
|
-
// survive JSON and which the browser has no use for — the
|
|
1448
|
-
// admin picks a document, the server builds the URL.
|
|
1449
|
-
redirectCollections: Object.keys(
|
|
1450
|
-
resolvedConfig.redirectRelationships
|
|
1451
|
-
),
|
|
1452
|
-
slugs: {
|
|
1453
|
-
forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
|
|
1454
|
-
submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
|
|
1455
|
-
}
|
|
1456
|
-
})
|
|
1457
|
-
)
|
|
1458
|
-
}
|
|
1459
|
-
]
|
|
1460
|
-
},
|
|
1461
|
-
// Forms is a first-class destination, not a plugin detail: standalone
|
|
1462
|
-
// placement gives it its own main-rail icon after Media, and its
|
|
1463
|
-
// sub-sidebar lists the plugin's collections (Forms, Submissions) —
|
|
1464
|
-
// no separate menu contribution, so "Forms" exists exactly once.
|
|
1465
|
-
// Hosts preferring the Plugins section override placement in config.
|
|
1466
|
-
// (The old admin.settings.component that rendered a second full builder
|
|
1467
|
-
// at /admin/plugins/<slug> is gone; the collection Edit-view override
|
|
1468
|
-
// is the single FormBuilderView mount.)
|
|
1469
|
-
admin: {
|
|
1470
|
-
placement: "standalone",
|
|
1471
|
-
after: "media",
|
|
1472
|
-
order: 50,
|
|
1473
|
-
appearance: { icon: "FileText", label: "Forms" },
|
|
1474
|
-
description: "Create and manage forms with submission tracking"
|
|
1475
|
-
},
|
|
1476
|
-
// -- Init ----------------------------------------------------------------
|
|
1477
|
-
// Registers an afterCreate hook on submissions to send email notifications.
|
|
1478
|
-
async init(nextly) {
|
|
1479
|
-
const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
1480
|
-
const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
|
|
1481
|
-
nextly.logger.info("Form Builder plugin initialized", {
|
|
1482
|
-
formsCollection: resolvedConfig.formOverrides.slug,
|
|
1483
|
-
submissionsCollection: submissionSlug,
|
|
1484
|
-
enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
|
|
1485
|
-
});
|
|
1486
|
-
for (const slug of Object.keys(
|
|
1487
|
-
resolvedConfig.redirectTargetLocalization
|
|
1488
|
-
)) {
|
|
1489
|
-
delete resolvedConfig.redirectTargetLocalization[slug];
|
|
1490
|
-
}
|
|
1491
|
-
await Promise.all(
|
|
1492
|
-
Object.keys(resolvedConfig.redirectRelationships).map(
|
|
1493
|
-
async (declared) => {
|
|
1494
|
-
const slug = nextly.self.collections[declared] ?? declared;
|
|
1495
|
-
try {
|
|
1496
|
-
const collection = await nextly.services.collections.getCollection(slug, {});
|
|
1497
|
-
const localized = collection?.localized;
|
|
1498
|
-
if (typeof localized === "boolean") {
|
|
1499
|
-
resolvedConfig.redirectTargetLocalization[declared] = localized;
|
|
1500
|
-
}
|
|
1501
|
-
} catch {
|
|
1502
|
-
nextly.logger.debug?.(
|
|
1503
|
-
"Redirect target localization could not be read",
|
|
1504
|
-
{ collection: slug }
|
|
1505
|
-
);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
)
|
|
1372
|
+
// Deliberately elevated, unlike the write below. This counts rows the
|
|
1373
|
+
// visitor is not entitled to see in order to refuse their second
|
|
1374
|
+
// submission; judged as the public it would count zero on any install
|
|
1375
|
+
// that hides submissions and the restriction would never apply.
|
|
1376
|
+
{ as: "system" }
|
|
1509
1377
|
);
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
);
|
|
1378
|
+
if (existing > 0) {
|
|
1379
|
+
logger.info?.("Repeat submission rejected (single-submission form)", {
|
|
1380
|
+
formSlug,
|
|
1381
|
+
ipAddress: metadata.ipAddress
|
|
1382
|
+
});
|
|
1383
|
+
return {
|
|
1384
|
+
success: false,
|
|
1385
|
+
outcome: "duplicate",
|
|
1386
|
+
error: "You have already submitted this form."
|
|
1387
|
+
};
|
|
1521
1388
|
}
|
|
1522
|
-
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
1523
|
-
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
1524
|
-
nextly.hooks.on(
|
|
1525
|
-
"beforeChange",
|
|
1526
|
-
submissionSlug,
|
|
1527
|
-
async (context) => prepareSubmissionForWrite(context, formsSlug, nextly)
|
|
1528
|
-
);
|
|
1529
|
-
nextly.hooks.on(
|
|
1530
|
-
"afterCreate",
|
|
1531
|
-
submissionSlug,
|
|
1532
|
-
async (context) => {
|
|
1533
|
-
await handleSubmissionCreated(context, resolvedConfig, nextly);
|
|
1534
|
-
}
|
|
1535
|
-
);
|
|
1536
|
-
nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
|
|
1537
|
-
const ctx = context;
|
|
1538
|
-
if (ctx.data && ctx.data.data !== void 0) {
|
|
1539
|
-
ctx.data.editedAt = /* @__PURE__ */ new Date();
|
|
1540
|
-
ctx.data.editedBy = ctx.user?.id ?? null;
|
|
1541
|
-
}
|
|
1542
|
-
return ctx.data;
|
|
1543
|
-
});
|
|
1544
|
-
nextly.hooks.on(
|
|
1545
|
-
"afterRead",
|
|
1546
|
-
formsSlug,
|
|
1547
|
-
(context) => injectSubmissionCount(context, submissionSlug, nextly)
|
|
1548
|
-
);
|
|
1549
1389
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
}
|
|
1561
|
-
function resolveFieldRef(ref, data) {
|
|
1562
|
-
const name = parseFieldRef(ref);
|
|
1563
|
-
if (name === null) return ref;
|
|
1564
|
-
const value = data[name];
|
|
1565
|
-
return typeof value === "string" ? value : "";
|
|
1566
|
-
}
|
|
1567
|
-
function collectAttachmentInputs(fields, submittedData) {
|
|
1568
|
-
const attachments = [];
|
|
1569
|
-
for (const field of fields) {
|
|
1570
|
-
if (field.type !== "file" || !field.attachToEmail) continue;
|
|
1571
|
-
const name = field.name;
|
|
1572
|
-
const value = submittedData[name];
|
|
1573
|
-
if (value == null) continue;
|
|
1574
|
-
if (Array.isArray(value)) {
|
|
1575
|
-
for (const item of value) {
|
|
1576
|
-
if (typeof item === "string" && item) {
|
|
1577
|
-
attachments.push({ mediaId: item });
|
|
1578
|
-
}
|
|
1390
|
+
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
1391
|
+
const undeclaredData = Object.fromEntries(
|
|
1392
|
+
Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
|
|
1393
|
+
);
|
|
1394
|
+
const spamResult = await checkSpam({
|
|
1395
|
+
data: undeclaredData,
|
|
1396
|
+
formSlug,
|
|
1397
|
+
config: {
|
|
1398
|
+
// Per-form overrides win where set; blank inherits the plugin config.
|
|
1399
|
+
honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot
|
|
1579
1400
|
}
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
try {
|
|
1589
|
-
const parsed = JSON.parse(value);
|
|
1590
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1591
|
-
} catch {
|
|
1592
|
-
return {};
|
|
1401
|
+
});
|
|
1402
|
+
const isContentSpam = spamResult.isSpam;
|
|
1403
|
+
if (isContentSpam) {
|
|
1404
|
+
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
1405
|
+
formSlug,
|
|
1406
|
+
reason: spamResult.reason,
|
|
1407
|
+
ipAddress: metadata?.ipAddress
|
|
1408
|
+
});
|
|
1593
1409
|
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
|
|
1604
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1605
|
-
const emails = [];
|
|
1606
|
-
const skipped = [];
|
|
1607
|
-
for (const notification of notifications) {
|
|
1608
|
-
if (!notification.enabled) continue;
|
|
1609
|
-
if (!notification.templateSlug) {
|
|
1610
|
-
skipped.push({ notificationId: notification.id, reason: "no-template" });
|
|
1611
|
-
continue;
|
|
1612
|
-
}
|
|
1613
|
-
const key = notification.id || JSON.stringify(notification);
|
|
1614
|
-
if (seen.has(key)) continue;
|
|
1615
|
-
seen.add(key);
|
|
1616
|
-
if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
|
|
1617
|
-
skipped.push({
|
|
1618
|
-
notificationId: notification.id,
|
|
1619
|
-
reason: "condition-unmet"
|
|
1620
|
-
});
|
|
1621
|
-
continue;
|
|
1622
|
-
}
|
|
1623
|
-
const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
|
|
1624
|
-
if (!to) {
|
|
1625
|
-
skipped.push({
|
|
1626
|
-
notificationId: notification.id,
|
|
1627
|
-
reason: "empty-recipient"
|
|
1410
|
+
const prepared = prepareSubmission({
|
|
1411
|
+
data,
|
|
1412
|
+
fields: form.fields,
|
|
1413
|
+
validate: !isContentSpam
|
|
1414
|
+
});
|
|
1415
|
+
if (prepared.validationErrors) {
|
|
1416
|
+
logger.debug?.("Form validation failed", {
|
|
1417
|
+
formSlug,
|
|
1418
|
+
errors: prepared.validationErrors
|
|
1628
1419
|
});
|
|
1629
|
-
|
|
1420
|
+
return {
|
|
1421
|
+
success: false,
|
|
1422
|
+
outcome: "invalid",
|
|
1423
|
+
error: "Validation failed",
|
|
1424
|
+
validationErrors: prepared.validationErrors,
|
|
1425
|
+
validationIssues: prepared.validationIssues
|
|
1426
|
+
};
|
|
1630
1427
|
}
|
|
1631
|
-
const
|
|
1632
|
-
const
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
},
|
|
1643
|
-
providerId: notification.providerId,
|
|
1644
|
-
from,
|
|
1645
|
-
replyTo,
|
|
1646
|
-
cc,
|
|
1647
|
-
bcc,
|
|
1648
|
-
notificationId: notification.id
|
|
1649
|
-
});
|
|
1650
|
-
}
|
|
1651
|
-
return { emails, skipped };
|
|
1652
|
-
}
|
|
1653
|
-
function parentFormId(submission) {
|
|
1654
|
-
const raw = submission.form;
|
|
1655
|
-
if (typeof raw === "string") return raw;
|
|
1656
|
-
if (raw && typeof raw === "object") {
|
|
1657
|
-
const maybeId = raw.id;
|
|
1658
|
-
if (typeof maybeId === "string") return maybeId;
|
|
1659
|
-
}
|
|
1660
|
-
return null;
|
|
1661
|
-
}
|
|
1662
|
-
function submittedPayload(raw) {
|
|
1663
|
-
if (typeof raw === "string") {
|
|
1428
|
+
const storedData = prepared.data;
|
|
1429
|
+
const submissionData = {
|
|
1430
|
+
form: form.id,
|
|
1431
|
+
data: storedData,
|
|
1432
|
+
status: isContentSpam ? "spam" : "new",
|
|
1433
|
+
spamReason: isContentSpam ? spamResult.reason ?? null : null,
|
|
1434
|
+
ipAddress: metadata?.ipAddress || null,
|
|
1435
|
+
userAgent: metadata?.userAgent || null,
|
|
1436
|
+
submittedAt: /* @__PURE__ */ new Date()
|
|
1437
|
+
};
|
|
1438
|
+
let submission;
|
|
1664
1439
|
try {
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1440
|
+
submission = await collections.createEntry(
|
|
1441
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
1442
|
+
asPluginSubmission(submissionData, {
|
|
1443
|
+
keepAsEvidence: isContentSpam,
|
|
1444
|
+
form: { id: form.id, fields: form.fields }
|
|
1445
|
+
}),
|
|
1446
|
+
// Public form submission — create as system. No ambient user; an
|
|
1447
|
+
// empty context already resolves to system, but be explicit.
|
|
1448
|
+
//
|
|
1449
|
+
// The request travels with it so the seam judges this submission on the
|
|
1450
|
+
// same facts as one arriving through a Nextly route. Omitted by a caller
|
|
1451
|
+
// that has none, and then the seam correctly reads the write as
|
|
1452
|
+
// server-side work.
|
|
1453
|
+
//
|
|
1454
|
+
// `access` rather than a fixed `system`, so a route serving an anonymous
|
|
1455
|
+
// visitor is still judged against the collection's own create rule. A
|
|
1456
|
+
// host that closed public submissions configured that on the
|
|
1457
|
+
// collection, and a write elevated past it reports a 201 for a
|
|
1458
|
+
// submission they had refused.
|
|
1459
|
+
{ ...access, ...request ? { request } : {} }
|
|
1460
|
+
);
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
if (NextlyError2.isRateLimited(error)) {
|
|
1463
|
+
logger.info?.("Form submission refused (rate limit)", { formSlug });
|
|
1464
|
+
return {
|
|
1465
|
+
success: true,
|
|
1466
|
+
outcome: "accepted",
|
|
1467
|
+
successMessage: settings.successMessage,
|
|
1468
|
+
// The same destination an accepted submission gets. Returning early
|
|
1469
|
+
// without it left a throttled visitor on the form instead of the
|
|
1470
|
+
// page its author sends people to, and made the one response this
|
|
1471
|
+
// whole branch exists to disguise the only accepted one carrying no
|
|
1472
|
+
// redirect.
|
|
1473
|
+
redirect: await redirectFor()
|
|
1474
|
+
};
|
|
1668
1475
|
}
|
|
1669
|
-
|
|
1476
|
+
throw error;
|
|
1670
1477
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1478
|
+
logger.info?.("Form submission created successfully", {
|
|
1479
|
+
formSlug,
|
|
1480
|
+
submissionId: submission.item.id,
|
|
1481
|
+
flaggedAsSpam: isContentSpam
|
|
1482
|
+
});
|
|
1483
|
+
if (submission.warnings) {
|
|
1484
|
+
logger.warn?.("Form submission side effects failed", {
|
|
1485
|
+
formSlug,
|
|
1486
|
+
submissionId: submission.item.id,
|
|
1487
|
+
warnings: submission.warnings
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
const redirect = await redirectFor();
|
|
1491
|
+
if (isContentSpam) {
|
|
1492
|
+
return {
|
|
1493
|
+
success: true,
|
|
1494
|
+
outcome: "accepted",
|
|
1495
|
+
successMessage: settings.successMessage,
|
|
1496
|
+
redirect
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
return {
|
|
1500
|
+
success: true,
|
|
1501
|
+
outcome: "accepted",
|
|
1502
|
+
successMessage: settings.successMessage,
|
|
1503
|
+
// The created ROW, not the envelope around it. Through the shared
|
|
1504
|
+
// conversion, which is where the unchecked step lives and says so; the
|
|
1505
|
+
// integration test reads `submission.id` off the result because nothing
|
|
1506
|
+
// at compile time will.
|
|
1507
|
+
submission: asSubmissionDocument(submission.item),
|
|
1508
|
+
redirect
|
|
1509
|
+
};
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
logger.error?.("Form submission error", {
|
|
1512
|
+
formSlug,
|
|
1513
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1514
|
+
});
|
|
1515
|
+
return {
|
|
1516
|
+
success: false,
|
|
1517
|
+
outcome: "failed",
|
|
1518
|
+
error: "An error occurred processing your submission. Please try again.",
|
|
1519
|
+
cause: error
|
|
1520
|
+
};
|
|
1673
1521
|
}
|
|
1674
|
-
throw NextlyError2.validation({
|
|
1675
|
-
errors: [
|
|
1676
|
-
{
|
|
1677
|
-
path: "data",
|
|
1678
|
-
code: "INVALID",
|
|
1679
|
-
message: "Submission data must be an object."
|
|
1680
|
-
}
|
|
1681
|
-
]
|
|
1682
|
-
});
|
|
1683
1522
|
}
|
|
1684
|
-
function
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1523
|
+
async function fetchFormBySlug(slug, pluginConfig, pluginContext, access = { as: "system" }) {
|
|
1524
|
+
const { collections } = pluginContext.services;
|
|
1525
|
+
const result = await collections.listEntries(
|
|
1526
|
+
pluginConfig.formOverrides.slug,
|
|
1527
|
+
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
1528
|
+
access
|
|
1529
|
+
);
|
|
1530
|
+
const form = result.data?.[0];
|
|
1531
|
+
return form ? asFormDocument(form) : null;
|
|
1689
1532
|
}
|
|
1690
|
-
async function
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
if (
|
|
1694
|
-
|
|
1695
|
-
const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
|
|
1696
|
-
if (!formId) return ctx.data;
|
|
1697
|
-
if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
|
|
1698
|
-
return ctx.data;
|
|
1533
|
+
async function resolveRedirectUrl(form, pluginConfig, pluginContext, read) {
|
|
1534
|
+
const settings = form.settings;
|
|
1535
|
+
if (!settings) return void 0;
|
|
1536
|
+
if (settings.confirmationType === "redirect" && settings.redirectUrl) {
|
|
1537
|
+
return settings.redirectUrl;
|
|
1699
1538
|
}
|
|
1700
|
-
const
|
|
1701
|
-
|
|
1539
|
+
const field = pickedDocumentField(settings);
|
|
1540
|
+
if (!field) return void 0;
|
|
1541
|
+
return urlForPickedDocument(
|
|
1542
|
+
settings[field],
|
|
1543
|
+
form,
|
|
1544
|
+
pluginConfig,
|
|
1545
|
+
pluginContext,
|
|
1546
|
+
read
|
|
1702
1547
|
);
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
throw NextlyError2.validation({
|
|
1712
|
-
errors: Object.entries(prepared.validationErrors).map(
|
|
1713
|
-
([path, message]) => ({ path, code: "INVALID", message })
|
|
1714
|
-
)
|
|
1548
|
+
}
|
|
1549
|
+
async function urlForPickedDocument(stored, form, pluginConfig, pluginContext, read) {
|
|
1550
|
+
const { logger } = pluginContext;
|
|
1551
|
+
const patterns = pluginConfig.redirectRelationships;
|
|
1552
|
+
const reference = parseRedirectReference(stored, Object.keys(patterns));
|
|
1553
|
+
if (!reference) {
|
|
1554
|
+
logger.warn?.("Form redirects to a page, but names no readable document", {
|
|
1555
|
+
form: form.slug
|
|
1715
1556
|
});
|
|
1557
|
+
return void 0;
|
|
1716
1558
|
}
|
|
1717
|
-
|
|
1718
|
-
if (
|
|
1719
|
-
|
|
1559
|
+
const pattern = patterns[reference.collection];
|
|
1560
|
+
if (!pattern) {
|
|
1561
|
+
logger.warn?.(
|
|
1562
|
+
"Form redirects to a collection that is not in redirectRelationships",
|
|
1563
|
+
{ form: form.slug, collection: reference.collection }
|
|
1564
|
+
);
|
|
1565
|
+
return void 0;
|
|
1720
1566
|
}
|
|
1721
|
-
|
|
1567
|
+
const target = await readTarget(reference, form, pluginContext, read);
|
|
1568
|
+
if (!target) return void 0;
|
|
1569
|
+
if (documentReachability(
|
|
1570
|
+
target,
|
|
1571
|
+
pluginConfig.redirectTargetLocalization[reference.collection]
|
|
1572
|
+
) === "unreachable") {
|
|
1573
|
+
logger.warn?.("Form redirects to a page that has never been published", {
|
|
1574
|
+
form: form.slug,
|
|
1575
|
+
collection: reference.collection,
|
|
1576
|
+
target: reference.id
|
|
1577
|
+
});
|
|
1578
|
+
return void 0;
|
|
1579
|
+
}
|
|
1580
|
+
return buildUrl(pattern, target, reference.collection, form, pluginContext);
|
|
1722
1581
|
}
|
|
1723
|
-
|
|
1724
|
-
const
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1582
|
+
function buildUrl(pattern, target, collection, form, pluginContext) {
|
|
1583
|
+
const { logger } = pluginContext;
|
|
1584
|
+
try {
|
|
1585
|
+
const url2 = applyRedirectPattern(pattern, target);
|
|
1586
|
+
if (!url2) {
|
|
1587
|
+
logger.warn?.(
|
|
1588
|
+
"Form redirect pattern could not be filled from the target document",
|
|
1589
|
+
{ form: form.slug, collection }
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
return url2;
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
logger.warn?.("Form redirect pattern threw while building a URL", {
|
|
1595
|
+
form: form.slug,
|
|
1596
|
+
collection,
|
|
1597
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1735
1598
|
});
|
|
1599
|
+
return void 0;
|
|
1736
1600
|
}
|
|
1737
|
-
return form.fields;
|
|
1738
1601
|
}
|
|
1739
|
-
function
|
|
1740
|
-
if (
|
|
1741
|
-
|
|
1742
|
-
row.editedBy = user?.id ?? null;
|
|
1602
|
+
function visitorLocale(locale) {
|
|
1603
|
+
if (locale === void 0 || isLocaleSelector(locale)) return void 0;
|
|
1604
|
+
return locale;
|
|
1743
1605
|
}
|
|
1744
|
-
async function
|
|
1745
|
-
|
|
1746
|
-
const
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
1765
|
-
const { emails, skipped } = buildNotificationEmails({
|
|
1766
|
-
notifications,
|
|
1767
|
-
submittedData,
|
|
1768
|
-
formName: form.name,
|
|
1769
|
-
submissionId: submission.id,
|
|
1770
|
-
defaultFrom: config.notifications.defaultFrom
|
|
1771
|
-
});
|
|
1772
|
-
for (const skip of skipped) {
|
|
1773
|
-
if (skip.reason === "empty-recipient") {
|
|
1774
|
-
nextly.logger.warn?.(
|
|
1775
|
-
"Form Builder: empty recipient, skipping notification",
|
|
1776
|
-
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
1777
|
-
);
|
|
1778
|
-
} else {
|
|
1779
|
-
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
1780
|
-
notificationId: skip.notificationId,
|
|
1781
|
-
formSlug: form.slug,
|
|
1782
|
-
reason: skip.reason
|
|
1783
|
-
});
|
|
1784
|
-
}
|
|
1606
|
+
async function readTarget(reference, form, pluginContext, read) {
|
|
1607
|
+
const { logger } = pluginContext;
|
|
1608
|
+
const context = {
|
|
1609
|
+
form: form.slug,
|
|
1610
|
+
collection: reference.collection,
|
|
1611
|
+
id: reference.id
|
|
1612
|
+
};
|
|
1613
|
+
let row;
|
|
1614
|
+
try {
|
|
1615
|
+
row = await pluginContext.services.collections.findEntryById(
|
|
1616
|
+
reference.collection,
|
|
1617
|
+
reference.id,
|
|
1618
|
+
{ ...read.access, locale: read.locale, request: read.request }
|
|
1619
|
+
);
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
logger.warn?.("Form redirect target could not be read", {
|
|
1622
|
+
...context,
|
|
1623
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1624
|
+
});
|
|
1625
|
+
return void 0;
|
|
1785
1626
|
}
|
|
1786
|
-
if (
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
try {
|
|
1790
|
-
await emailService.sendWithTemplate(
|
|
1791
|
-
email2.templateSlug,
|
|
1792
|
-
email2.to,
|
|
1793
|
-
email2.variables,
|
|
1794
|
-
{
|
|
1795
|
-
providerId: email2.providerId,
|
|
1796
|
-
from: email2.from,
|
|
1797
|
-
replyTo: email2.replyTo,
|
|
1798
|
-
cc: email2.cc,
|
|
1799
|
-
bcc: email2.bcc,
|
|
1800
|
-
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
1801
|
-
}
|
|
1802
|
-
);
|
|
1803
|
-
nextly.logger.info?.("Form Builder: notification sent", {
|
|
1804
|
-
formSlug: form.slug,
|
|
1805
|
-
to: email2.to,
|
|
1806
|
-
templateSlug: email2.templateSlug,
|
|
1807
|
-
notificationId: email2.notificationId,
|
|
1808
|
-
attachmentCount: fileAttachments.length
|
|
1809
|
-
});
|
|
1810
|
-
} catch (err) {
|
|
1811
|
-
nextly.logger.error?.("Form Builder: notification failed", {
|
|
1812
|
-
to: email2.to,
|
|
1813
|
-
templateSlug: email2.templateSlug,
|
|
1814
|
-
notificationId: email2.notificationId,
|
|
1815
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1816
|
-
});
|
|
1817
|
-
}
|
|
1627
|
+
if (!row || typeof row !== "object") {
|
|
1628
|
+
logger.warn?.("Form redirect target no longer exists", context);
|
|
1629
|
+
return void 0;
|
|
1818
1630
|
}
|
|
1631
|
+
return row;
|
|
1819
1632
|
}
|
|
1820
|
-
async function
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
);
|
|
1839
|
-
} catch {
|
|
1840
|
-
form.submissionCount = 0;
|
|
1841
|
-
}
|
|
1842
|
-
})
|
|
1843
|
-
);
|
|
1633
|
+
async function validateSubmission(formSlug, data, context) {
|
|
1634
|
+
const { pluginContext, pluginConfig } = context;
|
|
1635
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
1636
|
+
if (!form) {
|
|
1637
|
+
return { valid: false, errors: { _form: "Form not found" } };
|
|
1638
|
+
}
|
|
1639
|
+
const prepared = prepareSubmission({
|
|
1640
|
+
data,
|
|
1641
|
+
fields: form.fields,
|
|
1642
|
+
validate: true
|
|
1643
|
+
});
|
|
1644
|
+
if (!prepared.validationErrors) {
|
|
1645
|
+
return { valid: true };
|
|
1646
|
+
}
|
|
1647
|
+
return {
|
|
1648
|
+
valid: false,
|
|
1649
|
+
errors: prepared.validationErrors
|
|
1650
|
+
};
|
|
1844
1651
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1652
|
+
async function isFormAcceptingSubmissions(formSlug, context) {
|
|
1653
|
+
const { pluginContext, pluginConfig } = context;
|
|
1654
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
1655
|
+
if (!form) {
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1658
|
+
return form.status === "published";
|
|
1659
|
+
}
|
|
1660
|
+
async function getFormSubmissionStats(formSlug, context) {
|
|
1661
|
+
const { pluginContext, pluginConfig } = context;
|
|
1662
|
+
const { collections } = pluginContext.services;
|
|
1663
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
1664
|
+
if (!form) {
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1847
1667
|
try {
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1668
|
+
const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
|
|
1669
|
+
const sys = { as: "system" };
|
|
1670
|
+
const base = { form: { equals: form.id } };
|
|
1671
|
+
const [total, newCount, read, archived] = await Promise.all([
|
|
1672
|
+
collections.count(submissionsSlug, { where: base }, sys),
|
|
1673
|
+
collections.count(
|
|
1674
|
+
submissionsSlug,
|
|
1675
|
+
{ where: { ...base, status: { equals: "new" } } },
|
|
1676
|
+
sys
|
|
1677
|
+
),
|
|
1678
|
+
collections.count(
|
|
1679
|
+
submissionsSlug,
|
|
1680
|
+
{ where: { ...base, status: { equals: "read" } } },
|
|
1681
|
+
sys
|
|
1682
|
+
),
|
|
1683
|
+
collections.count(
|
|
1684
|
+
submissionsSlug,
|
|
1685
|
+
{ where: { ...base, status: { equals: "archived" } } },
|
|
1686
|
+
sys
|
|
1687
|
+
)
|
|
1688
|
+
]);
|
|
1689
|
+
return { total, new: newCount, read, archived };
|
|
1690
|
+
} catch {
|
|
1860
1691
|
return null;
|
|
1861
1692
|
}
|
|
1862
1693
|
}
|
|
1863
1694
|
|
|
1864
|
-
// src/
|
|
1865
|
-
|
|
1866
|
-
var
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
"create",
|
|
1876
|
-
"edit",
|
|
1877
|
-
"delete"
|
|
1878
|
-
];
|
|
1879
|
-
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
1880
|
-
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
1881
|
-
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
1882
|
-
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
1883
|
-
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
1884
|
-
function isReservedSlug(slug) {
|
|
1885
|
-
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
1695
|
+
// src/routes/public-forms.ts
|
|
1696
|
+
var PUBLIC = { as: "public" };
|
|
1697
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
1698
|
+
var DEFAULT_SUCCESS_MESSAGE = "Thank you for your submission!";
|
|
1699
|
+
function installedSlugs(config, self) {
|
|
1700
|
+
const declaredForms = config.formOverrides.slug;
|
|
1701
|
+
const declaredSubmissions = config.formSubmissionOverrides.slug;
|
|
1702
|
+
return {
|
|
1703
|
+
forms: self.collections[declaredForms] ?? declaredForms,
|
|
1704
|
+
submissions: self.collections[declaredSubmissions] ?? declaredSubmissions
|
|
1705
|
+
};
|
|
1886
1706
|
}
|
|
1887
|
-
function
|
|
1888
|
-
const
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1707
|
+
function configForInstall(config, self) {
|
|
1708
|
+
const slugs = installedSlugs(config, self);
|
|
1709
|
+
return {
|
|
1710
|
+
...config,
|
|
1711
|
+
formOverrides: { ...config.formOverrides, slug: slugs.forms },
|
|
1712
|
+
formSubmissionOverrides: {
|
|
1713
|
+
...config.formSubmissionOverrides,
|
|
1714
|
+
slug: slugs.submissions
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
function positiveInt(raw) {
|
|
1719
|
+
if (raw === null) return void 0;
|
|
1720
|
+
const value = Number(raw);
|
|
1721
|
+
return Number.isInteger(value) && value > 0 ? value : void 0;
|
|
1722
|
+
}
|
|
1723
|
+
function toPageMeta(pagination) {
|
|
1724
|
+
const limit = pagination.limit > 0 ? pagination.limit : DEFAULT_LIST_LIMIT;
|
|
1725
|
+
const built = buildPaginatedResponse([], {
|
|
1726
|
+
total: pagination.total,
|
|
1727
|
+
page: Math.floor(pagination.offset / limit) + 1,
|
|
1728
|
+
limit
|
|
1729
|
+
});
|
|
1730
|
+
return {
|
|
1731
|
+
total: built.totalDocs,
|
|
1732
|
+
page: built.page,
|
|
1733
|
+
limit: built.limit,
|
|
1734
|
+
totalPages: built.totalPages,
|
|
1735
|
+
hasNext: built.hasNextPage,
|
|
1736
|
+
hasPrev: built.hasPrevPage
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
async function listPublishedForms(req, ctx, config) {
|
|
1740
|
+
const url2 = new URL(req.url);
|
|
1741
|
+
const slugs = installedSlugs(config, ctx.self);
|
|
1742
|
+
const result = await ctx.services.collections.listEntries(
|
|
1743
|
+
slugs.forms,
|
|
1744
|
+
{
|
|
1745
|
+
where: { status: { equals: "published" } },
|
|
1746
|
+
pagination: {
|
|
1747
|
+
// Clamped HERE, with the canonical bound, so the limit that computes
|
|
1748
|
+
// the offset is the limit that comes back. The query service clamps to
|
|
1749
|
+
// 500 internally and reports the clamped value, while the offset is
|
|
1750
|
+
// built from what was asked for, so `?limit=1000&page=2` returned the
|
|
1751
|
+
// second 500-row slice and dividing the two reported page 3.
|
|
1752
|
+
limit: clampLimit(
|
|
1753
|
+
positiveInt(url2.searchParams.get("limit")) ?? DEFAULT_LIST_LIMIT
|
|
1754
|
+
),
|
|
1755
|
+
page: positiveInt(url2.searchParams.get("page")) ?? 1
|
|
1756
|
+
}
|
|
1757
|
+
},
|
|
1758
|
+
PUBLIC
|
|
1759
|
+
);
|
|
1760
|
+
return respondList(result.data ?? [], toPageMeta(result.pagination));
|
|
1761
|
+
}
|
|
1762
|
+
async function readFormBySlug(slug, ctx, config) {
|
|
1763
|
+
const slugs = installedSlugs(config, ctx.self);
|
|
1764
|
+
const result = await ctx.services.collections.listEntries(
|
|
1765
|
+
slugs.forms,
|
|
1766
|
+
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
1767
|
+
PUBLIC
|
|
1768
|
+
);
|
|
1769
|
+
const doc = result.data?.[0];
|
|
1770
|
+
const availability = formAvailability2(doc);
|
|
1771
|
+
if (availability.kind === "absent") {
|
|
1772
|
+
throw NextlyError3.notFound({
|
|
1773
|
+
message: NO_SUCH_FORM2,
|
|
1774
|
+
logContext: { entity: "form", slug, reason: availability.reason }
|
|
1910
1775
|
});
|
|
1911
1776
|
}
|
|
1912
|
-
if (
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1777
|
+
if (availability.kind === "closed") {
|
|
1778
|
+
return respondDoc({
|
|
1779
|
+
slug,
|
|
1780
|
+
status: "closed",
|
|
1781
|
+
closedMessage: availability.message
|
|
1917
1782
|
});
|
|
1918
1783
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1784
|
+
return respondDoc(doc);
|
|
1785
|
+
}
|
|
1786
|
+
async function readSubmittedData(req, slug) {
|
|
1787
|
+
let parsed;
|
|
1788
|
+
try {
|
|
1789
|
+
parsed = await req.json();
|
|
1790
|
+
} catch {
|
|
1791
|
+
parsed = void 0;
|
|
1925
1792
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1793
|
+
const body = parsed;
|
|
1794
|
+
if (!body?.data || typeof body.data !== "object") {
|
|
1795
|
+
throw NextlyError3.validation({
|
|
1796
|
+
errors: [
|
|
1797
|
+
{
|
|
1798
|
+
path: "data",
|
|
1799
|
+
code: "MISSING_FIELD",
|
|
1800
|
+
message: "Request body must contain a 'data' object."
|
|
1801
|
+
}
|
|
1802
|
+
],
|
|
1803
|
+
logContext: { slug }
|
|
1931
1804
|
});
|
|
1932
1805
|
}
|
|
1806
|
+
return body.data;
|
|
1933
1807
|
}
|
|
1934
|
-
function
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
errors
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1808
|
+
function refusalFor(result, slug) {
|
|
1809
|
+
const entity = { entity: "form", slug };
|
|
1810
|
+
switch (result.outcome) {
|
|
1811
|
+
case "no-such-form":
|
|
1812
|
+
return NextlyError3.notFound({
|
|
1813
|
+
message: NO_SUCH_FORM2,
|
|
1814
|
+
logContext: entity
|
|
1815
|
+
});
|
|
1816
|
+
// A state conflict, not a validation failure. `validation` fixes the
|
|
1817
|
+
// canonical `error.message` to "Validation failed." and nests the real one
|
|
1818
|
+
// under `error.data.errors[0]`, so a client reading the documented envelope
|
|
1819
|
+
// would be handed the author's explanation and never show it.
|
|
1820
|
+
case "closed":
|
|
1821
|
+
return NextlyError3.conflict({
|
|
1822
|
+
reason: "state",
|
|
1823
|
+
message: result.error ?? NO_SUCH_FORM2,
|
|
1824
|
+
logContext: { ...entity, reason: "closed" }
|
|
1825
|
+
});
|
|
1826
|
+
case "duplicate":
|
|
1827
|
+
return NextlyError3.conflict({
|
|
1828
|
+
reason: "state",
|
|
1829
|
+
message: result.error ?? "You have already submitted this form.",
|
|
1830
|
+
logContext: { ...entity, reason: "duplicate" }
|
|
1831
|
+
});
|
|
1832
|
+
// The codes come from the issue list, not from the message map beside it.
|
|
1833
|
+
// A client tells "you left this blank" from "this is not an email" by the
|
|
1834
|
+
// code, and reporting every failure as `INVALID` stops that working while
|
|
1835
|
+
// looking correct in a status-only test.
|
|
1836
|
+
case "invalid":
|
|
1837
|
+
return NextlyError3.validation({
|
|
1838
|
+
errors: (result.validationIssues ?? []).map((issue) => ({
|
|
1839
|
+
path: issue.path,
|
|
1840
|
+
code: issue.code,
|
|
1841
|
+
message: issue.message
|
|
1842
|
+
})),
|
|
1843
|
+
logContext: { slug }
|
|
1844
|
+
});
|
|
1845
|
+
// A typed refusal is re-thrown as itself. The collection may refuse a write
|
|
1846
|
+
// for a reason its host chose, and those carry their own status and field
|
|
1847
|
+
// detail; answering all of them with one internal error tells a visitor the
|
|
1848
|
+
// server broke when their submission was in fact answered deliberately.
|
|
1849
|
+
// Anything untyped stays a 500, which is what an unexpected failure is.
|
|
1850
|
+
default:
|
|
1851
|
+
if (result.cause instanceof NextlyError3) return result.cause;
|
|
1852
|
+
return NextlyError3.internal({
|
|
1853
|
+
logContext: { ...entity, reason: "submission-failed" }
|
|
1854
|
+
});
|
|
1967
1855
|
}
|
|
1968
1856
|
}
|
|
1969
|
-
function
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1857
|
+
async function acceptSubmission(req, ctx, config, slug) {
|
|
1858
|
+
const data = await readSubmittedData(req, slug);
|
|
1859
|
+
const result = await submitForm(
|
|
1860
|
+
{
|
|
1861
|
+
formSlug: slug,
|
|
1862
|
+
data,
|
|
1863
|
+
// The visitor's language, from `?locale=` on the submit URL — the same
|
|
1864
|
+
// place core's own routes read a locale on the wire — so a picked
|
|
1865
|
+
// redirect page answers with its URL in that language. A form on a
|
|
1866
|
+
// French page posts to `/api/forms/contact/submit?locale=fr` and is
|
|
1867
|
+
// sent to `/merci`; one that names no locale is answered in the
|
|
1868
|
+
// default, as before. An unconfigured code reads as the default rather
|
|
1869
|
+
// than refusing the submission: the visitor filled the form in, and a
|
|
1870
|
+
// wrong query string is not a reason to lose what they wrote.
|
|
1871
|
+
locale: new URL(req.url).searchParams.get("locale") || void 0,
|
|
1872
|
+
// The request itself, so the write seam resolves the client under this
|
|
1873
|
+
// deployment's proxy-trust settings and judges this submission on the
|
|
1874
|
+
// same facts as one arriving through any other door.
|
|
1875
|
+
request: req,
|
|
1876
|
+
// The address for the audit column and the single-submission check,
|
|
1877
|
+
// resolved by the core rather than read off a header here, which would
|
|
1878
|
+
// record whatever the sender chose to claim and let one visitor pass as
|
|
1879
|
+
// many.
|
|
1880
|
+
metadata: {
|
|
1881
|
+
ipAddress: trustedClientIp(req) ?? void 0,
|
|
1882
|
+
userAgent: req.headers.get("user-agent") ?? void 0
|
|
1883
|
+
},
|
|
1884
|
+
access: PUBLIC
|
|
1885
|
+
},
|
|
1886
|
+
{
|
|
1887
|
+
pluginContext: ctx,
|
|
1888
|
+
pluginConfig: configForInstall(config, ctx.self)
|
|
1889
|
+
}
|
|
1890
|
+
);
|
|
1891
|
+
if (result.outcome !== "accepted") throw refusalFor(result, slug);
|
|
1892
|
+
return respondAction(
|
|
1893
|
+
result.successMessage ?? DEFAULT_SUCCESS_MESSAGE,
|
|
1894
|
+
{
|
|
1895
|
+
...result.submission ? { submissionId: result.submission.id } : {},
|
|
1896
|
+
// Forwarded because this route is the only thing that can act on it. A
|
|
1897
|
+
// form configured to redirect on success resolves one here, and core's
|
|
1898
|
+
// endpoint dropped it, so the setting did nothing for any client posting
|
|
1899
|
+
// over HTTP. Additive, and present on a flagged submission too, so it
|
|
1900
|
+
// cannot be diffed against an accepted one.
|
|
1901
|
+
...result.redirect ? { redirect: result.redirect } : {}
|
|
1902
|
+
},
|
|
1903
|
+
{ status: 201 }
|
|
1904
|
+
);
|
|
1977
1905
|
}
|
|
1978
|
-
function
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
1906
|
+
function publicFormRoutes(config) {
|
|
1907
|
+
return [
|
|
1908
|
+
{
|
|
1909
|
+
method: "GET",
|
|
1910
|
+
path: "/forms",
|
|
1911
|
+
mount: "root",
|
|
1912
|
+
public: true,
|
|
1913
|
+
// Collection documents, so their stored timestamps are presented in the
|
|
1914
|
+
// installation's timezone exactly as the built-in read did.
|
|
1915
|
+
formatTimestamps: true,
|
|
1916
|
+
handler: (req, ctx) => listPublishedForms(req, ctx, config)
|
|
1917
|
+
},
|
|
1918
|
+
{
|
|
1919
|
+
method: "GET",
|
|
1920
|
+
path: "/forms/:slug",
|
|
1921
|
+
mount: "root",
|
|
1922
|
+
public: true,
|
|
1923
|
+
formatTimestamps: true,
|
|
1924
|
+
handler: (_req, ctx) => readFormBySlug(ctx.params.slug, ctx, config)
|
|
1925
|
+
},
|
|
1926
|
+
{
|
|
1927
|
+
method: "POST",
|
|
1928
|
+
path: "/forms/:slug/submit",
|
|
1929
|
+
mount: "root",
|
|
1930
|
+
public: true,
|
|
1931
|
+
handler: (req, ctx) => acceptSubmission(req, ctx, config, ctx.params.slug)
|
|
1932
|
+
}
|
|
1933
|
+
];
|
|
2004
1934
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
1935
|
+
|
|
1936
|
+
// src/plugin.ts
|
|
1937
|
+
var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
|
|
1938
|
+
"../package.json"
|
|
1939
|
+
);
|
|
1940
|
+
function resolveConfig(options) {
|
|
1941
|
+
const formOverrides = options.formOverrides || {};
|
|
1942
|
+
const submissionOverrides = options.formSubmissionOverrides || {};
|
|
1943
|
+
return {
|
|
1944
|
+
formOverrides: {
|
|
1945
|
+
...formOverrides,
|
|
1946
|
+
slug: formOverrides.slug || "forms",
|
|
1947
|
+
labels: {
|
|
1948
|
+
singular: formOverrides.labels?.singular || "Form",
|
|
1949
|
+
plural: formOverrides.labels?.plural || "Forms"
|
|
1950
|
+
}
|
|
1951
|
+
},
|
|
1952
|
+
formSubmissionOverrides: {
|
|
1953
|
+
...submissionOverrides,
|
|
1954
|
+
slug: submissionOverrides.slug || "form-submissions",
|
|
1955
|
+
labels: {
|
|
1956
|
+
singular: submissionOverrides.labels?.singular || "Submission",
|
|
1957
|
+
plural: submissionOverrides.labels?.plural || "Submissions"
|
|
1958
|
+
}
|
|
1959
|
+
},
|
|
1960
|
+
fields: {
|
|
1961
|
+
text: options.fields?.text ?? true,
|
|
1962
|
+
email: options.fields?.email ?? true,
|
|
1963
|
+
number: options.fields?.number ?? true,
|
|
1964
|
+
phone: options.fields?.phone ?? true,
|
|
1965
|
+
url: options.fields?.url ?? true,
|
|
1966
|
+
textarea: options.fields?.textarea ?? true,
|
|
1967
|
+
select: options.fields?.select ?? true,
|
|
1968
|
+
checkbox: options.fields?.checkbox ?? true,
|
|
1969
|
+
radio: options.fields?.radio ?? true,
|
|
1970
|
+
file: options.fields?.file ?? true,
|
|
1971
|
+
date: options.fields?.date ?? true,
|
|
1972
|
+
time: options.fields?.time ?? true,
|
|
1973
|
+
hidden: options.fields?.hidden ?? true
|
|
1974
|
+
},
|
|
1975
|
+
redirectRelationships: normalizeRedirectRelationships(
|
|
1976
|
+
options.redirectRelationships
|
|
1977
|
+
),
|
|
1978
|
+
// Filled at `init` from each collection's own metadata. Empty until then,
|
|
1979
|
+
// and a slug that stays absent reads as unknown rather than as not
|
|
1980
|
+
// localized — see `ResolvedFormBuilderConfig`.
|
|
1981
|
+
redirectTargetLocalization: {},
|
|
1982
|
+
beforeEmail: options.beforeEmail,
|
|
1983
|
+
notifications: {
|
|
1984
|
+
defaultFrom: options.notifications?.defaultFrom,
|
|
1985
|
+
defaultToEmail: options.notifications?.defaultToEmail,
|
|
1986
|
+
enabled: options.notifications?.enabled ?? true
|
|
1987
|
+
},
|
|
1988
|
+
spamProtection: {
|
|
1989
|
+
honeypot: options.spamProtection?.honeypot ?? true,
|
|
1990
|
+
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
1991
|
+
rateLimit: {
|
|
1992
|
+
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
1993
|
+
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
1994
|
+
}
|
|
1995
|
+
},
|
|
1996
|
+
uploads: {
|
|
1997
|
+
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
1998
|
+
// 10 MB
|
|
1999
|
+
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
2000
|
+
"image/*",
|
|
2001
|
+
"application/pdf",
|
|
2002
|
+
"text/*"
|
|
2003
|
+
],
|
|
2004
|
+
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
2005
|
+
},
|
|
2006
|
+
features: {
|
|
2007
|
+
builder: options.features?.builder ?? true,
|
|
2008
|
+
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
2009
|
+
fileUploads: options.features?.fileUploads ?? true
|
|
2026
2010
|
}
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
function formBuilder(options = {}) {
|
|
2014
|
+
const resolvedConfig = resolveConfig(options);
|
|
2015
|
+
const formsCol = formsCollection(resolvedConfig);
|
|
2016
|
+
const submissionsCol = submissionsCollection(resolvedConfig);
|
|
2017
|
+
const plugin = definePlugin({
|
|
2018
|
+
name: "@nextlyhq/plugin-form-builder",
|
|
2019
|
+
version: PLUGIN_VERSION,
|
|
2020
|
+
nextly: ">=0.0.2-alpha.21",
|
|
2021
|
+
// Identity metadata for the admin plugins page, mirroring package.json.
|
|
2022
|
+
author: "Nextly",
|
|
2023
|
+
homepage: "https://nextlyhq.com",
|
|
2024
|
+
repository: "https://github.com/nextlyhq/nextly",
|
|
2025
|
+
license: "MIT",
|
|
2026
|
+
category: "forms",
|
|
2027
|
+
tags: ["forms", "submissions", "email-notifications"],
|
|
2028
|
+
// Declarative schema: the merged pipeline folds these into the
|
|
2029
|
+
// app schema — no manual `setup()` append needed. Just register the plugin.
|
|
2030
|
+
contributes: {
|
|
2031
|
+
collections: [formsCol, submissionsCol],
|
|
2032
|
+
// Custom permission — gates submission export beyond CRUD. The
|
|
2033
|
+
// canonical example third-party plugin authors copy.
|
|
2034
|
+
permissions: [
|
|
2035
|
+
{
|
|
2036
|
+
action: "export",
|
|
2037
|
+
resource: "submissions",
|
|
2038
|
+
label: "Export Submissions",
|
|
2039
|
+
description: "Export form submissions as CSV/JSON",
|
|
2040
|
+
// No `group`: the admin already files this under the plugin that
|
|
2041
|
+
// declared it, so naming the plugin again would nest it inside
|
|
2042
|
+
// itself. `group` is for a plugin with enough permissions to sort
|
|
2043
|
+
// its own into headings, which one is not.
|
|
2044
|
+
//
|
|
2045
|
+
// `danger` because the point of the permission is to take
|
|
2046
|
+
// submissions — names, emails, whatever a form asked for — out of
|
|
2047
|
+
// the site in a file.
|
|
2048
|
+
danger: true
|
|
2049
|
+
}
|
|
2050
|
+
],
|
|
2051
|
+
// HTTP route — exported at
|
|
2052
|
+
// /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
|
|
2053
|
+
// default: gated by the custom `export-submissions` permission. Reads
|
|
2054
|
+
// via the secure-by-default service path as the authed user and
|
|
2055
|
+
// resolves its OWN slug through `ctx.self`. The canonical
|
|
2056
|
+
// contributes.routes example for third-party authors.
|
|
2057
|
+
routes: [
|
|
2058
|
+
// The public form endpoints, at the addresses core used to serve
|
|
2059
|
+
// before this plugin took them over. Root-mounted, so no caller's URL
|
|
2060
|
+
// changes; see `routes/public-forms`.
|
|
2061
|
+
...publicFormRoutes(resolvedConfig),
|
|
2062
|
+
{
|
|
2063
|
+
method: "GET",
|
|
2064
|
+
path: "/submissions/export",
|
|
2065
|
+
requiredPermission: "export-submissions",
|
|
2066
|
+
handler: async (req, ctx) => {
|
|
2067
|
+
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
2068
|
+
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
2069
|
+
const url2 = new URL(req.url);
|
|
2070
|
+
const format = url2.searchParams.get("format") ?? "json";
|
|
2071
|
+
const formId = url2.searchParams.get("form");
|
|
2072
|
+
const status = url2.searchParams.get("status");
|
|
2073
|
+
const where = {};
|
|
2074
|
+
if (formId) where.form = { equals: formId };
|
|
2075
|
+
if (status) where.status = { equals: status };
|
|
2076
|
+
else where.status = { not_equals: "spam" };
|
|
2077
|
+
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
2078
|
+
let form;
|
|
2079
|
+
if (format === "csv") {
|
|
2080
|
+
if (!formId) {
|
|
2081
|
+
return Response.json(
|
|
2082
|
+
{
|
|
2083
|
+
error: {
|
|
2084
|
+
code: "VALIDATION_ERROR",
|
|
2085
|
+
message: "CSV export requires a form parameter."
|
|
2086
|
+
}
|
|
2087
|
+
},
|
|
2088
|
+
{ status: 400 }
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
const formsSlugDeclared = resolvedConfig.formOverrides.slug;
|
|
2092
|
+
const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
|
|
2093
|
+
const formResult = await ctx.services.collections.listEntries(
|
|
2094
|
+
formsSlug,
|
|
2095
|
+
{ where: { id: { equals: formId } } },
|
|
2096
|
+
opts
|
|
2097
|
+
);
|
|
2098
|
+
form = formResult.data[0];
|
|
2099
|
+
if (!form) {
|
|
2100
|
+
return Response.json(
|
|
2101
|
+
{ error: { code: "NOT_FOUND", message: "Form not found." } },
|
|
2102
|
+
{ status: 404 }
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
const MAX_EXPORT_ROWS = 5e4;
|
|
2107
|
+
const items = [];
|
|
2108
|
+
const pageSize = 200;
|
|
2109
|
+
let truncated = false;
|
|
2110
|
+
for (let page = 1; ; page += 1) {
|
|
2111
|
+
const result = await ctx.services.collections.listEntries(
|
|
2112
|
+
slug,
|
|
2113
|
+
{ where, pagination: { limit: pageSize, page } },
|
|
2114
|
+
opts
|
|
2115
|
+
);
|
|
2116
|
+
items.push(...result.data);
|
|
2117
|
+
const pageWasFull = result.data.length === pageSize;
|
|
2118
|
+
if (items.length >= MAX_EXPORT_ROWS) {
|
|
2119
|
+
truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
|
|
2120
|
+
items.length = MAX_EXPORT_ROWS;
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
if (!pageWasFull) break;
|
|
2124
|
+
}
|
|
2125
|
+
const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
|
|
2126
|
+
if (format !== "csv" || !form) {
|
|
2127
|
+
return Response.json({ items }, { headers: truncationHeaders });
|
|
2128
|
+
}
|
|
2129
|
+
const csv = exportToCSV(
|
|
2130
|
+
asSubmissionDocuments(items),
|
|
2131
|
+
asFormDocument(form)
|
|
2132
|
+
);
|
|
2133
|
+
const filename = generateExportFilename(
|
|
2134
|
+
typeof form.slug === "string" ? form.slug : "form",
|
|
2135
|
+
"csv"
|
|
2136
|
+
);
|
|
2137
|
+
return new Response(csv, {
|
|
2138
|
+
headers: {
|
|
2139
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
2140
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
2141
|
+
...truncationHeaders
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
},
|
|
2146
|
+
{
|
|
2147
|
+
// The builder UI reads the host's resolved field enable/disable map
|
|
2148
|
+
// from here, so the plugin option actually gates the type picker.
|
|
2149
|
+
// Options resolve server-side only; this is the one channel the
|
|
2150
|
+
// admin client has to them.
|
|
2151
|
+
method: "GET",
|
|
2152
|
+
path: "/builder-config",
|
|
2153
|
+
// Derived from the forms slug like the menu entry: with an
|
|
2154
|
+
// overridden slug, users hold read-<slug>, not read-forms.
|
|
2155
|
+
requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
|
|
2156
|
+
handler: (_req, ctx) => Promise.resolve(
|
|
2157
|
+
Response.json({
|
|
2158
|
+
fields: resolvedConfig.fields,
|
|
2159
|
+
// Notification defaults the builder surfaces: defaultToEmail
|
|
2160
|
+
// seeds a new form's first rule, defaultFrom renders as the
|
|
2161
|
+
// inherited sender placeholder.
|
|
2162
|
+
notifications: {
|
|
2163
|
+
defaultFrom: resolvedConfig.notifications.defaultFrom,
|
|
2164
|
+
defaultToEmail: resolvedConfig.notifications.defaultToEmail
|
|
2165
|
+
},
|
|
2166
|
+
// Spam defaults the Settings tab surfaces, so per-form
|
|
2167
|
+
// override selects can show what "inherit" resolves to.
|
|
2168
|
+
spamProtection: {
|
|
2169
|
+
honeypot: resolvedConfig.spamProtection.honeypot,
|
|
2170
|
+
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
|
|
2171
|
+
},
|
|
2172
|
+
// Runtime-resolved collection slugs (through ctx.self, so a
|
|
2173
|
+
// framework .rename() is honored too) — admin components
|
|
2174
|
+
// never hardcode "forms"/"form-submissions".
|
|
2175
|
+
// Names only. A pattern may be a function, which does not
|
|
2176
|
+
// survive JSON and which the browser has no use for — the
|
|
2177
|
+
// admin picks a document, the server builds the URL.
|
|
2178
|
+
redirectCollections: Object.keys(
|
|
2179
|
+
resolvedConfig.redirectRelationships
|
|
2180
|
+
),
|
|
2181
|
+
slugs: {
|
|
2182
|
+
forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
|
|
2183
|
+
submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
|
|
2184
|
+
}
|
|
2185
|
+
})
|
|
2186
|
+
)
|
|
2187
|
+
}
|
|
2188
|
+
]
|
|
2189
|
+
},
|
|
2190
|
+
// Forms is a first-class destination, not a plugin detail: standalone
|
|
2191
|
+
// placement gives it its own main-rail icon after Media, and its
|
|
2192
|
+
// sub-sidebar lists the plugin's collections (Forms, Submissions) —
|
|
2193
|
+
// no separate menu contribution, so "Forms" exists exactly once.
|
|
2194
|
+
// Hosts preferring the Plugins section override placement in config.
|
|
2195
|
+
// (The old admin.settings.component that rendered a second full builder
|
|
2196
|
+
// at /admin/plugins/<slug> is gone; the collection Edit-view override
|
|
2197
|
+
// is the single FormBuilderView mount.)
|
|
2198
|
+
admin: {
|
|
2199
|
+
placement: "standalone",
|
|
2200
|
+
after: "media",
|
|
2201
|
+
order: 50,
|
|
2202
|
+
appearance: { icon: "FileText", label: "Forms" },
|
|
2203
|
+
description: "Create and manage forms with submission tracking"
|
|
2204
|
+
},
|
|
2205
|
+
// -- Init ----------------------------------------------------------------
|
|
2206
|
+
// Registers an afterCreate hook on submissions to send email notifications.
|
|
2207
|
+
async init(nextly) {
|
|
2208
|
+
const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
2209
|
+
const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
|
|
2210
|
+
nextly.logger.info("Form Builder plugin initialized", {
|
|
2211
|
+
formsCollection: resolvedConfig.formOverrides.slug,
|
|
2212
|
+
submissionsCollection: submissionSlug,
|
|
2213
|
+
enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
|
|
2214
|
+
});
|
|
2215
|
+
for (const slug of Object.keys(
|
|
2216
|
+
resolvedConfig.redirectTargetLocalization
|
|
2217
|
+
)) {
|
|
2218
|
+
delete resolvedConfig.redirectTargetLocalization[slug];
|
|
2219
|
+
}
|
|
2220
|
+
await Promise.all(
|
|
2221
|
+
Object.keys(resolvedConfig.redirectRelationships).map(
|
|
2222
|
+
async (declared) => {
|
|
2223
|
+
const slug = nextly.self.collections[declared] ?? declared;
|
|
2224
|
+
try {
|
|
2225
|
+
const collection = await nextly.services.collections.getCollection(slug, {});
|
|
2226
|
+
const localized = collection?.localized;
|
|
2227
|
+
if (typeof localized === "boolean") {
|
|
2228
|
+
resolvedConfig.redirectTargetLocalization[declared] = localized;
|
|
2229
|
+
}
|
|
2230
|
+
} catch {
|
|
2231
|
+
nextly.logger.debug?.(
|
|
2232
|
+
"Redirect target localization could not be read",
|
|
2233
|
+
{ collection: slug }
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
)
|
|
2238
|
+
);
|
|
2239
|
+
nextly.__formBuilderConfig = resolvedConfig;
|
|
2240
|
+
if (resolvedConfig.beforeEmail) {
|
|
2241
|
+
nextly.filters.add(
|
|
2242
|
+
"form-builder.beforeEmail",
|
|
2243
|
+
(emails, ctx) => resolvedConfig.beforeEmail({
|
|
2244
|
+
emails,
|
|
2245
|
+
// Boundary: loose runtime documents → the user's typed contract.
|
|
2246
|
+
form: asFormDocument(ctx.form),
|
|
2247
|
+
submission: asSubmissionDocument(ctx.submission)
|
|
2248
|
+
})
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
2252
|
+
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
2253
|
+
nextly.hooks.on(
|
|
2254
|
+
"beforeChange",
|
|
2255
|
+
submissionSlug,
|
|
2256
|
+
async (context) => prepareSubmissionForWrite(context, formsSlug, nextly)
|
|
2257
|
+
);
|
|
2258
|
+
nextly.hooks.on(
|
|
2259
|
+
"afterCreate",
|
|
2260
|
+
submissionSlug,
|
|
2261
|
+
async (context) => {
|
|
2262
|
+
await handleSubmissionCreated(context, resolvedConfig, nextly);
|
|
2263
|
+
}
|
|
2264
|
+
);
|
|
2265
|
+
nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
|
|
2266
|
+
const ctx = context;
|
|
2267
|
+
if (ctx.data && ctx.data.data !== void 0) {
|
|
2268
|
+
ctx.data.editedAt = /* @__PURE__ */ new Date();
|
|
2269
|
+
ctx.data.editedBy = ctx.user?.id ?? null;
|
|
2270
|
+
}
|
|
2271
|
+
return ctx.data;
|
|
2032
2272
|
});
|
|
2273
|
+
nextly.hooks.on(
|
|
2274
|
+
"afterRead",
|
|
2275
|
+
formsSlug,
|
|
2276
|
+
(context) => injectSubmissionCount(context, submissionSlug, nextly)
|
|
2277
|
+
);
|
|
2033
2278
|
}
|
|
2034
2279
|
});
|
|
2280
|
+
return {
|
|
2281
|
+
plugin,
|
|
2282
|
+
collections: [formsCol, submissionsCol],
|
|
2283
|
+
config: resolvedConfig
|
|
2284
|
+
};
|
|
2035
2285
|
}
|
|
2036
|
-
function
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
case "textarea": {
|
|
2058
|
-
const textField = field;
|
|
2059
|
-
if (textField.validation) {
|
|
2060
|
-
const { minLength, maxLength } = textField.validation;
|
|
2061
|
-
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
2062
|
-
errors.push({
|
|
2063
|
-
path: `${path}.validation`,
|
|
2064
|
-
message: "Min length cannot be greater than max length",
|
|
2065
|
-
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
2066
|
-
});
|
|
2286
|
+
function getFormBuilderConfig(nextly) {
|
|
2287
|
+
if (!nextly || typeof nextly !== "object") return void 0;
|
|
2288
|
+
return nextly.__formBuilderConfig;
|
|
2289
|
+
}
|
|
2290
|
+
function resolveFieldRef(ref, data) {
|
|
2291
|
+
const name = parseFieldRef(ref);
|
|
2292
|
+
if (name === null) return ref;
|
|
2293
|
+
const value = data[name];
|
|
2294
|
+
return typeof value === "string" ? value : "";
|
|
2295
|
+
}
|
|
2296
|
+
function collectAttachmentInputs(fields, submittedData) {
|
|
2297
|
+
const attachments = [];
|
|
2298
|
+
for (const field of fields) {
|
|
2299
|
+
if (field.type !== "file" || !field.attachToEmail) continue;
|
|
2300
|
+
const name = field.name;
|
|
2301
|
+
const value = submittedData[name];
|
|
2302
|
+
if (value == null) continue;
|
|
2303
|
+
if (Array.isArray(value)) {
|
|
2304
|
+
for (const item of value) {
|
|
2305
|
+
if (typeof item === "string" && item) {
|
|
2306
|
+
attachments.push({ mediaId: item });
|
|
2067
2307
|
}
|
|
2068
2308
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
case "file": {
|
|
2072
|
-
const fileField = field;
|
|
2073
|
-
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
2074
|
-
errors.push({
|
|
2075
|
-
path: `${path}.maxFileSize`,
|
|
2076
|
-
message: "File maxFileSize must be a positive number",
|
|
2077
|
-
code: "FILE_MAX_SIZE_INVALID"
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
break;
|
|
2309
|
+
} else if (typeof value === "string" && value) {
|
|
2310
|
+
attachments.push({ mediaId: value });
|
|
2081
2311
|
}
|
|
2082
2312
|
}
|
|
2313
|
+
return attachments;
|
|
2083
2314
|
}
|
|
2084
|
-
function
|
|
2085
|
-
if (
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
errors.push({
|
|
2092
|
-
path: `${path}.type`,
|
|
2093
|
-
message: "Field type is required",
|
|
2094
|
-
code: "FIELD_TYPE_REQUIRED"
|
|
2095
|
-
});
|
|
2096
|
-
return;
|
|
2097
|
-
}
|
|
2098
|
-
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
2099
|
-
if (!isValidType) {
|
|
2100
|
-
let fieldTypeRepr;
|
|
2101
|
-
if (typeof fieldType === "object" && fieldType !== null) {
|
|
2102
|
-
fieldTypeRepr = JSON.stringify(fieldType);
|
|
2103
|
-
} else {
|
|
2104
|
-
fieldTypeRepr = String(fieldType);
|
|
2315
|
+
function parseJsonColumn(value) {
|
|
2316
|
+
if (typeof value === "string") {
|
|
2317
|
+
try {
|
|
2318
|
+
const parsed = JSON.parse(value);
|
|
2319
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2320
|
+
} catch {
|
|
2321
|
+
return {};
|
|
2105
2322
|
}
|
|
2106
|
-
errors.push({
|
|
2107
|
-
path: `${path}.type`,
|
|
2108
|
-
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
2109
|
-
code: "FIELD_TYPE_INVALID"
|
|
2110
|
-
});
|
|
2111
|
-
return;
|
|
2112
|
-
}
|
|
2113
|
-
validateFieldName(f.name, path, errors, seenNames);
|
|
2114
|
-
if (fieldType !== "hidden") {
|
|
2115
|
-
validateFieldLabel(f.label, path, errors);
|
|
2116
|
-
}
|
|
2117
|
-
validateFieldByType(field, path, errors);
|
|
2118
|
-
if (f.conditionalLogic) {
|
|
2119
|
-
validateConditionalLogic(field, path, errors);
|
|
2120
2323
|
}
|
|
2324
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
2121
2325
|
}
|
|
2122
|
-
function
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
validateField(field, fieldPath, errors, seenNames);
|
|
2127
|
-
});
|
|
2326
|
+
function normalizeAddressList(addresses) {
|
|
2327
|
+
if (!Array.isArray(addresses)) return void 0;
|
|
2328
|
+
const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
|
|
2329
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
2128
2330
|
}
|
|
2129
|
-
function
|
|
2130
|
-
const
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2331
|
+
function buildNotificationEmails(input) {
|
|
2332
|
+
const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
|
|
2333
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2334
|
+
const emails = [];
|
|
2335
|
+
const skipped = [];
|
|
2336
|
+
for (const notification of notifications) {
|
|
2337
|
+
if (!notification.enabled) continue;
|
|
2338
|
+
if (!notification.templateSlug) {
|
|
2339
|
+
skipped.push({ notificationId: notification.id, reason: "no-template" });
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
const key = notification.id || JSON.stringify(notification);
|
|
2343
|
+
if (seen.has(key)) continue;
|
|
2344
|
+
seen.add(key);
|
|
2345
|
+
if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
|
|
2346
|
+
skipped.push({
|
|
2347
|
+
notificationId: notification.id,
|
|
2348
|
+
reason: "condition-unmet"
|
|
2349
|
+
});
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
|
|
2353
|
+
if (!to) {
|
|
2354
|
+
skipped.push({
|
|
2355
|
+
notificationId: notification.id,
|
|
2356
|
+
reason: "empty-recipient"
|
|
2357
|
+
});
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
const cc = normalizeAddressList(notification.cc);
|
|
2361
|
+
const bcc = normalizeAddressList(notification.bcc);
|
|
2362
|
+
const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
|
|
2363
|
+
const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
|
|
2364
|
+
emails.push({
|
|
2365
|
+
to,
|
|
2366
|
+
templateSlug: notification.templateSlug,
|
|
2367
|
+
variables: {
|
|
2368
|
+
...submittedData,
|
|
2369
|
+
formName,
|
|
2370
|
+
submissionId
|
|
2371
|
+
},
|
|
2372
|
+
providerId: notification.providerId,
|
|
2373
|
+
from,
|
|
2374
|
+
replyTo,
|
|
2375
|
+
cc,
|
|
2376
|
+
bcc,
|
|
2377
|
+
notificationId: notification.id
|
|
2136
2378
|
});
|
|
2137
|
-
return;
|
|
2138
2379
|
}
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2380
|
+
return { emails, skipped };
|
|
2381
|
+
}
|
|
2382
|
+
function parentFormId(submission) {
|
|
2383
|
+
const raw = submission.form;
|
|
2384
|
+
if (typeof raw === "string") return raw;
|
|
2385
|
+
if (raw && typeof raw === "object") {
|
|
2386
|
+
const maybeId = raw.id;
|
|
2387
|
+
if (typeof maybeId === "string") return maybeId;
|
|
2146
2388
|
}
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2389
|
+
return null;
|
|
2390
|
+
}
|
|
2391
|
+
function submittedPayload(raw) {
|
|
2392
|
+
if (typeof raw === "string") {
|
|
2393
|
+
try {
|
|
2394
|
+
const parsed = JSON.parse(raw);
|
|
2395
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2396
|
+
return parsed;
|
|
2397
|
+
}
|
|
2398
|
+
} catch {
|
|
2399
|
+
}
|
|
2400
|
+
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
2401
|
+
return raw;
|
|
2154
2402
|
}
|
|
2155
|
-
|
|
2403
|
+
throw NextlyError4.validation({
|
|
2404
|
+
errors: [
|
|
2405
|
+
{
|
|
2406
|
+
path: "data",
|
|
2407
|
+
code: "INVALID",
|
|
2408
|
+
message: "Submission data must be an object."
|
|
2409
|
+
}
|
|
2410
|
+
]
|
|
2411
|
+
});
|
|
2156
2412
|
}
|
|
2157
|
-
function
|
|
2158
|
-
if (
|
|
2159
|
-
|
|
2413
|
+
function updateNeedsChecking(submission, stored) {
|
|
2414
|
+
if (submission.data !== void 0) return true;
|
|
2415
|
+
if (stored?.data === void 0) return false;
|
|
2416
|
+
if (submission.form !== void 0) return true;
|
|
2417
|
+
return stored.status === "spam" && submission.status !== void 0 && submission.status !== "spam";
|
|
2418
|
+
}
|
|
2419
|
+
async function prepareSubmissionForWrite(context, formsSlug, nextly) {
|
|
2420
|
+
const ctx = context;
|
|
2421
|
+
const submission = ctx.data;
|
|
2422
|
+
if (!submission || typeof submission !== "object") return ctx.data;
|
|
2423
|
+
const stored = ctx.originalData;
|
|
2424
|
+
const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
|
|
2425
|
+
if (!formId) return ctx.data;
|
|
2426
|
+
if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
|
|
2427
|
+
return ctx.data;
|
|
2160
2428
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2429
|
+
const incoming = submittedPayload(
|
|
2430
|
+
submission.data !== void 0 ? submission.data : stored?.data
|
|
2431
|
+
);
|
|
2432
|
+
const marks = submissionMarks(submission);
|
|
2433
|
+
const form = await formToCheckAgainst(marks, formsSlug, formId, nextly);
|
|
2434
|
+
const fields = form.fields;
|
|
2435
|
+
const spam = ctx.operation === "create" && ctx.req?.http ? await judgeSubmission(incoming, form, ctx.req.http, nextly) : void 0;
|
|
2436
|
+
if (spam?.reason === "rate_limit") {
|
|
2437
|
+
throw NextlyError4.rateLimited();
|
|
2438
|
+
}
|
|
2439
|
+
const prepared = prepareSubmission({
|
|
2440
|
+
data: incoming,
|
|
2441
|
+
fields,
|
|
2442
|
+
validate: marks?.keepAsEvidence !== true && spam === void 0
|
|
2443
|
+
});
|
|
2444
|
+
if (prepared.validationErrors) {
|
|
2445
|
+
throw NextlyError4.validation({
|
|
2446
|
+
errors: Object.entries(prepared.validationErrors).map(
|
|
2447
|
+
([path, message]) => ({ path, code: "INVALID", message })
|
|
2448
|
+
)
|
|
2166
2449
|
});
|
|
2167
|
-
return;
|
|
2168
2450
|
}
|
|
2169
|
-
|
|
2170
|
-
if (
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
path: "settings.redirectUrl",
|
|
2174
|
-
message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
|
|
2175
|
-
code: "REDIRECT_URL_REQUIRED"
|
|
2176
|
-
});
|
|
2177
|
-
}
|
|
2451
|
+
ctx.data = { ...submission, data: prepared.data };
|
|
2452
|
+
if (spam) {
|
|
2453
|
+
ctx.data.status = "spam";
|
|
2454
|
+
ctx.data.spamReason = spam.reason ?? null;
|
|
2178
2455
|
}
|
|
2179
|
-
if (
|
|
2180
|
-
|
|
2181
|
-
path: "settings.redirectPage",
|
|
2182
|
-
message: "A page is required when confirmation type is 'relationship'",
|
|
2183
|
-
code: "REDIRECT_PAGE_REQUIRED"
|
|
2184
|
-
});
|
|
2456
|
+
if (ctx.operation !== "create" && submission.data === void 0) {
|
|
2457
|
+
stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
|
|
2185
2458
|
}
|
|
2459
|
+
return ctx.data;
|
|
2186
2460
|
}
|
|
2187
|
-
function
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
code: "ACCESS_INVALID_TYPE"
|
|
2461
|
+
async function formToCheckAgainst(marks, formsSlug, formId, nextly) {
|
|
2462
|
+
const handedOver = marks?.form?.id === formId ? marks.form : null;
|
|
2463
|
+
const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
|
|
2464
|
+
if (!form || !Array.isArray(form.fields)) {
|
|
2465
|
+
throw NextlyError4.validation({
|
|
2466
|
+
errors: [
|
|
2467
|
+
{
|
|
2468
|
+
path: "form",
|
|
2469
|
+
code: "INVALID",
|
|
2470
|
+
message: "The form this submission belongs to could not be read, so the submission could not be checked against it."
|
|
2471
|
+
}
|
|
2472
|
+
]
|
|
2200
2473
|
});
|
|
2201
|
-
return;
|
|
2202
2474
|
}
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2475
|
+
return form;
|
|
2476
|
+
}
|
|
2477
|
+
function rateLimitKeyFor(form) {
|
|
2478
|
+
const { id } = form;
|
|
2479
|
+
return typeof id === "string" && id.length > 0 ? id : "unidentified-form";
|
|
2480
|
+
}
|
|
2481
|
+
async function judgeSubmission(payload, form, http, nextly) {
|
|
2482
|
+
const config = getFormBuilderConfig(nextly);
|
|
2483
|
+
if (!config) return void 0;
|
|
2484
|
+
const declared = new Set(
|
|
2485
|
+
(Array.isArray(form.fields) ? form.fields : []).map((field) => field.name).filter((name) => typeof name === "string")
|
|
2486
|
+
);
|
|
2487
|
+
const undeclared = Object.fromEntries(
|
|
2488
|
+
Object.entries(payload).filter(([key]) => !declared.has(key))
|
|
2489
|
+
);
|
|
2490
|
+
const settings = form.settings ?? {};
|
|
2491
|
+
const verdict = await checkSpam({
|
|
2492
|
+
data: undeclared,
|
|
2493
|
+
ipAddress: http.ip ?? void 0,
|
|
2494
|
+
formSlug: rateLimitKeyFor(form),
|
|
2495
|
+
config: {
|
|
2496
|
+
honeypot: settings.honeypotEnabled ?? config.spamProtection.honeypot,
|
|
2497
|
+
rateLimit: config.spamProtection.rateLimit
|
|
2213
2498
|
}
|
|
2214
|
-
}
|
|
2499
|
+
});
|
|
2500
|
+
return verdict.isSpam ? verdict : void 0;
|
|
2215
2501
|
}
|
|
2216
|
-
function
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
validateSettings(config.settings, errors);
|
|
2221
|
-
validateAccess(config.access, errors);
|
|
2222
|
-
return {
|
|
2223
|
-
valid: errors.length === 0,
|
|
2224
|
-
errors
|
|
2225
|
-
};
|
|
2502
|
+
function stampDerivedEdit(row, user, before, after) {
|
|
2503
|
+
if (sameSubmittedPayload(before, after)) return;
|
|
2504
|
+
row.editedAt = /* @__PURE__ */ new Date();
|
|
2505
|
+
row.editedBy = user?.id ?? null;
|
|
2226
2506
|
}
|
|
2227
|
-
function
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2507
|
+
async function handleSubmissionCreated(context, config, nextly) {
|
|
2508
|
+
if (!config.notifications.enabled) return;
|
|
2509
|
+
const submission = context.data;
|
|
2510
|
+
if (!submission) return;
|
|
2511
|
+
if (submission.status === "spam") return;
|
|
2512
|
+
const formId = parentFormId(submission);
|
|
2513
|
+
if (!formId) return;
|
|
2514
|
+
const declaredFormsSlug = config.formOverrides.slug;
|
|
2515
|
+
const form = await fetchParentForm(
|
|
2516
|
+
nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,
|
|
2517
|
+
formId,
|
|
2518
|
+
nextly
|
|
2519
|
+
);
|
|
2520
|
+
if (!form) return;
|
|
2521
|
+
const notifications = Array.isArray(form.notifications) ? form.notifications : [];
|
|
2522
|
+
if (notifications.length === 0) return;
|
|
2523
|
+
const emailService = nextly.services.email;
|
|
2524
|
+
if (!emailService) return;
|
|
2525
|
+
const submittedData = parseJsonColumn(submission.data);
|
|
2526
|
+
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
2527
|
+
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
2528
|
+
const { emails, skipped } = buildNotificationEmails({
|
|
2529
|
+
notifications,
|
|
2530
|
+
submittedData,
|
|
2531
|
+
formName: form.name,
|
|
2532
|
+
submissionId: submission.id,
|
|
2533
|
+
defaultFrom: config.notifications.defaultFrom
|
|
2534
|
+
});
|
|
2535
|
+
for (const skip of skipped) {
|
|
2536
|
+
if (skip.reason === "empty-recipient") {
|
|
2537
|
+
nextly.logger.warn?.(
|
|
2538
|
+
"Form Builder: empty recipient, skipping notification",
|
|
2539
|
+
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
2540
|
+
);
|
|
2541
|
+
} else {
|
|
2542
|
+
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
2543
|
+
notificationId: skip.notificationId,
|
|
2544
|
+
formSlug: form.slug,
|
|
2545
|
+
reason: skip.reason
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2235
2548
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
});
|
|
2267
|
-
var select3 = (config) => ({
|
|
2268
|
-
...config,
|
|
2269
|
-
type: "select"
|
|
2270
|
-
});
|
|
2271
|
-
var checkbox2 = (config) => ({
|
|
2272
|
-
...config,
|
|
2273
|
-
type: "checkbox"
|
|
2274
|
-
});
|
|
2275
|
-
var radio = (config) => ({
|
|
2276
|
-
...config,
|
|
2277
|
-
type: "radio"
|
|
2278
|
-
});
|
|
2279
|
-
var file = (config) => ({
|
|
2280
|
-
...config,
|
|
2281
|
-
type: "file"
|
|
2282
|
-
});
|
|
2283
|
-
var date3 = (config) => ({
|
|
2284
|
-
...config,
|
|
2285
|
-
type: "date"
|
|
2286
|
-
});
|
|
2287
|
-
var time = (config) => ({
|
|
2288
|
-
...config,
|
|
2289
|
-
type: "time"
|
|
2290
|
-
});
|
|
2291
|
-
var hidden = (config) => ({
|
|
2292
|
-
...config,
|
|
2293
|
-
type: "hidden"
|
|
2294
|
-
});
|
|
2295
|
-
|
|
2296
|
-
// src/handlers/submit-form.ts
|
|
2297
|
-
import { formAvailability, NO_SUCH_FORM } from "nextly";
|
|
2298
|
-
|
|
2299
|
-
// src/handlers/spam-detection.ts
|
|
2300
|
-
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
2301
|
-
async function checkSpam(options) {
|
|
2302
|
-
const { data, ipAddress, formSlug, config } = options;
|
|
2303
|
-
if (config.honeypot) {
|
|
2304
|
-
const honeypotResult = checkHoneypot(data);
|
|
2305
|
-
if (honeypotResult.isSpam) {
|
|
2306
|
-
return honeypotResult;
|
|
2549
|
+
if (emails.length === 0) return;
|
|
2550
|
+
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
|
|
2551
|
+
for (const email2 of finalEmails) {
|
|
2552
|
+
try {
|
|
2553
|
+
await emailService.sendWithTemplate(
|
|
2554
|
+
email2.templateSlug,
|
|
2555
|
+
email2.to,
|
|
2556
|
+
email2.variables,
|
|
2557
|
+
{
|
|
2558
|
+
providerId: email2.providerId,
|
|
2559
|
+
from: email2.from,
|
|
2560
|
+
replyTo: email2.replyTo,
|
|
2561
|
+
cc: email2.cc,
|
|
2562
|
+
bcc: email2.bcc,
|
|
2563
|
+
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
2564
|
+
}
|
|
2565
|
+
);
|
|
2566
|
+
nextly.logger.info?.("Form Builder: notification sent", {
|
|
2567
|
+
formSlug: form.slug,
|
|
2568
|
+
to: email2.to,
|
|
2569
|
+
templateSlug: email2.templateSlug,
|
|
2570
|
+
notificationId: email2.notificationId,
|
|
2571
|
+
attachmentCount: fileAttachments.length
|
|
2572
|
+
});
|
|
2573
|
+
} catch (err) {
|
|
2574
|
+
nextly.logger.error?.("Form Builder: notification failed", {
|
|
2575
|
+
to: email2.to,
|
|
2576
|
+
templateSlug: email2.templateSlug,
|
|
2577
|
+
notificationId: email2.notificationId,
|
|
2578
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2579
|
+
});
|
|
2307
2580
|
}
|
|
2308
2581
|
}
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2582
|
+
}
|
|
2583
|
+
async function injectSubmissionCount(context, submissionSlug, nextly) {
|
|
2584
|
+
const hook = context;
|
|
2585
|
+
if (hook.context?.[SCHEMA_ONLY_READ] === true) return;
|
|
2586
|
+
const records = Array.isArray(hook.data) ? hook.data : hook.data ? [hook.data] : [];
|
|
2587
|
+
await Promise.all(
|
|
2588
|
+
records.map(async (record) => {
|
|
2589
|
+
const form = record;
|
|
2590
|
+
if (typeof form.id !== "string") return;
|
|
2591
|
+
try {
|
|
2592
|
+
form.submissionCount = await nextly.services.collections.count(
|
|
2593
|
+
submissionSlug,
|
|
2594
|
+
{
|
|
2595
|
+
where: {
|
|
2596
|
+
form: { equals: form.id },
|
|
2597
|
+
status: { not_equals: "spam" }
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
{ as: "system" }
|
|
2601
|
+
);
|
|
2602
|
+
} catch {
|
|
2603
|
+
form.submissionCount = 0;
|
|
2604
|
+
}
|
|
2605
|
+
})
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
var SCHEMA_ONLY_READ = "formBuilder.schemaOnlyRead";
|
|
2609
|
+
async function fetchParentForm(formsSlug, formId, nextly) {
|
|
2610
|
+
try {
|
|
2611
|
+
const form = await nextly.services.collections.findEntryById(
|
|
2612
|
+
formsSlug,
|
|
2613
|
+
formId,
|
|
2614
|
+
{ as: "system", context: { [SCHEMA_ONLY_READ]: true } }
|
|
2314
2615
|
);
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2616
|
+
return form;
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
if (!NextlyError4.isNotFound(err)) throw err;
|
|
2619
|
+
nextly.logger.error?.("Form Builder: form not found", {
|
|
2620
|
+
formId,
|
|
2621
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2622
|
+
});
|
|
2623
|
+
return null;
|
|
2318
2624
|
}
|
|
2319
|
-
return { isSpam: false };
|
|
2320
2625
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
"
|
|
2327
|
-
"
|
|
2328
|
-
|
|
2329
|
-
"
|
|
2330
|
-
//
|
|
2331
|
-
"
|
|
2332
|
-
|
|
2626
|
+
|
|
2627
|
+
// src/config/validate-form.ts
|
|
2628
|
+
import { isPluginFieldTypeOnSurface } from "nextly";
|
|
2629
|
+
var RESERVED_FORM_SLUGS = [
|
|
2630
|
+
// API routes
|
|
2631
|
+
"api",
|
|
2632
|
+
"submit",
|
|
2633
|
+
"submissions",
|
|
2634
|
+
"export",
|
|
2635
|
+
// System slugs
|
|
2636
|
+
"admin",
|
|
2637
|
+
"new",
|
|
2638
|
+
"create",
|
|
2639
|
+
"edit",
|
|
2640
|
+
"delete"
|
|
2333
2641
|
];
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2642
|
+
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
2643
|
+
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
2644
|
+
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
2645
|
+
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
2646
|
+
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
2647
|
+
function isReservedSlug(slug) {
|
|
2648
|
+
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
2649
|
+
}
|
|
2650
|
+
function validateSlug(slug, errors) {
|
|
2651
|
+
const path = "slug";
|
|
2652
|
+
if (!slug) {
|
|
2653
|
+
errors.push({
|
|
2654
|
+
path,
|
|
2655
|
+
message: "Form slug is required",
|
|
2656
|
+
code: "SLUG_REQUIRED"
|
|
2657
|
+
});
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
if (typeof slug !== "string") {
|
|
2661
|
+
errors.push({
|
|
2662
|
+
path,
|
|
2663
|
+
message: "Form slug must be a string",
|
|
2664
|
+
code: "SLUG_INVALID_TYPE"
|
|
2665
|
+
});
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
if (slug.length < 1) {
|
|
2669
|
+
errors.push({
|
|
2670
|
+
path,
|
|
2671
|
+
message: "Form slug must be at least 1 character",
|
|
2672
|
+
code: "SLUG_TOO_SHORT"
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
if (slug.length > 50) {
|
|
2676
|
+
errors.push({
|
|
2677
|
+
path,
|
|
2678
|
+
message: "Form slug must be at most 50 characters",
|
|
2679
|
+
code: "SLUG_TOO_LONG"
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
2683
|
+
errors.push({
|
|
2684
|
+
path,
|
|
2685
|
+
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
2686
|
+
code: "SLUG_INVALID_FORMAT"
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
if (isReservedSlug(slug)) {
|
|
2690
|
+
errors.push({
|
|
2691
|
+
path,
|
|
2692
|
+
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
2693
|
+
code: "SLUG_RESERVED"
|
|
2694
|
+
});
|
|
2353
2695
|
}
|
|
2354
|
-
return { isSpam: false };
|
|
2355
2696
|
}
|
|
2356
|
-
function
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2697
|
+
function validateFieldName(name, path, errors, seenNames) {
|
|
2698
|
+
if (!name) {
|
|
2699
|
+
errors.push({
|
|
2700
|
+
path: `${path}.name`,
|
|
2701
|
+
message: "Field name is required",
|
|
2702
|
+
code: "FIELD_NAME_REQUIRED"
|
|
2703
|
+
});
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
if (typeof name !== "string") {
|
|
2707
|
+
errors.push({
|
|
2708
|
+
path: `${path}.name`,
|
|
2709
|
+
message: "Field name must be a string",
|
|
2710
|
+
code: "FIELD_NAME_REQUIRED"
|
|
2711
|
+
});
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
2715
|
+
errors.push({
|
|
2716
|
+
path: `${path}.name`,
|
|
2717
|
+
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
2718
|
+
code: "FIELD_NAME_INVALID_FORMAT"
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
const nameLower = name.toLowerCase();
|
|
2722
|
+
if (seenNames.has(nameLower)) {
|
|
2723
|
+
errors.push({
|
|
2724
|
+
path: `${path}.name`,
|
|
2725
|
+
message: `Duplicate field name '${name}'`,
|
|
2726
|
+
code: "FIELD_NAME_DUPLICATE"
|
|
2375
2727
|
});
|
|
2376
2728
|
} else {
|
|
2377
|
-
|
|
2729
|
+
seenNames.add(nameLower);
|
|
2378
2730
|
}
|
|
2379
|
-
return { isSpam: false };
|
|
2380
2731
|
}
|
|
2381
|
-
function
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
}
|
|
2732
|
+
function validateFieldLabel(label, path, errors) {
|
|
2733
|
+
if (!label || typeof label === "string" && label.trim() === "") {
|
|
2734
|
+
errors.push({
|
|
2735
|
+
path: `${path}.label`,
|
|
2736
|
+
message: "Field label is required",
|
|
2737
|
+
code: "FIELD_LABEL_REQUIRED"
|
|
2738
|
+
});
|
|
2389
2739
|
}
|
|
2390
|
-
return removed;
|
|
2391
|
-
}
|
|
2392
|
-
function getRateLimitStoreSize() {
|
|
2393
|
-
return rateLimitStore.size;
|
|
2394
2740
|
}
|
|
2395
|
-
function
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
if (now - entry.timestamp > config.windowMs) {
|
|
2406
|
-
return false;
|
|
2741
|
+
function validateOptions(field, path, errors) {
|
|
2742
|
+
const fieldWithOptions = field;
|
|
2743
|
+
const options = fieldWithOptions.options;
|
|
2744
|
+
if (!options) {
|
|
2745
|
+
errors.push({
|
|
2746
|
+
path: `${path}.options`,
|
|
2747
|
+
message: `${field.type} field must have an 'options' array`,
|
|
2748
|
+
code: "OPTIONS_REQUIRED"
|
|
2749
|
+
});
|
|
2750
|
+
return;
|
|
2407
2751
|
}
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
formSlug
|
|
2422
|
-
});
|
|
2423
|
-
return { success: false, error: NO_SUCH_FORM };
|
|
2424
|
-
}
|
|
2425
|
-
const availability = formAvailability(form);
|
|
2426
|
-
if (availability.kind !== "open") {
|
|
2427
|
-
logger.info?.("Form submission rejected - form not accepting", {
|
|
2428
|
-
formSlug,
|
|
2429
|
-
status: form.status,
|
|
2430
|
-
availability: availability.kind
|
|
2431
|
-
});
|
|
2432
|
-
return {
|
|
2433
|
-
success: false,
|
|
2434
|
-
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
2435
|
-
};
|
|
2436
|
-
}
|
|
2437
|
-
const settings = normalizeFormSettings(form.settings);
|
|
2438
|
-
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
2439
|
-
logger.warn?.(
|
|
2440
|
-
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
2441
|
-
{ formSlug }
|
|
2442
|
-
);
|
|
2443
|
-
}
|
|
2444
|
-
if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
|
|
2445
|
-
const existing = await collections.count(
|
|
2446
|
-
pluginConfig.formSubmissionOverrides.slug,
|
|
2447
|
-
{
|
|
2448
|
-
where: {
|
|
2449
|
-
form: { equals: form.id },
|
|
2450
|
-
ipAddress: { equals: metadata.ipAddress },
|
|
2451
|
-
status: { not_equals: "spam" }
|
|
2452
|
-
}
|
|
2453
|
-
},
|
|
2454
|
-
{ as: "system" }
|
|
2455
|
-
);
|
|
2456
|
-
if (existing > 0) {
|
|
2457
|
-
logger.info?.("Repeat submission rejected (single-submission form)", {
|
|
2458
|
-
formSlug,
|
|
2459
|
-
ipAddress: metadata.ipAddress
|
|
2460
|
-
});
|
|
2461
|
-
return {
|
|
2462
|
-
success: false,
|
|
2463
|
-
error: "You have already submitted this form."
|
|
2464
|
-
};
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
2467
|
-
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
2468
|
-
const undeclaredData = Object.fromEntries(
|
|
2469
|
-
Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
|
|
2470
|
-
);
|
|
2471
|
-
const spamResult = await checkSpam({
|
|
2472
|
-
data: undeclaredData,
|
|
2473
|
-
ipAddress: metadata?.ipAddress,
|
|
2474
|
-
formSlug,
|
|
2475
|
-
config: {
|
|
2476
|
-
// Per-form overrides win where set; blank inherits the plugin config.
|
|
2477
|
-
honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
|
|
2478
|
-
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2479
|
-
recaptcha: {
|
|
2480
|
-
...pluginConfig.spamProtection.recaptcha,
|
|
2481
|
-
enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2752
|
+
if (!Array.isArray(options)) {
|
|
2753
|
+
errors.push({
|
|
2754
|
+
path: `${path}.options`,
|
|
2755
|
+
message: `${field.type} field 'options' must be an array`,
|
|
2756
|
+
code: "OPTIONS_REQUIRED"
|
|
2757
|
+
});
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
if (options.length === 0) {
|
|
2761
|
+
errors.push({
|
|
2762
|
+
path: `${path}.options`,
|
|
2763
|
+
message: `${field.type} field must have at least one option`,
|
|
2764
|
+
code: "OPTIONS_EMPTY"
|
|
2484
2765
|
});
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
function validateConditionalLogic(field, path, errors) {
|
|
2769
|
+
const { conditionalLogic } = field;
|
|
2770
|
+
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
|
|
2774
|
+
errors.push({
|
|
2775
|
+
path: `${path}.conditionalLogic.conditions`,
|
|
2776
|
+
message: "Conditional logic must have at least one condition",
|
|
2777
|
+
code: "CONDITIONS_REQUIRED"
|
|
2778
|
+
});
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
conditionalLogic.conditions.forEach((condition, index) => {
|
|
2782
|
+
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
2783
|
+
if (!condition.field) {
|
|
2784
|
+
errors.push({
|
|
2785
|
+
path: `${conditionPath}.field`,
|
|
2786
|
+
message: "Condition must specify a field name",
|
|
2787
|
+
code: "CONDITION_FIELD_REQUIRED"
|
|
2489
2788
|
});
|
|
2490
|
-
return { success: true };
|
|
2491
2789
|
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
ipAddress: metadata?.ipAddress
|
|
2790
|
+
if (!condition.comparison) {
|
|
2791
|
+
errors.push({
|
|
2792
|
+
path: `${conditionPath}.comparison`,
|
|
2793
|
+
message: "Condition must specify a comparison operator",
|
|
2794
|
+
code: "CONDITION_COMPARISON_REQUIRED"
|
|
2498
2795
|
});
|
|
2499
2796
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
function validateFieldByType(field, path, errors) {
|
|
2800
|
+
switch (field.type) {
|
|
2801
|
+
case "select":
|
|
2802
|
+
case "radio":
|
|
2803
|
+
validateOptions(field, path, errors);
|
|
2804
|
+
break;
|
|
2805
|
+
case "number": {
|
|
2806
|
+
const numberField = field;
|
|
2807
|
+
if (numberField.validation) {
|
|
2808
|
+
const { min, max } = numberField.validation;
|
|
2809
|
+
if (min !== void 0 && max !== void 0 && min > max) {
|
|
2810
|
+
errors.push({
|
|
2811
|
+
path: `${path}.validation`,
|
|
2812
|
+
message: "Min value cannot be greater than max value",
|
|
2813
|
+
code: "MIN_GREATER_THAN_MAX"
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
break;
|
|
2515
2818
|
}
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
form: { id: form.id, fields: form.fields }
|
|
2531
|
-
}),
|
|
2532
|
-
// Public form submission — create as system. No ambient user; an
|
|
2533
|
-
// empty context already resolves to system, but be explicit.
|
|
2534
|
-
{ as: "system" }
|
|
2535
|
-
);
|
|
2536
|
-
logger.info?.("Form submission created successfully", {
|
|
2537
|
-
formSlug,
|
|
2538
|
-
submissionId: submission.item.id,
|
|
2539
|
-
flaggedAsSpam: isContentSpam
|
|
2540
|
-
});
|
|
2541
|
-
if (submission.warnings) {
|
|
2542
|
-
logger.warn?.("Form submission side effects failed", {
|
|
2543
|
-
formSlug,
|
|
2544
|
-
submissionId: submission.item.id,
|
|
2545
|
-
warnings: submission.warnings
|
|
2546
|
-
});
|
|
2819
|
+
case "text":
|
|
2820
|
+
case "textarea": {
|
|
2821
|
+
const textField = field;
|
|
2822
|
+
if (textField.validation) {
|
|
2823
|
+
const { minLength, maxLength } = textField.validation;
|
|
2824
|
+
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
2825
|
+
errors.push({
|
|
2826
|
+
path: `${path}.validation`,
|
|
2827
|
+
message: "Min length cannot be greater than max length",
|
|
2828
|
+
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
break;
|
|
2547
2833
|
}
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2834
|
+
case "file": {
|
|
2835
|
+
const fileField = field;
|
|
2836
|
+
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
2837
|
+
errors.push({
|
|
2838
|
+
path: `${path}.maxFileSize`,
|
|
2839
|
+
message: "File maxFileSize must be a positive number",
|
|
2840
|
+
code: "FILE_MAX_SIZE_INVALID"
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
break;
|
|
2555
2844
|
}
|
|
2556
|
-
return {
|
|
2557
|
-
success: true,
|
|
2558
|
-
// The created ROW, not the envelope around it. Through the shared
|
|
2559
|
-
// conversion, which is where the unchecked step lives and says so; the
|
|
2560
|
-
// integration test reads `submission.id` off the result because nothing
|
|
2561
|
-
// at compile time will.
|
|
2562
|
-
submission: asSubmissionDocument(submission.item),
|
|
2563
|
-
redirect
|
|
2564
|
-
};
|
|
2565
|
-
} catch (error) {
|
|
2566
|
-
logger.error?.("Form submission error", {
|
|
2567
|
-
formSlug,
|
|
2568
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2569
|
-
});
|
|
2570
|
-
return {
|
|
2571
|
-
success: false,
|
|
2572
|
-
error: "An error occurred processing your submission. Please try again."
|
|
2573
|
-
};
|
|
2574
2845
|
}
|
|
2575
2846
|
}
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
const result = await collections.listEntries(
|
|
2580
|
-
pluginConfig.formOverrides.slug,
|
|
2581
|
-
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
2582
|
-
{ as: "system" }
|
|
2583
|
-
);
|
|
2584
|
-
const form = result.data?.[0];
|
|
2585
|
-
return form ? asFormDocument(form) : null;
|
|
2586
|
-
} catch {
|
|
2587
|
-
return null;
|
|
2847
|
+
function validateField(field, path, errors, seenNames) {
|
|
2848
|
+
if (!field || typeof field !== "object") {
|
|
2849
|
+
return;
|
|
2588
2850
|
}
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2851
|
+
const f = field;
|
|
2852
|
+
const fieldType = f.type;
|
|
2853
|
+
if (!fieldType) {
|
|
2854
|
+
errors.push({
|
|
2855
|
+
path: `${path}.type`,
|
|
2856
|
+
message: "Field type is required",
|
|
2857
|
+
code: "FIELD_TYPE_REQUIRED"
|
|
2858
|
+
});
|
|
2859
|
+
return;
|
|
2595
2860
|
}
|
|
2596
|
-
const
|
|
2597
|
-
if (!
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
const reference = parseRedirectReference(stored, Object.keys(patterns));
|
|
2609
|
-
if (!reference) {
|
|
2610
|
-
logger.warn?.("Form redirects to a page, but names no readable document", {
|
|
2611
|
-
form: form.slug
|
|
2861
|
+
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
2862
|
+
if (!isValidType) {
|
|
2863
|
+
let fieldTypeRepr;
|
|
2864
|
+
if (typeof fieldType === "object" && fieldType !== null) {
|
|
2865
|
+
fieldTypeRepr = JSON.stringify(fieldType);
|
|
2866
|
+
} else {
|
|
2867
|
+
fieldTypeRepr = String(fieldType);
|
|
2868
|
+
}
|
|
2869
|
+
errors.push({
|
|
2870
|
+
path: `${path}.type`,
|
|
2871
|
+
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
2872
|
+
code: "FIELD_TYPE_INVALID"
|
|
2612
2873
|
});
|
|
2613
|
-
return
|
|
2874
|
+
return;
|
|
2614
2875
|
}
|
|
2615
|
-
|
|
2616
|
-
if (
|
|
2617
|
-
|
|
2618
|
-
"Form redirects to a collection that is not in redirectRelationships",
|
|
2619
|
-
{ form: form.slug, collection: reference.collection }
|
|
2620
|
-
);
|
|
2621
|
-
return void 0;
|
|
2876
|
+
validateFieldName(f.name, path, errors, seenNames);
|
|
2877
|
+
if (fieldType !== "hidden") {
|
|
2878
|
+
validateFieldLabel(f.label, path, errors);
|
|
2622
2879
|
}
|
|
2623
|
-
|
|
2624
|
-
if (
|
|
2625
|
-
|
|
2626
|
-
target,
|
|
2627
|
-
pluginConfig.redirectTargetLocalization[reference.collection]
|
|
2628
|
-
) === "unreachable") {
|
|
2629
|
-
logger.warn?.("Form redirects to a page that has never been published", {
|
|
2630
|
-
form: form.slug,
|
|
2631
|
-
collection: reference.collection,
|
|
2632
|
-
target: reference.id
|
|
2633
|
-
});
|
|
2634
|
-
return void 0;
|
|
2880
|
+
validateFieldByType(field, path, errors);
|
|
2881
|
+
if (f.conditionalLogic) {
|
|
2882
|
+
validateConditionalLogic(field, path, errors);
|
|
2635
2883
|
}
|
|
2636
|
-
return buildUrl(pattern, target, reference.collection, form, pluginContext);
|
|
2637
2884
|
}
|
|
2638
|
-
function
|
|
2639
|
-
const
|
|
2640
|
-
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2885
|
+
function validateFieldsArray(fields, basePath, errors) {
|
|
2886
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
2887
|
+
fields.forEach((field, index) => {
|
|
2888
|
+
const fieldPath = `${basePath}[${index}]`;
|
|
2889
|
+
validateField(field, fieldPath, errors, seenNames);
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
function validateFields(fields, errors) {
|
|
2893
|
+
const path = "fields";
|
|
2894
|
+
if (!fields) {
|
|
2895
|
+
errors.push({
|
|
2896
|
+
path,
|
|
2897
|
+
message: "Form must have at least one field",
|
|
2898
|
+
code: "FIELDS_REQUIRED"
|
|
2899
|
+
});
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
if (!Array.isArray(fields)) {
|
|
2903
|
+
errors.push({
|
|
2904
|
+
path,
|
|
2905
|
+
message: "Form fields must be an array",
|
|
2906
|
+
code: "FIELDS_INVALID_TYPE"
|
|
2907
|
+
});
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
if (fields.length === 0) {
|
|
2911
|
+
errors.push({
|
|
2912
|
+
path,
|
|
2913
|
+
message: "Form must have at least one field",
|
|
2914
|
+
code: "FIELDS_EMPTY"
|
|
2654
2915
|
});
|
|
2655
|
-
return
|
|
2916
|
+
return;
|
|
2656
2917
|
}
|
|
2918
|
+
validateFieldsArray(fields, path, errors);
|
|
2657
2919
|
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
form: form.slug,
|
|
2662
|
-
collection: reference.collection,
|
|
2663
|
-
id: reference.id
|
|
2664
|
-
};
|
|
2665
|
-
let row;
|
|
2666
|
-
try {
|
|
2667
|
-
row = await pluginContext.services.collections.findEntryById(
|
|
2668
|
-
reference.collection,
|
|
2669
|
-
reference.id,
|
|
2670
|
-
{ as: "system" }
|
|
2671
|
-
);
|
|
2672
|
-
} catch (error) {
|
|
2673
|
-
logger.warn?.("Form redirect target could not be read", {
|
|
2674
|
-
...context,
|
|
2675
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2676
|
-
});
|
|
2677
|
-
return void 0;
|
|
2920
|
+
function validateSettings(settings, errors) {
|
|
2921
|
+
if (!settings) {
|
|
2922
|
+
return;
|
|
2678
2923
|
}
|
|
2679
|
-
if (
|
|
2680
|
-
|
|
2681
|
-
|
|
2924
|
+
if (typeof settings !== "object" || Array.isArray(settings)) {
|
|
2925
|
+
errors.push({
|
|
2926
|
+
path: "settings",
|
|
2927
|
+
message: "Form settings must be an object",
|
|
2928
|
+
code: "SETTINGS_INVALID_TYPE"
|
|
2929
|
+
});
|
|
2930
|
+
return;
|
|
2682
2931
|
}
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2932
|
+
const s = settings;
|
|
2933
|
+
if (s.confirmationType === "redirect") {
|
|
2934
|
+
if (!s.redirectUrl && !s.redirectRelation) {
|
|
2935
|
+
errors.push({
|
|
2936
|
+
path: "settings.redirectUrl",
|
|
2937
|
+
message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
|
|
2938
|
+
code: "REDIRECT_URL_REQUIRED"
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2690
2941
|
}
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
return { valid: true };
|
|
2942
|
+
if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
|
|
2943
|
+
errors.push({
|
|
2944
|
+
path: "settings.redirectPage",
|
|
2945
|
+
message: "A page is required when confirmation type is 'relationship'",
|
|
2946
|
+
code: "REDIRECT_PAGE_REQUIRED"
|
|
2947
|
+
});
|
|
2698
2948
|
}
|
|
2699
|
-
return {
|
|
2700
|
-
valid: false,
|
|
2701
|
-
errors: prepared.validationErrors
|
|
2702
|
-
};
|
|
2703
2949
|
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
if (!form) {
|
|
2708
|
-
return false;
|
|
2709
|
-
}
|
|
2710
|
-
return form.status === "published";
|
|
2950
|
+
function namesADocument(value) {
|
|
2951
|
+
if (typeof value === "string") return value.length > 0;
|
|
2952
|
+
return parseRedirectReference(value, []) !== null;
|
|
2711
2953
|
}
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2716
|
-
if (!form) {
|
|
2717
|
-
return null;
|
|
2954
|
+
function validateAccess(access, errors) {
|
|
2955
|
+
if (!access) {
|
|
2956
|
+
return;
|
|
2718
2957
|
}
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
submissionsSlug,
|
|
2727
|
-
{ where: { ...base, status: { equals: "new" } } },
|
|
2728
|
-
sys
|
|
2729
|
-
),
|
|
2730
|
-
collections.count(
|
|
2731
|
-
submissionsSlug,
|
|
2732
|
-
{ where: { ...base, status: { equals: "read" } } },
|
|
2733
|
-
sys
|
|
2734
|
-
),
|
|
2735
|
-
collections.count(
|
|
2736
|
-
submissionsSlug,
|
|
2737
|
-
{ where: { ...base, status: { equals: "archived" } } },
|
|
2738
|
-
sys
|
|
2739
|
-
)
|
|
2740
|
-
]);
|
|
2741
|
-
return { total, new: newCount, read, archived };
|
|
2742
|
-
} catch {
|
|
2743
|
-
return null;
|
|
2958
|
+
if (typeof access !== "object" || Array.isArray(access)) {
|
|
2959
|
+
errors.push({
|
|
2960
|
+
path: "access",
|
|
2961
|
+
message: "Access control must be an object",
|
|
2962
|
+
code: "ACCESS_INVALID_TYPE"
|
|
2963
|
+
});
|
|
2964
|
+
return;
|
|
2744
2965
|
}
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
const encoder = new TextEncoder();
|
|
2755
|
-
const keyData = encoder.encode(secret);
|
|
2756
|
-
const messageData = encoder.encode(payload);
|
|
2757
|
-
const cryptoKey = await crypto.subtle.importKey(
|
|
2758
|
-
"raw",
|
|
2759
|
-
keyData,
|
|
2760
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
2761
|
-
false,
|
|
2762
|
-
["sign"]
|
|
2763
|
-
);
|
|
2764
|
-
const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
|
|
2765
|
-
const hashArray = Array.from(new Uint8Array(signature));
|
|
2766
|
-
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2767
|
-
return `sha256=${hashHex}`;
|
|
2768
|
-
}
|
|
2769
|
-
async function sendWebhook(webhook, payload) {
|
|
2770
|
-
const startTime = Date.now();
|
|
2771
|
-
const payloadString = JSON.stringify(payload);
|
|
2772
|
-
try {
|
|
2773
|
-
const headers = {
|
|
2774
|
-
"Content-Type": "application/json",
|
|
2775
|
-
[EVENT_HEADER]: payload.event,
|
|
2776
|
-
[DELIVERY_ID_HEADER]: payload.submission.id,
|
|
2777
|
-
...webhook.headers
|
|
2778
|
-
};
|
|
2779
|
-
if (webhook.secret) {
|
|
2780
|
-
headers[SIGNATURE_HEADER] = await generateSignature(
|
|
2781
|
-
payloadString,
|
|
2782
|
-
webhook.secret
|
|
2783
|
-
);
|
|
2784
|
-
}
|
|
2785
|
-
const controller = new AbortController();
|
|
2786
|
-
const timeoutId = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
|
|
2787
|
-
try {
|
|
2788
|
-
const response = await safeFetch(webhook.url, {
|
|
2789
|
-
method: webhook.method || "POST",
|
|
2790
|
-
headers,
|
|
2791
|
-
body: payloadString,
|
|
2792
|
-
signal: controller.signal,
|
|
2793
|
-
allowLocalhost: process.env.NODE_ENV !== "production"
|
|
2966
|
+
const accessObj = access;
|
|
2967
|
+
const validAccessKeys = ["submit", "read"];
|
|
2968
|
+
for (const key of validAccessKeys) {
|
|
2969
|
+
const fn = accessObj[key];
|
|
2970
|
+
if (fn !== void 0 && typeof fn !== "function") {
|
|
2971
|
+
errors.push({
|
|
2972
|
+
path: `access.${key}`,
|
|
2973
|
+
message: `Access control '${key}' must be a function`,
|
|
2974
|
+
code: "ACCESS_FUNCTION_INVALID"
|
|
2794
2975
|
});
|
|
2795
|
-
clearTimeout(timeoutId);
|
|
2796
|
-
const durationMs = Date.now() - startTime;
|
|
2797
|
-
if (response.ok) {
|
|
2798
|
-
return {
|
|
2799
|
-
url: webhook.url,
|
|
2800
|
-
success: true,
|
|
2801
|
-
statusCode: response.status,
|
|
2802
|
-
durationMs
|
|
2803
|
-
};
|
|
2804
|
-
}
|
|
2805
|
-
return {
|
|
2806
|
-
url: webhook.url,
|
|
2807
|
-
success: false,
|
|
2808
|
-
statusCode: response.status,
|
|
2809
|
-
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
2810
|
-
durationMs
|
|
2811
|
-
};
|
|
2812
|
-
} finally {
|
|
2813
|
-
clearTimeout(timeoutId);
|
|
2814
|
-
}
|
|
2815
|
-
} catch (error) {
|
|
2816
|
-
const durationMs = Date.now() - startTime;
|
|
2817
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
2818
|
-
return {
|
|
2819
|
-
url: webhook.url,
|
|
2820
|
-
success: false,
|
|
2821
|
-
error: `Timeout after ${WEBHOOK_TIMEOUT_MS}ms`,
|
|
2822
|
-
durationMs
|
|
2823
|
-
};
|
|
2824
|
-
}
|
|
2825
|
-
if (error instanceof ExternalUrlError) {
|
|
2826
|
-
return {
|
|
2827
|
-
url: webhook.url,
|
|
2828
|
-
success: false,
|
|
2829
|
-
error: `Webhook URL rejected for SSRF safety: ${error.message}`,
|
|
2830
|
-
durationMs
|
|
2831
|
-
};
|
|
2832
2976
|
}
|
|
2833
|
-
return {
|
|
2834
|
-
url: webhook.url,
|
|
2835
|
-
success: false,
|
|
2836
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2837
|
-
durationMs
|
|
2838
|
-
};
|
|
2839
2977
|
}
|
|
2840
2978
|
}
|
|
2841
|
-
|
|
2842
|
-
const
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
successful: 0,
|
|
2849
|
-
failed: 0,
|
|
2850
|
-
results: []
|
|
2851
|
-
};
|
|
2852
|
-
}
|
|
2853
|
-
const deliveryPromises = relevantWebhooks.map(async (webhook) => {
|
|
2854
|
-
const payload = {
|
|
2855
|
-
event,
|
|
2856
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2857
|
-
form: {
|
|
2858
|
-
id: form.id,
|
|
2859
|
-
slug: form.slug,
|
|
2860
|
-
name: form.name
|
|
2861
|
-
},
|
|
2862
|
-
submission: webhook.includeData !== false ? submission : {
|
|
2863
|
-
id: submission.id,
|
|
2864
|
-
submittedAt: submission.submittedAt
|
|
2865
|
-
}
|
|
2866
|
-
};
|
|
2867
|
-
return sendWebhook(webhook, payload);
|
|
2868
|
-
});
|
|
2869
|
-
const results = await Promise.all(deliveryPromises);
|
|
2870
|
-
const successful = results.filter((r) => r.success).length;
|
|
2871
|
-
const failed = results.filter((r) => !r.success).length;
|
|
2979
|
+
function validateFormConfig(config) {
|
|
2980
|
+
const errors = [];
|
|
2981
|
+
warnIfWebhooksConfigured(config);
|
|
2982
|
+
validateSlug(config.slug, errors);
|
|
2983
|
+
validateFields(config.fields, errors);
|
|
2984
|
+
validateSettings(config.settings, errors);
|
|
2985
|
+
validateAccess(config.access, errors);
|
|
2872
2986
|
return {
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
failed,
|
|
2876
|
-
results
|
|
2987
|
+
valid: errors.length === 0,
|
|
2988
|
+
errors
|
|
2877
2989
|
};
|
|
2878
2990
|
}
|
|
2879
|
-
function
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
triggered: result.triggered,
|
|
2888
|
-
successful: result.successful,
|
|
2889
|
-
failed: result.failed,
|
|
2890
|
-
errors: result.results.filter((r) => !r.success).map((r) => ({ url: r.url, error: r.error }))
|
|
2891
|
-
});
|
|
2892
|
-
} else {
|
|
2893
|
-
logger?.info?.("Webhooks triggered successfully", {
|
|
2894
|
-
event: options.event,
|
|
2895
|
-
formSlug: options.form.slug,
|
|
2896
|
-
submissionId: options.submission.id,
|
|
2897
|
-
count: result.triggered
|
|
2898
|
-
});
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
}).catch((error) => {
|
|
2902
|
-
logger?.error?.("Unexpected error triggering webhooks", {
|
|
2903
|
-
event: options.event,
|
|
2904
|
-
formSlug: options.form.slug,
|
|
2905
|
-
submissionId: options.submission.id,
|
|
2906
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2907
|
-
});
|
|
2908
|
-
});
|
|
2909
|
-
}
|
|
2910
|
-
function isValidWebhookUrl(url2) {
|
|
2911
|
-
try {
|
|
2912
|
-
const parsed = new URL(url2);
|
|
2913
|
-
if (parsed.protocol !== "https:" && !parsed.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
2914
|
-
return false;
|
|
2915
|
-
}
|
|
2916
|
-
return true;
|
|
2917
|
-
} catch {
|
|
2918
|
-
return false;
|
|
2991
|
+
function assertValidFormConfig(config) {
|
|
2992
|
+
const result = validateFormConfig(config);
|
|
2993
|
+
if (!result.valid) {
|
|
2994
|
+
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
2995
|
+
throw new Error(
|
|
2996
|
+
`Invalid form config for '${config.slug || "unknown"}':
|
|
2997
|
+
${errorMessages}`
|
|
2998
|
+
);
|
|
2919
2999
|
}
|
|
2920
3000
|
}
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
3001
|
+
|
|
3002
|
+
// src/config/field-helpers.ts
|
|
3003
|
+
var option = (label, value) => ({
|
|
3004
|
+
label,
|
|
3005
|
+
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
3006
|
+
});
|
|
3007
|
+
var text3 = (config) => ({
|
|
3008
|
+
...config,
|
|
3009
|
+
type: "text"
|
|
3010
|
+
});
|
|
3011
|
+
var email = (config) => ({
|
|
3012
|
+
...config,
|
|
3013
|
+
type: "email"
|
|
3014
|
+
});
|
|
3015
|
+
var phone = (config) => ({
|
|
3016
|
+
...config,
|
|
3017
|
+
type: "phone"
|
|
3018
|
+
});
|
|
3019
|
+
var url = (config) => ({
|
|
3020
|
+
...config,
|
|
3021
|
+
type: "url"
|
|
3022
|
+
});
|
|
3023
|
+
var textarea3 = (config) => ({
|
|
3024
|
+
...config,
|
|
3025
|
+
type: "textarea"
|
|
3026
|
+
});
|
|
3027
|
+
var number = (config) => ({
|
|
3028
|
+
...config,
|
|
3029
|
+
type: "number"
|
|
3030
|
+
});
|
|
3031
|
+
var select3 = (config) => ({
|
|
3032
|
+
...config,
|
|
3033
|
+
type: "select"
|
|
3034
|
+
});
|
|
3035
|
+
var checkbox2 = (config) => ({
|
|
3036
|
+
...config,
|
|
3037
|
+
type: "checkbox"
|
|
3038
|
+
});
|
|
3039
|
+
var radio = (config) => ({
|
|
3040
|
+
...config,
|
|
3041
|
+
type: "radio"
|
|
3042
|
+
});
|
|
3043
|
+
var file = (config) => ({
|
|
3044
|
+
...config,
|
|
3045
|
+
type: "file"
|
|
3046
|
+
});
|
|
3047
|
+
var date3 = (config) => ({
|
|
3048
|
+
...config,
|
|
3049
|
+
type: "date"
|
|
3050
|
+
});
|
|
3051
|
+
var time = (config) => ({
|
|
3052
|
+
...config,
|
|
3053
|
+
type: "time"
|
|
3054
|
+
});
|
|
3055
|
+
var hidden = (config) => ({
|
|
3056
|
+
...config,
|
|
3057
|
+
type: "hidden"
|
|
3058
|
+
});
|
|
2924
3059
|
|
|
2925
3060
|
// src/index.ts
|
|
2926
3061
|
var formBuilderPlugin = formBuilder().plugin;
|
|
@@ -2934,8 +3069,6 @@ export {
|
|
|
2934
3069
|
assertValidFormConfig,
|
|
2935
3070
|
checkSpam,
|
|
2936
3071
|
checkbox2 as checkbox,
|
|
2937
|
-
cleanupRateLimitStore,
|
|
2938
|
-
clearRateLimitStore,
|
|
2939
3072
|
createFormConfig,
|
|
2940
3073
|
date3 as date,
|
|
2941
3074
|
downloadFile,
|
|
@@ -2945,7 +3078,6 @@ export {
|
|
|
2945
3078
|
exportToCSV,
|
|
2946
3079
|
exportToJSON,
|
|
2947
3080
|
file,
|
|
2948
|
-
fireWebhooks,
|
|
2949
3081
|
formBuilder,
|
|
2950
3082
|
formBuilderPlugin,
|
|
2951
3083
|
formatExportValue,
|
|
@@ -2954,9 +3086,7 @@ export {
|
|
|
2954
3086
|
generateZodSchema,
|
|
2955
3087
|
getFormBuilderConfig,
|
|
2956
3088
|
getFormSubmissionStats,
|
|
2957
|
-
getRateLimitStoreSize,
|
|
2958
3089
|
getSupportedComparisonOperators,
|
|
2959
|
-
getSupportedWebhookEvents,
|
|
2960
3090
|
getValidationErrors,
|
|
2961
3091
|
hidden,
|
|
2962
3092
|
isCheckboxFormField,
|
|
@@ -2966,11 +3096,9 @@ export {
|
|
|
2966
3096
|
isFormField,
|
|
2967
3097
|
isFormFieldType,
|
|
2968
3098
|
isKnownFormField,
|
|
2969
|
-
isRateLimited,
|
|
2970
3099
|
isSelectFormField,
|
|
2971
3100
|
isTextFormField,
|
|
2972
3101
|
isValidComparisonOperator,
|
|
2973
|
-
isValidWebhookUrl,
|
|
2974
3102
|
number,
|
|
2975
3103
|
option,
|
|
2976
3104
|
phone,
|
|
@@ -2984,7 +3112,6 @@ export {
|
|
|
2984
3112
|
time,
|
|
2985
3113
|
toTitleCase,
|
|
2986
3114
|
transformFormData,
|
|
2987
|
-
triggerWebhooks,
|
|
2988
3115
|
url,
|
|
2989
3116
|
validateFormConfig,
|
|
2990
3117
|
validateFormData,
|