@actuate-media/cms-core 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/api/admin-contracts.test.js +10 -2
- package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
- package/dist/__tests__/api/public-forms.test.d.ts +2 -0
- package/dist/__tests__/api/public-forms.test.d.ts.map +1 -0
- package/dist/__tests__/api/public-forms.test.js +125 -0
- package/dist/__tests__/api/public-forms.test.js.map +1 -0
- package/dist/__tests__/api/public-seo.test.js +2 -0
- package/dist/__tests__/api/public-seo.test.js.map +1 -1
- package/dist/__tests__/forms/forms-service.test.d.ts +2 -0
- package/dist/__tests__/forms/forms-service.test.d.ts.map +1 -0
- package/dist/__tests__/forms/forms-service.test.js +193 -0
- package/dist/__tests__/forms/forms-service.test.js.map +1 -0
- package/dist/__tests__/forms/spam.test.d.ts +2 -0
- package/dist/__tests__/forms/spam.test.d.ts.map +1 -0
- package/dist/__tests__/forms/spam.test.js +115 -0
- package/dist/__tests__/forms/spam.test.js.map +1 -0
- package/dist/__tests__/forms/submission-files.test.d.ts +2 -0
- package/dist/__tests__/forms/submission-files.test.d.ts.map +1 -0
- package/dist/__tests__/forms/submission-files.test.js +65 -0
- package/dist/__tests__/forms/submission-files.test.js.map +1 -0
- package/dist/__tests__/forms/submission-pipeline.test.d.ts +2 -0
- package/dist/__tests__/forms/submission-pipeline.test.d.ts.map +1 -0
- package/dist/__tests__/forms/submission-pipeline.test.js +131 -0
- package/dist/__tests__/forms/submission-pipeline.test.js.map +1 -0
- package/dist/__tests__/forms/submission-validation.test.d.ts +2 -0
- package/dist/__tests__/forms/submission-validation.test.d.ts.map +1 -0
- package/dist/__tests__/forms/submission-validation.test.js +108 -0
- package/dist/__tests__/forms/submission-validation.test.js.map +1 -0
- package/dist/__tests__/seo/audit-runner.test.js +54 -1
- package/dist/__tests__/seo/audit-runner.test.js.map +1 -1
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +2 -2
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +724 -155
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/router.d.ts +2 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +3 -0
- package/dist/api/router.js.map +1 -1
- package/dist/config/types.d.ts +42 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/cron/index.d.ts +1 -0
- package/dist/cron/index.d.ts.map +1 -1
- package/dist/cron/index.js +10 -1
- package/dist/cron/index.js.map +1 -1
- package/dist/forms/entries.d.ts +45 -0
- package/dist/forms/entries.d.ts.map +1 -0
- package/dist/forms/entries.js +348 -0
- package/dist/forms/entries.js.map +1 -0
- package/dist/forms/files.d.ts +38 -0
- package/dist/forms/files.d.ts.map +1 -0
- package/dist/forms/files.js +134 -0
- package/dist/forms/files.js.map +1 -0
- package/dist/forms/index.d.ts +8 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +7 -0
- package/dist/forms/index.js.map +1 -1
- package/dist/forms/service.d.ts +92 -0
- package/dist/forms/service.d.ts.map +1 -0
- package/dist/forms/service.js +579 -0
- package/dist/forms/service.js.map +1 -0
- package/dist/forms/spam.d.ts +33 -0
- package/dist/forms/spam.d.ts.map +1 -0
- package/dist/forms/spam.js +93 -0
- package/dist/forms/spam.js.map +1 -0
- package/dist/forms/submission.d.ts +95 -0
- package/dist/forms/submission.d.ts.map +1 -0
- package/dist/forms/submission.js +282 -0
- package/dist/forms/submission.js.map +1 -0
- package/dist/forms/types.d.ts +244 -0
- package/dist/forms/types.d.ts.map +1 -0
- package/dist/forms/types.js +36 -0
- package/dist/forms/types.js.map +1 -0
- package/dist/forms/validation.d.ts +19 -0
- package/dist/forms/validation.d.ts.map +1 -0
- package/dist/forms/validation.js +235 -0
- package/dist/forms/validation.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/security/client-ip.d.ts +11 -21
- package/dist/security/client-ip.d.ts.map +1 -1
- package/dist/security/client-ip.js +38 -0
- package/dist/security/client-ip.js.map +1 -1
- package/dist/seo/audit-runner.d.ts +37 -2
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js +56 -6
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/index.d.ts +2 -1
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +1 -1
- package/dist/seo/index.js.map +1 -1
- package/package.json +1 -1
- package/prisma/cms-schema.prisma +81 -7
- package/prisma/migrations/0010_forms/migration.sql +130 -0
- package/prisma/schema.prisma +130 -35
package/dist/api/handlers.js
CHANGED
|
@@ -23,6 +23,11 @@ import { schedulingCronHandler } from '../scheduling/index.js';
|
|
|
23
23
|
import { isAuthorizedCronRequest, processCleanup, processSeoScan } from '../cron/index.js';
|
|
24
24
|
import { processContentHealthScan } from '../content-health/cron.js';
|
|
25
25
|
import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
|
|
26
|
+
import { listForms as listFormsService, getFormById as getFormByIdService, getFormBySlug as getFormBySlugService, createForm as createFormService, updateForm as updateFormService, duplicateForm as duplicateFormService, deleteForm as deleteFormService, saveFormSchema as saveFormSchemaService, getFormSchema as getFormSchemaService, getNotificationSettings as getFormNotificationSettingsService, updateNotificationSettings as updateFormNotificationSettingsService, setNotifyEnabled as setFormNotifyEnabledService, getEmbedSettings as getFormEmbedSettingsService, updateEmbedSettings as updateFormEmbedSettingsService, getFormStats as getFormStatsService, getFormsSidebarCounts as getFormsSidebarCountsService, FormNotFoundError, FormValidationError, FormConflictError, FormHasSubmissionsError, } from '../forms/service.js';
|
|
27
|
+
import { listEntries as listEntriesService, getEntryWithSchema as getEntryWithSchemaService, getEntryCounts as getEntryCountsService, updateEntry as updateEntryService, bulkUpdateEntries as bulkUpdateEntriesService, deleteEntry as deleteEntryService, } from '../forms/entries.js';
|
|
28
|
+
import { processSubmission, buildPublicForm, SubmissionValidationError, } from '../forms/submission.js';
|
|
29
|
+
import { validateSubmissionFiles, FORM_UPLOAD_ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_BYTES, } from '../forms/files.js';
|
|
30
|
+
import { resolveSubmissionFields } from '../forms/submission.js';
|
|
26
31
|
import { checkForUpdates } from '../upgrade/version-check.js';
|
|
27
32
|
import { createUpgradePR } from '../upgrade/upgrade-pr.js';
|
|
28
33
|
import { encryptField, decryptField } from '../security/encrypted-fields.js';
|
|
@@ -246,17 +251,6 @@ function asRecord(value) {
|
|
|
246
251
|
? value
|
|
247
252
|
: {};
|
|
248
253
|
}
|
|
249
|
-
function normalizeFormDocument(form, submissions = 0) {
|
|
250
|
-
const data = asRecord(form.data);
|
|
251
|
-
const fields = Array.isArray(data.fields) ? data.fields.length : Number(data.fields ?? 0);
|
|
252
|
-
return {
|
|
253
|
-
...form,
|
|
254
|
-
name: data.name ?? form.name ?? form.title ?? 'Untitled form',
|
|
255
|
-
description: data.description ?? form.description ?? '',
|
|
256
|
-
fields,
|
|
257
|
-
submissions,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
254
|
function normalizeSubmission(submission) {
|
|
261
255
|
const data = asRecord(submission.data);
|
|
262
256
|
const attribution = asRecord(submission.attribution);
|
|
@@ -281,6 +275,288 @@ function normalizeSubmission(submission) {
|
|
|
281
275
|
},
|
|
282
276
|
};
|
|
283
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Map a forms-service domain error to an HTTP response. Returns `null` for
|
|
280
|
+
* unrecognised errors so the caller can fall through to `internalError`.
|
|
281
|
+
*/
|
|
282
|
+
function mapFormError(err) {
|
|
283
|
+
if (err instanceof FormNotFoundError)
|
|
284
|
+
return errorResponse(err.message, 404);
|
|
285
|
+
if (err instanceof FormValidationError) {
|
|
286
|
+
return json({ error: err.message, code: 'INVALID_SCHEMA', details: err.errors }, 400);
|
|
287
|
+
}
|
|
288
|
+
if (err instanceof FormConflictError)
|
|
289
|
+
return errorResponse(err.message, 409);
|
|
290
|
+
if (err instanceof FormHasSubmissionsError) {
|
|
291
|
+
return json({ error: err.message, code: 'FORM_HAS_SUBMISSIONS', submissionCount: err.submissionCount }, 409);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
function parseFormsQuery(url) {
|
|
296
|
+
const sp = url.searchParams;
|
|
297
|
+
const status = sp.get('status');
|
|
298
|
+
const notify = sp.get('notifyEnabled');
|
|
299
|
+
return {
|
|
300
|
+
search: sp.get('search') ?? undefined,
|
|
301
|
+
status: status === 'active' || status === 'draft' || status === 'archived' ? status : undefined,
|
|
302
|
+
notifyEnabled: notify == null ? undefined : notify === 'true',
|
|
303
|
+
sortBy: sp.get('sortBy') ?? undefined,
|
|
304
|
+
sortDirection: sp.get('sortDirection') === 'asc'
|
|
305
|
+
? 'asc'
|
|
306
|
+
: sp.get('sortDirection') === 'desc'
|
|
307
|
+
? 'desc'
|
|
308
|
+
: undefined,
|
|
309
|
+
page: sp.get('page') ? Number(sp.get('page')) : undefined,
|
|
310
|
+
pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function parseEntriesQuery(url) {
|
|
314
|
+
const sp = url.searchParams;
|
|
315
|
+
const spam = sp.get('spamStatus');
|
|
316
|
+
const boolParam = (key) => {
|
|
317
|
+
const v = sp.get(key);
|
|
318
|
+
return v == null ? undefined : v === 'true';
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
search: sp.get('search') ?? undefined,
|
|
322
|
+
formId: sp.get('formId') ?? sp.get('form') ?? undefined,
|
|
323
|
+
unread: boolParam('unread'),
|
|
324
|
+
starred: boolParam('starred'),
|
|
325
|
+
archived: boolParam('archived'),
|
|
326
|
+
spamStatus: spam === 'clean' || spam === 'suspected' || spam === 'spam'
|
|
327
|
+
? spam
|
|
328
|
+
: undefined,
|
|
329
|
+
dateFrom: sp.get('dateFrom') ?? undefined,
|
|
330
|
+
dateTo: sp.get('dateTo') ?? undefined,
|
|
331
|
+
thisWeek: boolParam('thisWeek'),
|
|
332
|
+
sortBy: sp.get('sortBy') ?? undefined,
|
|
333
|
+
sortDirection: sp.get('sortDirection') === 'asc' ? 'asc' : undefined,
|
|
334
|
+
page: sp.get('page') ? Number(sp.get('page')) : undefined,
|
|
335
|
+
pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// ─── Public form submission helpers ─────────────────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Build CORS headers for the public form endpoints. When the form has no
|
|
341
|
+
* embed allowlist we reflect the requesting origin (open embedding). When an
|
|
342
|
+
* allowlist is set we only echo the origin if it matches, so a disallowed site
|
|
343
|
+
* cannot read the response. Browser-level CORS is complemented by a hard
|
|
344
|
+
* server-side origin check in the submit handler.
|
|
345
|
+
*/
|
|
346
|
+
function formCorsHeaders(request, allowedDomains) {
|
|
347
|
+
const origin = request.headers.get('origin');
|
|
348
|
+
const headers = {
|
|
349
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
350
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
|
|
351
|
+
'Access-Control-Max-Age': '600',
|
|
352
|
+
Vary: 'Origin',
|
|
353
|
+
};
|
|
354
|
+
if (!allowedDomains || allowedDomains.length === 0) {
|
|
355
|
+
headers['Access-Control-Allow-Origin'] = origin || '*';
|
|
356
|
+
}
|
|
357
|
+
else if (origin && isOriginAllowed(origin, allowedDomains)) {
|
|
358
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
359
|
+
}
|
|
360
|
+
return headers;
|
|
361
|
+
}
|
|
362
|
+
function normalizeAllowedHost(domain) {
|
|
363
|
+
return domain
|
|
364
|
+
.trim()
|
|
365
|
+
.toLowerCase()
|
|
366
|
+
.replace(/^https?:\/\//, '')
|
|
367
|
+
.replace(/\/.*$/, '')
|
|
368
|
+
.replace(/^\*\./, '');
|
|
369
|
+
}
|
|
370
|
+
function isOriginAllowed(origin, allowedDomains) {
|
|
371
|
+
let host;
|
|
372
|
+
try {
|
|
373
|
+
host = new URL(origin).hostname.toLowerCase();
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
return allowedDomains
|
|
379
|
+
.map(normalizeAllowedHost)
|
|
380
|
+
.some((a) => a !== '' && (host === a || host.endsWith(`.${a}`)));
|
|
381
|
+
}
|
|
382
|
+
function jsonWithHeaders(data, status, extra) {
|
|
383
|
+
return new Response(JSON.stringify(data), {
|
|
384
|
+
status,
|
|
385
|
+
headers: { ...SECURITY_HEADERS, ...extra },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Parse a public submission request into field values, files, and metadata.
|
|
390
|
+
* Supports both `application/json` (no files) and `multipart/form-data`
|
|
391
|
+
* (files + fields). Unknown/oversized bodies are rejected by the caller's
|
|
392
|
+
* Content-Length gate before this runs.
|
|
393
|
+
*/
|
|
394
|
+
async function parseSubmissionRequest(request) {
|
|
395
|
+
const contentType = request.headers.get('content-type') ?? '';
|
|
396
|
+
if (contentType.includes('multipart/form-data')) {
|
|
397
|
+
const form = await request.formData();
|
|
398
|
+
const values = {};
|
|
399
|
+
const files = [];
|
|
400
|
+
let attribution = null;
|
|
401
|
+
let elapsedMs = null;
|
|
402
|
+
let pageUrl = null;
|
|
403
|
+
let captchaToken = '';
|
|
404
|
+
for (const [key, value] of form.entries()) {
|
|
405
|
+
if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
|
|
406
|
+
const file = value;
|
|
407
|
+
if (file.size === 0 && !file.name)
|
|
408
|
+
continue;
|
|
409
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
410
|
+
files.push({
|
|
411
|
+
fieldKey: key,
|
|
412
|
+
fileName: file.name || 'file',
|
|
413
|
+
mimeType: file.type || 'application/octet-stream',
|
|
414
|
+
size: buffer.byteLength,
|
|
415
|
+
buffer,
|
|
416
|
+
});
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const str = String(value);
|
|
420
|
+
if (key === '__attribution') {
|
|
421
|
+
attribution = safeJsonObject(str);
|
|
422
|
+
}
|
|
423
|
+
else if (key === '__elapsedMs') {
|
|
424
|
+
const n = Number(str);
|
|
425
|
+
elapsedMs = Number.isFinite(n) ? n : null;
|
|
426
|
+
}
|
|
427
|
+
else if (key === '__pageUrl') {
|
|
428
|
+
pageUrl = str || null;
|
|
429
|
+
}
|
|
430
|
+
else if (key === '__captchaToken' || key === 'captchaToken') {
|
|
431
|
+
captchaToken = str;
|
|
432
|
+
}
|
|
433
|
+
else if (Object.prototype.hasOwnProperty.call(values, key)) {
|
|
434
|
+
// Repeated keys (e.g. multi-checkbox) collapse into an array.
|
|
435
|
+
const existing = values[key];
|
|
436
|
+
values[key] = Array.isArray(existing) ? [...existing, str] : [existing, str];
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
values[key] = str;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { values, files, meta: { attribution, elapsedMs, pageUrl, captchaToken } };
|
|
443
|
+
}
|
|
444
|
+
// JSON body.
|
|
445
|
+
const body = (await request.json().catch(() => null));
|
|
446
|
+
if (!body)
|
|
447
|
+
return null;
|
|
448
|
+
const values = body.fields && typeof body.fields === 'object' && !Array.isArray(body.fields)
|
|
449
|
+
? body.fields
|
|
450
|
+
: null;
|
|
451
|
+
if (!values)
|
|
452
|
+
return null;
|
|
453
|
+
return {
|
|
454
|
+
values,
|
|
455
|
+
files: [],
|
|
456
|
+
meta: {
|
|
457
|
+
attribution: safeJsonObject(body.attribution),
|
|
458
|
+
elapsedMs: typeof body.elapsedMs === 'number' && Number.isFinite(body.elapsedMs)
|
|
459
|
+
? body.elapsedMs
|
|
460
|
+
: null,
|
|
461
|
+
pageUrl: typeof body.pageUrl === 'string' ? body.pageUrl : null,
|
|
462
|
+
captchaToken: typeof body.captchaToken === 'string' ? body.captchaToken : '',
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function safeJsonObject(value) {
|
|
467
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
468
|
+
return value;
|
|
469
|
+
}
|
|
470
|
+
if (typeof value === 'string' && value.trim()) {
|
|
471
|
+
try {
|
|
472
|
+
const parsed = JSON.parse(value);
|
|
473
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
474
|
+
return parsed;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
/* ignore malformed attribution blob */
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Shared public-submission handler used by both the slug-based public route and
|
|
485
|
+
* the legacy by-id route. Enforces per-IP+form rate limiting, embed-origin
|
|
486
|
+
* allowlisting, optional CAPTCHA, file validation, then runs the core
|
|
487
|
+
* submission pipeline. Always responds with CORS headers so embedded forms can
|
|
488
|
+
* read the result.
|
|
489
|
+
*/
|
|
490
|
+
async function submitToForm(request, form) {
|
|
491
|
+
const allowedDomains = form.embedSettings?.allowedDomains;
|
|
492
|
+
const cors = formCorsHeaders(request, allowedDomains);
|
|
493
|
+
const ip = getClientIp(request);
|
|
494
|
+
const rlKey = isResolvedIp(ip) ? `formsubmit:${ip}:${form.id}` : `formsubmit:_unknown:${form.id}`;
|
|
495
|
+
if (!(await checkRateLimitAsync(formLimiterGlobal, rlKey))) {
|
|
496
|
+
return jsonWithHeaders({ error: 'Too many submissions. Please try again later.' }, 429, cors);
|
|
497
|
+
}
|
|
498
|
+
// Hard server-side origin check when an embed allowlist is configured —
|
|
499
|
+
// browser CORS alone does not prevent a non-browser client from posting.
|
|
500
|
+
if (allowedDomains && allowedDomains.length > 0) {
|
|
501
|
+
const origin = request.headers.get('origin');
|
|
502
|
+
if (origin && !isOriginAllowed(origin, allowedDomains)) {
|
|
503
|
+
return jsonWithHeaders({ error: 'This form cannot be submitted from this origin.' }, 403, cors);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const parsed = await parseSubmissionRequest(request);
|
|
507
|
+
if (!parsed) {
|
|
508
|
+
return jsonWithHeaders({ error: 'Invalid or missing submission body.' }, 400, cors);
|
|
509
|
+
}
|
|
510
|
+
const captchaConfig = getCaptchaConfig();
|
|
511
|
+
if (captchaConfig.provider !== 'none') {
|
|
512
|
+
const result = await verifyCaptcha(parsed.meta.captchaToken, captchaConfig, isResolvedIp(ip) ? ip : undefined);
|
|
513
|
+
if (!result.success) {
|
|
514
|
+
return jsonWithHeaders({ error: 'CAPTCHA verification failed. Please try again.' }, 403, cors);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (parsed.files.length > 0) {
|
|
518
|
+
const fileCfg = getActuateConfig()?.forms?.fileUpload;
|
|
519
|
+
if (fileCfg?.enabled === false) {
|
|
520
|
+
return jsonWithHeaders({ error: 'File uploads are not enabled for this site.' }, 400, cors);
|
|
521
|
+
}
|
|
522
|
+
const { fields } = await resolveSubmissionFields(form);
|
|
523
|
+
const fileCheck = validateSubmissionFiles(fields, parsed.files, {
|
|
524
|
+
maxSizeBytes: fileCfg?.maxSizeMB ? fileCfg.maxSizeMB * 1024 * 1024 : DEFAULT_MAX_FILE_BYTES,
|
|
525
|
+
allowedMimeTypes: fileCfg?.allowedMimeTypes ?? FORM_UPLOAD_ALLOWED_MIME_TYPES,
|
|
526
|
+
});
|
|
527
|
+
if (!fileCheck.ok) {
|
|
528
|
+
return jsonWithHeaders({ error: 'File validation failed.', code: 'INVALID_FILES', details: fileCheck.errors }, 422, cors);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const ctx = {
|
|
532
|
+
values: parsed.values,
|
|
533
|
+
files: parsed.files,
|
|
534
|
+
ip,
|
|
535
|
+
userAgent: request.headers.get('user-agent'),
|
|
536
|
+
referrer: request.headers.get('referer'),
|
|
537
|
+
origin: request.headers.get('origin'),
|
|
538
|
+
pageUrl: parsed.meta.pageUrl,
|
|
539
|
+
attribution: parsed.meta.attribution,
|
|
540
|
+
elapsedMs: parsed.meta.elapsedMs,
|
|
541
|
+
};
|
|
542
|
+
try {
|
|
543
|
+
const result = await processSubmission(form, ctx);
|
|
544
|
+
return jsonWithHeaders({
|
|
545
|
+
data: {
|
|
546
|
+
success: true,
|
|
547
|
+
submissionId: result.submissionId,
|
|
548
|
+
entryNumber: result.entryNumber,
|
|
549
|
+
confirmation: result.confirmation,
|
|
550
|
+
},
|
|
551
|
+
}, 201, cors);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
if (err instanceof SubmissionValidationError) {
|
|
555
|
+
return jsonWithHeaders({ error: 'Validation failed.', code: 'VALIDATION_ERROR', details: err.fieldErrors }, 422, cors);
|
|
556
|
+
}
|
|
557
|
+
return internalError(err);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
284
560
|
function normalizeRedirect(redirect) {
|
|
285
561
|
return {
|
|
286
562
|
...redirect,
|
|
@@ -3992,35 +4268,17 @@ export function registerCMSRoutes(router) {
|
|
|
3992
4268
|
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
3993
4269
|
if (roleErr)
|
|
3994
4270
|
return roleErr;
|
|
3995
|
-
const
|
|
3996
|
-
const forms = await
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
const ids = forms.map((f) => f.id);
|
|
4007
|
-
if (typeof d.formSubmission.groupBy === 'function') {
|
|
4008
|
-
const grouped = await d.formSubmission.groupBy({
|
|
4009
|
-
by: ['formId'],
|
|
4010
|
-
where: { formId: { in: ids } },
|
|
4011
|
-
_count: { _all: true },
|
|
4012
|
-
});
|
|
4013
|
-
submissionCounts = new Map(grouped.map((g) => [g.formId, g._count._all]));
|
|
4014
|
-
}
|
|
4015
|
-
else {
|
|
4016
|
-
for (const id of ids) {
|
|
4017
|
-
const count = await d.formSubmission.count({ where: { formId: id } });
|
|
4018
|
-
submissionCounts.set(id, count);
|
|
4019
|
-
}
|
|
4020
|
-
}
|
|
4021
|
-
}
|
|
4022
|
-
const normalized = forms.map((form) => normalizeFormDocument(form, submissionCounts.get(form.id) ?? 0));
|
|
4023
|
-
return json({ data: normalized });
|
|
4271
|
+
const url = new URL(request.url);
|
|
4272
|
+
const { forms, total, page, pageSize } = await listFormsService(parseFormsQuery(url));
|
|
4273
|
+
// Response is a superset of the typed FormDefinition with legacy aliases
|
|
4274
|
+
// (`submissions`, `fieldCount`) so older admin views keep working while
|
|
4275
|
+
// PR3 migrates them to the typed shape.
|
|
4276
|
+
const data = forms.map((f) => ({
|
|
4277
|
+
...f,
|
|
4278
|
+
fieldCount: f.fields?.length ?? 0,
|
|
4279
|
+
submissions: f.totalEntries ?? 0,
|
|
4280
|
+
}));
|
|
4281
|
+
return json({ data, total, page, pageSize });
|
|
4024
4282
|
}
|
|
4025
4283
|
catch (err) {
|
|
4026
4284
|
return internalError(err);
|
|
@@ -4061,71 +4319,407 @@ export function registerCMSRoutes(router) {
|
|
|
4061
4319
|
return internalError(err);
|
|
4062
4320
|
}
|
|
4063
4321
|
});
|
|
4064
|
-
|
|
4322
|
+
// Public, unauthenticated form schema — what an embedded form needs to render.
|
|
4323
|
+
// CSRF-exempt and CORS-enabled (see handler-factory + formCorsHeaders).
|
|
4324
|
+
router.options('/public/forms/:slug', async (request, params) => {
|
|
4325
|
+
const form = await getFormBySlugService(params.slug).catch(() => null);
|
|
4326
|
+
return new Response(null, {
|
|
4327
|
+
status: 204,
|
|
4328
|
+
headers: formCorsHeaders(request, form?.embedSettings?.allowedDomains),
|
|
4329
|
+
});
|
|
4330
|
+
});
|
|
4331
|
+
router.get('/public/forms/:slug', async (request, params) => {
|
|
4065
4332
|
try {
|
|
4066
|
-
const
|
|
4067
|
-
if (!
|
|
4068
|
-
return
|
|
4333
|
+
const form = await getFormBySlugService(params.slug);
|
|
4334
|
+
if (!form || form.status !== 'active') {
|
|
4335
|
+
return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
|
|
4069
4336
|
}
|
|
4070
|
-
const
|
|
4071
|
-
const
|
|
4072
|
-
|
|
4337
|
+
const cors = formCorsHeaders(request, form.embedSettings?.allowedDomains);
|
|
4338
|
+
const { fields, versionId } = await resolveSubmissionFields(form);
|
|
4339
|
+
const captcha = getCaptchaConfig();
|
|
4340
|
+
const publicForm = buildPublicForm(form, fields, versionId, {
|
|
4341
|
+
captcha: { provider: captcha.provider, siteKey: captcha.siteKey },
|
|
4073
4342
|
});
|
|
4074
|
-
|
|
4075
|
-
|
|
4343
|
+
return jsonWithHeaders({ data: publicForm }, 200, cors);
|
|
4344
|
+
}
|
|
4345
|
+
catch (err) {
|
|
4346
|
+
return internalError(err);
|
|
4347
|
+
}
|
|
4348
|
+
});
|
|
4349
|
+
router.options('/public/forms/:slug/submit', async (request, params) => {
|
|
4350
|
+
const form = await getFormBySlugService(params.slug).catch(() => null);
|
|
4351
|
+
return new Response(null, {
|
|
4352
|
+
status: 204,
|
|
4353
|
+
headers: formCorsHeaders(request, form?.embedSettings?.allowedDomains),
|
|
4354
|
+
});
|
|
4355
|
+
});
|
|
4356
|
+
router.post('/public/forms/:slug/submit', async (request, params) => {
|
|
4357
|
+
try {
|
|
4358
|
+
const form = await getFormBySlugService(params.slug);
|
|
4359
|
+
if (!form || form.status !== 'active') {
|
|
4360
|
+
return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
|
|
4076
4361
|
}
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4362
|
+
return submitToForm(request, form);
|
|
4363
|
+
}
|
|
4364
|
+
catch (err) {
|
|
4365
|
+
return internalError(err);
|
|
4366
|
+
}
|
|
4367
|
+
});
|
|
4368
|
+
// Legacy by-id submission endpoint — same pipeline, kept for existing embeds.
|
|
4369
|
+
router.post('/forms/:id/submit', async (request, params) => {
|
|
4370
|
+
try {
|
|
4371
|
+
const form = await getFormByIdService(params.id);
|
|
4372
|
+
if (!form || form.status !== 'active') {
|
|
4373
|
+
return jsonWithHeaders({ error: 'Form not found' }, 404, formCorsHeaders(request));
|
|
4081
4374
|
}
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4375
|
+
return submitToForm(request, form);
|
|
4376
|
+
}
|
|
4377
|
+
catch (err) {
|
|
4378
|
+
return internalError(err);
|
|
4379
|
+
}
|
|
4380
|
+
});
|
|
4381
|
+
// ---------------------------------------------------------------------------
|
|
4382
|
+
// Forms management routes (admin) — services in ../forms/*.
|
|
4383
|
+
//
|
|
4384
|
+
// Route order matters: the router matches the first registered pattern, and
|
|
4385
|
+
// `:id` is a greedy `[^/]+`. All static `/forms/<word>` GETs are registered
|
|
4386
|
+
// before `/forms/:id` so e.g. `/forms/entries` is not captured as an id.
|
|
4387
|
+
// ---------------------------------------------------------------------------
|
|
4388
|
+
router.get('/forms/stats', async (request) => {
|
|
4389
|
+
try {
|
|
4390
|
+
const auth = await requireAuth(request);
|
|
4391
|
+
if (auth.error)
|
|
4392
|
+
return auth.error;
|
|
4393
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4394
|
+
if (roleErr)
|
|
4395
|
+
return roleErr;
|
|
4396
|
+
return json({ data: await getFormStatsService() });
|
|
4397
|
+
}
|
|
4398
|
+
catch (err) {
|
|
4399
|
+
return internalError(err);
|
|
4400
|
+
}
|
|
4401
|
+
});
|
|
4402
|
+
router.get('/forms/sidebar-counts', async (request) => {
|
|
4403
|
+
try {
|
|
4404
|
+
const auth = await requireAuth(request);
|
|
4405
|
+
if (auth.error)
|
|
4406
|
+
return auth.error;
|
|
4407
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4408
|
+
if (roleErr)
|
|
4409
|
+
return roleErr;
|
|
4410
|
+
return json({ data: await getFormsSidebarCountsService() });
|
|
4411
|
+
}
|
|
4412
|
+
catch (err) {
|
|
4413
|
+
return internalError(err);
|
|
4414
|
+
}
|
|
4415
|
+
});
|
|
4416
|
+
router.get('/forms/entries/counts', async (request) => {
|
|
4417
|
+
try {
|
|
4418
|
+
const auth = await requireAuth(request);
|
|
4419
|
+
if (auth.error)
|
|
4420
|
+
return auth.error;
|
|
4421
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4422
|
+
if (roleErr)
|
|
4423
|
+
return roleErr;
|
|
4424
|
+
const counts = await getEntryCountsService(parseEntriesQuery(new URL(request.url)));
|
|
4425
|
+
return json({ data: counts });
|
|
4426
|
+
}
|
|
4427
|
+
catch (err) {
|
|
4428
|
+
return internalError(err);
|
|
4429
|
+
}
|
|
4430
|
+
});
|
|
4431
|
+
router.get('/forms/entries/:entryId', async (request, params) => {
|
|
4432
|
+
try {
|
|
4433
|
+
const auth = await requireAuth(request);
|
|
4434
|
+
if (auth.error)
|
|
4435
|
+
return auth.error;
|
|
4436
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4437
|
+
if (roleErr)
|
|
4438
|
+
return roleErr;
|
|
4439
|
+
const result = await getEntryWithSchemaService(params.entryId);
|
|
4440
|
+
if (!result)
|
|
4441
|
+
return errorResponse('Entry not found', 404);
|
|
4442
|
+
return json({ data: result });
|
|
4443
|
+
}
|
|
4444
|
+
catch (err) {
|
|
4445
|
+
return internalError(err);
|
|
4446
|
+
}
|
|
4447
|
+
});
|
|
4448
|
+
router.patch('/forms/entries/:entryId', async (request, params) => {
|
|
4449
|
+
try {
|
|
4450
|
+
const auth = await requireAuth(request);
|
|
4451
|
+
if (auth.error)
|
|
4452
|
+
return auth.error;
|
|
4453
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4454
|
+
if (roleErr)
|
|
4455
|
+
return roleErr;
|
|
4456
|
+
const body = (await request.json().catch(() => ({})));
|
|
4457
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4458
|
+
const updated = await updateEntryService(ctx, params.entryId, body);
|
|
4459
|
+
if (!updated)
|
|
4460
|
+
return errorResponse('Entry not found', 404);
|
|
4461
|
+
return json({ data: updated });
|
|
4462
|
+
}
|
|
4463
|
+
catch (err) {
|
|
4464
|
+
return internalError(err);
|
|
4465
|
+
}
|
|
4466
|
+
});
|
|
4467
|
+
router.delete('/forms/entries/:entryId', async (request, params) => {
|
|
4468
|
+
try {
|
|
4469
|
+
const auth = await requireAuth(request);
|
|
4470
|
+
if (auth.error)
|
|
4471
|
+
return auth.error;
|
|
4472
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4473
|
+
if (roleErr)
|
|
4474
|
+
return roleErr;
|
|
4475
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4476
|
+
await deleteEntryService(ctx, params.entryId);
|
|
4477
|
+
return json({ data: { success: true } });
|
|
4478
|
+
}
|
|
4479
|
+
catch (err) {
|
|
4480
|
+
return internalError(err);
|
|
4481
|
+
}
|
|
4482
|
+
});
|
|
4483
|
+
router.post('/forms/entries/bulk', async (request) => {
|
|
4484
|
+
try {
|
|
4485
|
+
const auth = await requireAuth(request);
|
|
4486
|
+
if (auth.error)
|
|
4487
|
+
return auth.error;
|
|
4488
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4489
|
+
if (roleErr)
|
|
4490
|
+
return roleErr;
|
|
4491
|
+
const body = (await request.json().catch(() => ({})));
|
|
4492
|
+
if (!Array.isArray(body.ids) || body.ids.length === 0) {
|
|
4493
|
+
return errorResponse('"ids" must be a non-empty array', 400);
|
|
4089
4494
|
}
|
|
4090
|
-
const
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
const
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4495
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4496
|
+
const count = await bulkUpdateEntriesService(ctx, body.ids, body.patch ?? {});
|
|
4497
|
+
return json({ data: { updated: count } });
|
|
4498
|
+
}
|
|
4499
|
+
catch (err) {
|
|
4500
|
+
return internalError(err);
|
|
4501
|
+
}
|
|
4502
|
+
});
|
|
4503
|
+
router.get('/forms/entries', async (request) => {
|
|
4504
|
+
try {
|
|
4505
|
+
const auth = await requireAuth(request);
|
|
4506
|
+
if (auth.error)
|
|
4507
|
+
return auth.error;
|
|
4508
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4509
|
+
if (roleErr)
|
|
4510
|
+
return roleErr;
|
|
4511
|
+
const result = await listEntriesService(parseEntriesQuery(new URL(request.url)));
|
|
4512
|
+
return json({ data: result });
|
|
4513
|
+
}
|
|
4514
|
+
catch (err) {
|
|
4515
|
+
return internalError(err);
|
|
4516
|
+
}
|
|
4517
|
+
});
|
|
4518
|
+
router.post('/forms', async (request) => {
|
|
4519
|
+
try {
|
|
4520
|
+
const auth = await requireAuth(request);
|
|
4521
|
+
if (auth.error)
|
|
4522
|
+
return auth.error;
|
|
4523
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4524
|
+
if (roleErr)
|
|
4525
|
+
return roleErr;
|
|
4526
|
+
const body = (await request.json().catch(() => null));
|
|
4527
|
+
if (!body || typeof body.name !== 'string' || !body.name.trim()) {
|
|
4528
|
+
return errorResponse('"name" is required', 400);
|
|
4529
|
+
}
|
|
4530
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4531
|
+
const form = await createFormService(ctx, body);
|
|
4532
|
+
return json({ data: form }, 201);
|
|
4533
|
+
}
|
|
4534
|
+
catch (err) {
|
|
4535
|
+
return mapFormError(err) ?? internalError(err);
|
|
4536
|
+
}
|
|
4537
|
+
});
|
|
4538
|
+
router.get('/forms/:id', async (request, params) => {
|
|
4539
|
+
try {
|
|
4540
|
+
const auth = await requireAuth(request);
|
|
4541
|
+
if (auth.error)
|
|
4542
|
+
return auth.error;
|
|
4543
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4544
|
+
if (roleErr)
|
|
4545
|
+
return roleErr;
|
|
4546
|
+
const form = await getFormByIdService(params.id);
|
|
4547
|
+
if (!form)
|
|
4548
|
+
return errorResponse('Form not found', 404);
|
|
4549
|
+
return json({ data: form });
|
|
4124
4550
|
}
|
|
4125
4551
|
catch (err) {
|
|
4126
4552
|
return internalError(err);
|
|
4127
4553
|
}
|
|
4128
4554
|
});
|
|
4555
|
+
router.patch('/forms/:id', async (request, params) => {
|
|
4556
|
+
try {
|
|
4557
|
+
const auth = await requireAuth(request);
|
|
4558
|
+
if (auth.error)
|
|
4559
|
+
return auth.error;
|
|
4560
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4561
|
+
if (roleErr)
|
|
4562
|
+
return roleErr;
|
|
4563
|
+
const body = (await request.json().catch(() => ({})));
|
|
4564
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4565
|
+
const form = await updateFormService(ctx, params.id, body);
|
|
4566
|
+
return json({ data: form });
|
|
4567
|
+
}
|
|
4568
|
+
catch (err) {
|
|
4569
|
+
return mapFormError(err) ?? internalError(err);
|
|
4570
|
+
}
|
|
4571
|
+
});
|
|
4572
|
+
router.delete('/forms/:id', async (request, params) => {
|
|
4573
|
+
try {
|
|
4574
|
+
const auth = await requireAuth(request);
|
|
4575
|
+
if (auth.error)
|
|
4576
|
+
return auth.error;
|
|
4577
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4578
|
+
if (roleErr)
|
|
4579
|
+
return roleErr;
|
|
4580
|
+
const url = new URL(request.url);
|
|
4581
|
+
const force = url.searchParams.get('force') === 'true';
|
|
4582
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4583
|
+
await deleteFormService(ctx, params.id, { force });
|
|
4584
|
+
return json({ data: { success: true } });
|
|
4585
|
+
}
|
|
4586
|
+
catch (err) {
|
|
4587
|
+
return mapFormError(err) ?? internalError(err);
|
|
4588
|
+
}
|
|
4589
|
+
});
|
|
4590
|
+
router.post('/forms/:id/duplicate', async (request, params) => {
|
|
4591
|
+
try {
|
|
4592
|
+
const auth = await requireAuth(request);
|
|
4593
|
+
if (auth.error)
|
|
4594
|
+
return auth.error;
|
|
4595
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4596
|
+
if (roleErr)
|
|
4597
|
+
return roleErr;
|
|
4598
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4599
|
+
const form = await duplicateFormService(ctx, params.id);
|
|
4600
|
+
return json({ data: form }, 201);
|
|
4601
|
+
}
|
|
4602
|
+
catch (err) {
|
|
4603
|
+
return mapFormError(err) ?? internalError(err);
|
|
4604
|
+
}
|
|
4605
|
+
});
|
|
4606
|
+
router.get('/forms/:id/schema', async (request, params) => {
|
|
4607
|
+
try {
|
|
4608
|
+
const auth = await requireAuth(request);
|
|
4609
|
+
if (auth.error)
|
|
4610
|
+
return auth.error;
|
|
4611
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4612
|
+
if (roleErr)
|
|
4613
|
+
return roleErr;
|
|
4614
|
+
const schema = await getFormSchemaService(params.id);
|
|
4615
|
+
return json({ data: schema });
|
|
4616
|
+
}
|
|
4617
|
+
catch (err) {
|
|
4618
|
+
return mapFormError(err) ?? internalError(err);
|
|
4619
|
+
}
|
|
4620
|
+
});
|
|
4621
|
+
router.put('/forms/:id/schema', async (request, params) => {
|
|
4622
|
+
try {
|
|
4623
|
+
const auth = await requireAuth(request);
|
|
4624
|
+
if (auth.error)
|
|
4625
|
+
return auth.error;
|
|
4626
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4627
|
+
if (roleErr)
|
|
4628
|
+
return roleErr;
|
|
4629
|
+
const body = (await request.json().catch(() => ({})));
|
|
4630
|
+
if (!Array.isArray(body.fields)) {
|
|
4631
|
+
return errorResponse('"fields" must be an array', 400);
|
|
4632
|
+
}
|
|
4633
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4634
|
+
const result = await saveFormSchemaService(ctx, params.id, body.fields, body.notes);
|
|
4635
|
+
return json({ data: result });
|
|
4636
|
+
}
|
|
4637
|
+
catch (err) {
|
|
4638
|
+
return mapFormError(err) ?? internalError(err);
|
|
4639
|
+
}
|
|
4640
|
+
});
|
|
4641
|
+
router.get('/forms/:id/notifications', async (request, params) => {
|
|
4642
|
+
try {
|
|
4643
|
+
const auth = await requireAuth(request);
|
|
4644
|
+
if (auth.error)
|
|
4645
|
+
return auth.error;
|
|
4646
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4647
|
+
if (roleErr)
|
|
4648
|
+
return roleErr;
|
|
4649
|
+
const settings = await getFormNotificationSettingsService(params.id);
|
|
4650
|
+
return json({ data: settings });
|
|
4651
|
+
}
|
|
4652
|
+
catch (err) {
|
|
4653
|
+
return mapFormError(err) ?? internalError(err);
|
|
4654
|
+
}
|
|
4655
|
+
});
|
|
4656
|
+
router.put('/forms/:id/notifications', async (request, params) => {
|
|
4657
|
+
try {
|
|
4658
|
+
const auth = await requireAuth(request);
|
|
4659
|
+
if (auth.error)
|
|
4660
|
+
return auth.error;
|
|
4661
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4662
|
+
if (roleErr)
|
|
4663
|
+
return roleErr;
|
|
4664
|
+
const body = (await request.json().catch(() => ({})));
|
|
4665
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4666
|
+
const settings = await updateFormNotificationSettingsService(ctx, params.id, body);
|
|
4667
|
+
return json({ data: settings });
|
|
4668
|
+
}
|
|
4669
|
+
catch (err) {
|
|
4670
|
+
return mapFormError(err) ?? internalError(err);
|
|
4671
|
+
}
|
|
4672
|
+
});
|
|
4673
|
+
// Lightweight notify toggle used by the forms table switch.
|
|
4674
|
+
router.put('/forms/:id/notify', async (request, params) => {
|
|
4675
|
+
try {
|
|
4676
|
+
const auth = await requireAuth(request);
|
|
4677
|
+
if (auth.error)
|
|
4678
|
+
return auth.error;
|
|
4679
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4680
|
+
if (roleErr)
|
|
4681
|
+
return roleErr;
|
|
4682
|
+
const body = (await request.json().catch(() => ({})));
|
|
4683
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4684
|
+
const settings = await setFormNotifyEnabledService(ctx, params.id, Boolean(body.enabled));
|
|
4685
|
+
return json({ data: { notifyEnabled: settings.enabled } });
|
|
4686
|
+
}
|
|
4687
|
+
catch (err) {
|
|
4688
|
+
return mapFormError(err) ?? internalError(err);
|
|
4689
|
+
}
|
|
4690
|
+
});
|
|
4691
|
+
router.get('/forms/:id/embed', async (request, params) => {
|
|
4692
|
+
try {
|
|
4693
|
+
const auth = await requireAuth(request);
|
|
4694
|
+
if (auth.error)
|
|
4695
|
+
return auth.error;
|
|
4696
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4697
|
+
if (roleErr)
|
|
4698
|
+
return roleErr;
|
|
4699
|
+
const settings = await getFormEmbedSettingsService(params.id);
|
|
4700
|
+
return json({ data: settings });
|
|
4701
|
+
}
|
|
4702
|
+
catch (err) {
|
|
4703
|
+
return mapFormError(err) ?? internalError(err);
|
|
4704
|
+
}
|
|
4705
|
+
});
|
|
4706
|
+
router.put('/forms/:id/embed', async (request, params) => {
|
|
4707
|
+
try {
|
|
4708
|
+
const auth = await requireAuth(request);
|
|
4709
|
+
if (auth.error)
|
|
4710
|
+
return auth.error;
|
|
4711
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4712
|
+
if (roleErr)
|
|
4713
|
+
return roleErr;
|
|
4714
|
+
const body = (await request.json().catch(() => ({})));
|
|
4715
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4716
|
+
const settings = await updateFormEmbedSettingsService(ctx, params.id, body);
|
|
4717
|
+
return json({ data: settings });
|
|
4718
|
+
}
|
|
4719
|
+
catch (err) {
|
|
4720
|
+
return mapFormError(err) ?? internalError(err);
|
|
4721
|
+
}
|
|
4722
|
+
});
|
|
4129
4723
|
// ---------------------------------------------------------------------------
|
|
4130
4724
|
// Redirects routes
|
|
4131
4725
|
// ---------------------------------------------------------------------------
|
|
@@ -4438,8 +5032,10 @@ export function registerCMSRoutes(router) {
|
|
|
4438
5032
|
}
|
|
4439
5033
|
const { suggestRedirectTarget } = await import('../redirects/suggest.js');
|
|
4440
5034
|
// Candidate set: published page/post URLs.
|
|
4441
|
-
const { gatherAuditEntities } = await import('../seo/audit-runner.js');
|
|
4442
|
-
const entities = await gatherAuditEntities(db()
|
|
5035
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
|
|
5036
|
+
const entities = await gatherAuditEntities(db(), {
|
|
5037
|
+
collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
|
|
5038
|
+
});
|
|
4443
5039
|
const candidates = entities.map((e) => ({ url: e.url, title: e.title }));
|
|
4444
5040
|
const unresolved = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
|
|
4445
5041
|
let created = 0;
|
|
@@ -5046,60 +5642,13 @@ export function registerCMSRoutes(router) {
|
|
|
5046
5642
|
if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
|
|
5047
5643
|
return errorResponse('Too many scans. Please try again later.', 429);
|
|
5048
5644
|
}
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
data: true,
|
|
5057
|
-
plainText: true,
|
|
5058
|
-
},
|
|
5059
|
-
orderBy: { updatedAt: 'desc' },
|
|
5060
|
-
take: 5000,
|
|
5061
|
-
});
|
|
5062
|
-
const issues = [];
|
|
5063
|
-
for (const doc of documents) {
|
|
5064
|
-
const data = (doc.data ?? {});
|
|
5065
|
-
const problems = [];
|
|
5066
|
-
if (!data.metaTitle && !data.seoTitle)
|
|
5067
|
-
problems.push('Missing meta title');
|
|
5068
|
-
if (!data.metaDescription && !data.seoDescription)
|
|
5069
|
-
problems.push('Missing meta description');
|
|
5070
|
-
if (!data.canonical)
|
|
5071
|
-
problems.push('No canonical URL set');
|
|
5072
|
-
if (!data.schemaType)
|
|
5073
|
-
problems.push('No Schema.org type');
|
|
5074
|
-
const plainText = (doc.plainText ?? '');
|
|
5075
|
-
if (plainText.length > 0 && plainText.length < 300)
|
|
5076
|
-
problems.push('Content is too short (< 300 characters)');
|
|
5077
|
-
const content = typeof data.body === 'string'
|
|
5078
|
-
? data.body
|
|
5079
|
-
: typeof data.content === 'string'
|
|
5080
|
-
? data.content
|
|
5081
|
-
: '';
|
|
5082
|
-
if (content) {
|
|
5083
|
-
const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
|
|
5084
|
-
const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
|
|
5085
|
-
if (missingAlt > 0)
|
|
5086
|
-
problems.push(`${missingAlt} image(s) missing alt text`);
|
|
5087
|
-
if (!content.includes('<h1') && !content.includes('<h1>'))
|
|
5088
|
-
problems.push('No H1 heading found in content');
|
|
5089
|
-
}
|
|
5090
|
-
if (problems.length > 0) {
|
|
5091
|
-
issues.push({
|
|
5092
|
-
documentId: doc.id,
|
|
5093
|
-
title: doc.title ?? 'Untitled',
|
|
5094
|
-
slug: doc.slug ?? '',
|
|
5095
|
-
problems,
|
|
5096
|
-
});
|
|
5097
|
-
}
|
|
5098
|
-
}
|
|
5099
|
-
const total = documents.length;
|
|
5100
|
-
const pagesWithIssues = issues.length;
|
|
5101
|
-
const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
|
|
5102
|
-
return json({ data: { total, pagesWithIssues, totalProblems, issues } });
|
|
5645
|
+
// Scope the scan to configured front-end content collections so structural
|
|
5646
|
+
// (navigation/footer) and benchmark documents are never reported. Shares
|
|
5647
|
+
// the exact scan logic with the cron path via processSeoScan.
|
|
5648
|
+
const { frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
|
|
5649
|
+
const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
|
|
5650
|
+
const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
|
|
5651
|
+
return json({ data: result });
|
|
5103
5652
|
}
|
|
5104
5653
|
catch (err) {
|
|
5105
5654
|
return internalError(err);
|
|
@@ -5152,9 +5701,10 @@ export function registerCMSRoutes(router) {
|
|
|
5152
5701
|
};
|
|
5153
5702
|
}
|
|
5154
5703
|
async function buildSeoContentRecords() {
|
|
5155
|
-
const { gatherAuditEntities } = await import('../seo/audit-runner.js');
|
|
5704
|
+
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
|
|
5156
5705
|
const { calculatePageSeoScore } = await import('../seo/score.js');
|
|
5157
|
-
const
|
|
5706
|
+
const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
|
|
5707
|
+
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
5158
5708
|
return entities.map((e) => {
|
|
5159
5709
|
const score = calculatePageSeoScore(e);
|
|
5160
5710
|
return {
|
|
@@ -5232,8 +5782,12 @@ export function registerCMSRoutes(router) {
|
|
|
5232
5782
|
}
|
|
5233
5783
|
const body = (await request.json().catch(() => ({})));
|
|
5234
5784
|
const scope = body?.scope ?? 'full';
|
|
5235
|
-
const { runAndPersistAudit } = await import('../seo/audit-runner.js');
|
|
5236
|
-
const run = await runAndPersistAudit(db(), {
|
|
5785
|
+
const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
|
|
5786
|
+
const run = await runAndPersistAudit(db(), {
|
|
5787
|
+
scope,
|
|
5788
|
+
startedById: auth.session.userId,
|
|
5789
|
+
collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
|
|
5790
|
+
});
|
|
5237
5791
|
try {
|
|
5238
5792
|
await logEvent({
|
|
5239
5793
|
event: 'settings_changed',
|
|
@@ -5827,6 +6381,13 @@ export function registerCMSRoutes(router) {
|
|
|
5827
6381
|
const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
|
|
5828
6382
|
const out = [];
|
|
5829
6383
|
for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
|
|
6384
|
+
// Only publicly-rendered content types belong in the sitemap. Structural
|
|
6385
|
+
// / utility collections (navigation menus, footers, tags, templates) have
|
|
6386
|
+
// no `type` and must never produce indexable sitemap URLs.
|
|
6387
|
+
if (col.type !== 'page' && col.type !== 'post')
|
|
6388
|
+
continue;
|
|
6389
|
+
if (col.admin?.hidden)
|
|
6390
|
+
continue;
|
|
5830
6391
|
if (excluded.has(slug))
|
|
5831
6392
|
continue;
|
|
5832
6393
|
if (col.seo?.excludeFromSitemap)
|
|
@@ -5907,6 +6468,12 @@ export function registerCMSRoutes(router) {
|
|
|
5907
6468
|
const collection = cfg?.collections?.[rawSlug];
|
|
5908
6469
|
if (!collection)
|
|
5909
6470
|
return errorResponse('Collection not found', 404);
|
|
6471
|
+
// Only publicly-rendered page/post collections are sitemap-eligible —
|
|
6472
|
+
// structural/utility collections (menus, tags, templates) are not.
|
|
6473
|
+
if (collection.type !== 'page' && collection.type !== 'post')
|
|
6474
|
+
return errorResponse('Collection excluded from sitemap', 404);
|
|
6475
|
+
if (collection.admin?.hidden)
|
|
6476
|
+
return errorResponse('Collection excluded from sitemap', 404);
|
|
5910
6477
|
if (collection.seo?.excludeFromSitemap)
|
|
5911
6478
|
return errorResponse('Collection excluded from sitemap', 404);
|
|
5912
6479
|
const base = siteUrlFromRequest(request, cfg);
|
|
@@ -8023,7 +8590,9 @@ export function registerCMSRoutes(router) {
|
|
|
8023
8590
|
if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
|
|
8024
8591
|
return errorResponse('Unauthorized', 401);
|
|
8025
8592
|
}
|
|
8026
|
-
const
|
|
8593
|
+
const { frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
|
|
8594
|
+
const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
|
|
8595
|
+
const result = await processSeoScan(db(), { allowedCollections });
|
|
8027
8596
|
return json({ data: result });
|
|
8028
8597
|
}
|
|
8029
8598
|
catch (err) {
|