@actuate-media/cms-core 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/__tests__/api/admin-contracts.test.js +10 -2
  2. package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
  3. package/dist/__tests__/api/public-seo.test.js +2 -0
  4. package/dist/__tests__/api/public-seo.test.js.map +1 -1
  5. package/dist/__tests__/forms/forms-service.test.d.ts +2 -0
  6. package/dist/__tests__/forms/forms-service.test.d.ts.map +1 -0
  7. package/dist/__tests__/forms/forms-service.test.js +193 -0
  8. package/dist/__tests__/forms/forms-service.test.js.map +1 -0
  9. package/dist/__tests__/seo/audit-runner.test.js +54 -1
  10. package/dist/__tests__/seo/audit-runner.test.js.map +1 -1
  11. package/dist/api/handlers.d.ts.map +1 -1
  12. package/dist/api/handlers.js +451 -101
  13. package/dist/api/handlers.js.map +1 -1
  14. package/dist/config/types.d.ts +42 -0
  15. package/dist/config/types.d.ts.map +1 -1
  16. package/dist/cron/index.d.ts +1 -0
  17. package/dist/cron/index.d.ts.map +1 -1
  18. package/dist/cron/index.js +10 -1
  19. package/dist/cron/index.js.map +1 -1
  20. package/dist/forms/entries.d.ts +45 -0
  21. package/dist/forms/entries.d.ts.map +1 -0
  22. package/dist/forms/entries.js +348 -0
  23. package/dist/forms/entries.js.map +1 -0
  24. package/dist/forms/index.d.ts +4 -0
  25. package/dist/forms/index.d.ts.map +1 -1
  26. package/dist/forms/index.js +3 -0
  27. package/dist/forms/index.js.map +1 -1
  28. package/dist/forms/service.d.ts +92 -0
  29. package/dist/forms/service.d.ts.map +1 -0
  30. package/dist/forms/service.js +579 -0
  31. package/dist/forms/service.js.map +1 -0
  32. package/dist/forms/types.d.ts +244 -0
  33. package/dist/forms/types.d.ts.map +1 -0
  34. package/dist/forms/types.js +36 -0
  35. package/dist/forms/types.js.map +1 -0
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/seo/audit-runner.d.ts +37 -2
  41. package/dist/seo/audit-runner.d.ts.map +1 -1
  42. package/dist/seo/audit-runner.js +56 -6
  43. package/dist/seo/audit-runner.js.map +1 -1
  44. package/dist/seo/index.d.ts +2 -1
  45. package/dist/seo/index.d.ts.map +1 -1
  46. package/dist/seo/index.js +1 -1
  47. package/dist/seo/index.js.map +1 -1
  48. package/package.json +1 -1
  49. package/prisma/cms-schema.prisma +81 -7
  50. package/prisma/migrations/0010_forms/migration.sql +130 -0
  51. package/prisma/schema.prisma +130 -35
@@ -23,6 +23,8 @@ 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, 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';
26
28
  import { checkForUpdates } from '../upgrade/version-check.js';
27
29
  import { createUpgradePR } from '../upgrade/upgrade-pr.js';
28
30
  import { encryptField, decryptField } from '../security/encrypted-fields.js';
@@ -246,17 +248,6 @@ function asRecord(value) {
246
248
  ? value
247
249
  : {};
248
250
  }
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
251
  function normalizeSubmission(submission) {
261
252
  const data = asRecord(submission.data);
262
253
  const attribution = asRecord(submission.attribution);
@@ -281,6 +272,66 @@ function normalizeSubmission(submission) {
281
272
  },
282
273
  };
283
274
  }
275
+ /**
276
+ * Map a forms-service domain error to an HTTP response. Returns `null` for
277
+ * unrecognised errors so the caller can fall through to `internalError`.
278
+ */
279
+ function mapFormError(err) {
280
+ if (err instanceof FormNotFoundError)
281
+ return errorResponse(err.message, 404);
282
+ if (err instanceof FormValidationError) {
283
+ return json({ error: err.message, code: 'INVALID_SCHEMA', details: err.errors }, 400);
284
+ }
285
+ if (err instanceof FormConflictError)
286
+ return errorResponse(err.message, 409);
287
+ if (err instanceof FormHasSubmissionsError) {
288
+ return json({ error: err.message, code: 'FORM_HAS_SUBMISSIONS', submissionCount: err.submissionCount }, 409);
289
+ }
290
+ return null;
291
+ }
292
+ function parseFormsQuery(url) {
293
+ const sp = url.searchParams;
294
+ const status = sp.get('status');
295
+ const notify = sp.get('notifyEnabled');
296
+ return {
297
+ search: sp.get('search') ?? undefined,
298
+ status: status === 'active' || status === 'draft' || status === 'archived' ? status : undefined,
299
+ notifyEnabled: notify == null ? undefined : notify === 'true',
300
+ sortBy: sp.get('sortBy') ?? undefined,
301
+ sortDirection: sp.get('sortDirection') === 'asc'
302
+ ? 'asc'
303
+ : sp.get('sortDirection') === 'desc'
304
+ ? 'desc'
305
+ : undefined,
306
+ page: sp.get('page') ? Number(sp.get('page')) : undefined,
307
+ pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
308
+ };
309
+ }
310
+ function parseEntriesQuery(url) {
311
+ const sp = url.searchParams;
312
+ const spam = sp.get('spamStatus');
313
+ const boolParam = (key) => {
314
+ const v = sp.get(key);
315
+ return v == null ? undefined : v === 'true';
316
+ };
317
+ return {
318
+ search: sp.get('search') ?? undefined,
319
+ formId: sp.get('formId') ?? sp.get('form') ?? undefined,
320
+ unread: boolParam('unread'),
321
+ starred: boolParam('starred'),
322
+ archived: boolParam('archived'),
323
+ spamStatus: spam === 'clean' || spam === 'suspected' || spam === 'spam'
324
+ ? spam
325
+ : undefined,
326
+ dateFrom: sp.get('dateFrom') ?? undefined,
327
+ dateTo: sp.get('dateTo') ?? undefined,
328
+ thisWeek: boolParam('thisWeek'),
329
+ sortBy: sp.get('sortBy') ?? undefined,
330
+ sortDirection: sp.get('sortDirection') === 'asc' ? 'asc' : undefined,
331
+ page: sp.get('page') ? Number(sp.get('page')) : undefined,
332
+ pageSize: sp.get('pageSize') ? Number(sp.get('pageSize')) : undefined,
333
+ };
334
+ }
284
335
  function normalizeRedirect(redirect) {
285
336
  return {
286
337
  ...redirect,
@@ -3992,35 +4043,17 @@ export function registerCMSRoutes(router) {
3992
4043
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
3993
4044
  if (roleErr)
3994
4045
  return roleErr;
3995
- const d = db();
3996
- const forms = await d.document.findMany({
3997
- where: { collection: 'forms', deletedAt: null },
3998
- orderBy: { createdAt: 'desc' },
3999
- });
4000
- // Single grouped count is far cheaper than N+1 count() per form once
4001
- // the dashboard grows past a handful of forms. Fall back to per-form
4002
- // .count() when the consumer's Prisma client doesn't expose groupBy
4003
- // (older schemas, custom adapters).
4004
- let submissionCounts = new Map();
4005
- if (hasModel(d, 'formSubmission') && forms.length > 0) {
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 });
4046
+ const url = new URL(request.url);
4047
+ const { forms, total, page, pageSize } = await listFormsService(parseFormsQuery(url));
4048
+ // Response is a superset of the typed FormDefinition with legacy aliases
4049
+ // (`submissions`, `fieldCount`) so older admin views keep working while
4050
+ // PR3 migrates them to the typed shape.
4051
+ const data = forms.map((f) => ({
4052
+ ...f,
4053
+ fieldCount: f.fields?.length ?? 0,
4054
+ submissions: f.totalEntries ?? 0,
4055
+ }));
4056
+ return json({ data, total, page, pageSize });
4024
4057
  }
4025
4058
  catch (err) {
4026
4059
  return internalError(err);
@@ -4127,6 +4160,348 @@ export function registerCMSRoutes(router) {
4127
4160
  }
4128
4161
  });
4129
4162
  // ---------------------------------------------------------------------------
4163
+ // Forms management routes (admin) — services in ../forms/*.
4164
+ //
4165
+ // Route order matters: the router matches the first registered pattern, and
4166
+ // `:id` is a greedy `[^/]+`. All static `/forms/<word>` GETs are registered
4167
+ // before `/forms/:id` so e.g. `/forms/entries` is not captured as an id.
4168
+ // ---------------------------------------------------------------------------
4169
+ router.get('/forms/stats', async (request) => {
4170
+ try {
4171
+ const auth = await requireAuth(request);
4172
+ if (auth.error)
4173
+ return auth.error;
4174
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4175
+ if (roleErr)
4176
+ return roleErr;
4177
+ return json({ data: await getFormStatsService() });
4178
+ }
4179
+ catch (err) {
4180
+ return internalError(err);
4181
+ }
4182
+ });
4183
+ router.get('/forms/sidebar-counts', async (request) => {
4184
+ try {
4185
+ const auth = await requireAuth(request);
4186
+ if (auth.error)
4187
+ return auth.error;
4188
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4189
+ if (roleErr)
4190
+ return roleErr;
4191
+ return json({ data: await getFormsSidebarCountsService() });
4192
+ }
4193
+ catch (err) {
4194
+ return internalError(err);
4195
+ }
4196
+ });
4197
+ router.get('/forms/entries/counts', async (request) => {
4198
+ try {
4199
+ const auth = await requireAuth(request);
4200
+ if (auth.error)
4201
+ return auth.error;
4202
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4203
+ if (roleErr)
4204
+ return roleErr;
4205
+ const counts = await getEntryCountsService(parseEntriesQuery(new URL(request.url)));
4206
+ return json({ data: counts });
4207
+ }
4208
+ catch (err) {
4209
+ return internalError(err);
4210
+ }
4211
+ });
4212
+ router.get('/forms/entries/:entryId', async (request, params) => {
4213
+ try {
4214
+ const auth = await requireAuth(request);
4215
+ if (auth.error)
4216
+ return auth.error;
4217
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4218
+ if (roleErr)
4219
+ return roleErr;
4220
+ const result = await getEntryWithSchemaService(params.entryId);
4221
+ if (!result)
4222
+ return errorResponse('Entry not found', 404);
4223
+ return json({ data: result });
4224
+ }
4225
+ catch (err) {
4226
+ return internalError(err);
4227
+ }
4228
+ });
4229
+ router.patch('/forms/entries/:entryId', async (request, params) => {
4230
+ try {
4231
+ const auth = await requireAuth(request);
4232
+ if (auth.error)
4233
+ return auth.error;
4234
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4235
+ if (roleErr)
4236
+ return roleErr;
4237
+ const body = (await request.json().catch(() => ({})));
4238
+ const ctx = buildActionContext(auth.session, db());
4239
+ const updated = await updateEntryService(ctx, params.entryId, body);
4240
+ if (!updated)
4241
+ return errorResponse('Entry not found', 404);
4242
+ return json({ data: updated });
4243
+ }
4244
+ catch (err) {
4245
+ return internalError(err);
4246
+ }
4247
+ });
4248
+ router.delete('/forms/entries/:entryId', async (request, params) => {
4249
+ try {
4250
+ const auth = await requireAuth(request);
4251
+ if (auth.error)
4252
+ return auth.error;
4253
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4254
+ if (roleErr)
4255
+ return roleErr;
4256
+ const ctx = buildActionContext(auth.session, db());
4257
+ await deleteEntryService(ctx, params.entryId);
4258
+ return json({ data: { success: true } });
4259
+ }
4260
+ catch (err) {
4261
+ return internalError(err);
4262
+ }
4263
+ });
4264
+ router.post('/forms/entries/bulk', async (request) => {
4265
+ try {
4266
+ const auth = await requireAuth(request);
4267
+ if (auth.error)
4268
+ return auth.error;
4269
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4270
+ if (roleErr)
4271
+ return roleErr;
4272
+ const body = (await request.json().catch(() => ({})));
4273
+ if (!Array.isArray(body.ids) || body.ids.length === 0) {
4274
+ return errorResponse('"ids" must be a non-empty array', 400);
4275
+ }
4276
+ const ctx = buildActionContext(auth.session, db());
4277
+ const count = await bulkUpdateEntriesService(ctx, body.ids, body.patch ?? {});
4278
+ return json({ data: { updated: count } });
4279
+ }
4280
+ catch (err) {
4281
+ return internalError(err);
4282
+ }
4283
+ });
4284
+ router.get('/forms/entries', async (request) => {
4285
+ try {
4286
+ const auth = await requireAuth(request);
4287
+ if (auth.error)
4288
+ return auth.error;
4289
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4290
+ if (roleErr)
4291
+ return roleErr;
4292
+ const result = await listEntriesService(parseEntriesQuery(new URL(request.url)));
4293
+ return json({ data: result });
4294
+ }
4295
+ catch (err) {
4296
+ return internalError(err);
4297
+ }
4298
+ });
4299
+ router.post('/forms', async (request) => {
4300
+ try {
4301
+ const auth = await requireAuth(request);
4302
+ if (auth.error)
4303
+ return auth.error;
4304
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4305
+ if (roleErr)
4306
+ return roleErr;
4307
+ const body = (await request.json().catch(() => null));
4308
+ if (!body || typeof body.name !== 'string' || !body.name.trim()) {
4309
+ return errorResponse('"name" is required', 400);
4310
+ }
4311
+ const ctx = buildActionContext(auth.session, db());
4312
+ const form = await createFormService(ctx, body);
4313
+ return json({ data: form }, 201);
4314
+ }
4315
+ catch (err) {
4316
+ return mapFormError(err) ?? internalError(err);
4317
+ }
4318
+ });
4319
+ router.get('/forms/:id', async (request, params) => {
4320
+ try {
4321
+ const auth = await requireAuth(request);
4322
+ if (auth.error)
4323
+ return auth.error;
4324
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4325
+ if (roleErr)
4326
+ return roleErr;
4327
+ const form = await getFormByIdService(params.id);
4328
+ if (!form)
4329
+ return errorResponse('Form not found', 404);
4330
+ return json({ data: form });
4331
+ }
4332
+ catch (err) {
4333
+ return internalError(err);
4334
+ }
4335
+ });
4336
+ router.patch('/forms/:id', async (request, params) => {
4337
+ try {
4338
+ const auth = await requireAuth(request);
4339
+ if (auth.error)
4340
+ return auth.error;
4341
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4342
+ if (roleErr)
4343
+ return roleErr;
4344
+ const body = (await request.json().catch(() => ({})));
4345
+ const ctx = buildActionContext(auth.session, db());
4346
+ const form = await updateFormService(ctx, params.id, body);
4347
+ return json({ data: form });
4348
+ }
4349
+ catch (err) {
4350
+ return mapFormError(err) ?? internalError(err);
4351
+ }
4352
+ });
4353
+ router.delete('/forms/:id', async (request, params) => {
4354
+ try {
4355
+ const auth = await requireAuth(request);
4356
+ if (auth.error)
4357
+ return auth.error;
4358
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4359
+ if (roleErr)
4360
+ return roleErr;
4361
+ const url = new URL(request.url);
4362
+ const force = url.searchParams.get('force') === 'true';
4363
+ const ctx = buildActionContext(auth.session, db());
4364
+ await deleteFormService(ctx, params.id, { force });
4365
+ return json({ data: { success: true } });
4366
+ }
4367
+ catch (err) {
4368
+ return mapFormError(err) ?? internalError(err);
4369
+ }
4370
+ });
4371
+ router.post('/forms/:id/duplicate', async (request, params) => {
4372
+ try {
4373
+ const auth = await requireAuth(request);
4374
+ if (auth.error)
4375
+ return auth.error;
4376
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4377
+ if (roleErr)
4378
+ return roleErr;
4379
+ const ctx = buildActionContext(auth.session, db());
4380
+ const form = await duplicateFormService(ctx, params.id);
4381
+ return json({ data: form }, 201);
4382
+ }
4383
+ catch (err) {
4384
+ return mapFormError(err) ?? internalError(err);
4385
+ }
4386
+ });
4387
+ router.get('/forms/:id/schema', async (request, params) => {
4388
+ try {
4389
+ const auth = await requireAuth(request);
4390
+ if (auth.error)
4391
+ return auth.error;
4392
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4393
+ if (roleErr)
4394
+ return roleErr;
4395
+ const schema = await getFormSchemaService(params.id);
4396
+ return json({ data: schema });
4397
+ }
4398
+ catch (err) {
4399
+ return mapFormError(err) ?? internalError(err);
4400
+ }
4401
+ });
4402
+ router.put('/forms/:id/schema', async (request, params) => {
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
+ const body = (await request.json().catch(() => ({})));
4411
+ if (!Array.isArray(body.fields)) {
4412
+ return errorResponse('"fields" must be an array', 400);
4413
+ }
4414
+ const ctx = buildActionContext(auth.session, db());
4415
+ const result = await saveFormSchemaService(ctx, params.id, body.fields, body.notes);
4416
+ return json({ data: result });
4417
+ }
4418
+ catch (err) {
4419
+ return mapFormError(err) ?? internalError(err);
4420
+ }
4421
+ });
4422
+ router.get('/forms/:id/notifications', async (request, params) => {
4423
+ try {
4424
+ const auth = await requireAuth(request);
4425
+ if (auth.error)
4426
+ return auth.error;
4427
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4428
+ if (roleErr)
4429
+ return roleErr;
4430
+ const settings = await getFormNotificationSettingsService(params.id);
4431
+ return json({ data: settings });
4432
+ }
4433
+ catch (err) {
4434
+ return mapFormError(err) ?? internalError(err);
4435
+ }
4436
+ });
4437
+ router.put('/forms/:id/notifications', async (request, params) => {
4438
+ try {
4439
+ const auth = await requireAuth(request);
4440
+ if (auth.error)
4441
+ return auth.error;
4442
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4443
+ if (roleErr)
4444
+ return roleErr;
4445
+ const body = (await request.json().catch(() => ({})));
4446
+ const ctx = buildActionContext(auth.session, db());
4447
+ const settings = await updateFormNotificationSettingsService(ctx, params.id, body);
4448
+ return json({ data: settings });
4449
+ }
4450
+ catch (err) {
4451
+ return mapFormError(err) ?? internalError(err);
4452
+ }
4453
+ });
4454
+ // Lightweight notify toggle used by the forms table switch.
4455
+ router.put('/forms/:id/notify', async (request, params) => {
4456
+ try {
4457
+ const auth = await requireAuth(request);
4458
+ if (auth.error)
4459
+ return auth.error;
4460
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4461
+ if (roleErr)
4462
+ return roleErr;
4463
+ const body = (await request.json().catch(() => ({})));
4464
+ const ctx = buildActionContext(auth.session, db());
4465
+ const settings = await setFormNotifyEnabledService(ctx, params.id, Boolean(body.enabled));
4466
+ return json({ data: { notifyEnabled: settings.enabled } });
4467
+ }
4468
+ catch (err) {
4469
+ return mapFormError(err) ?? internalError(err);
4470
+ }
4471
+ });
4472
+ router.get('/forms/:id/embed', async (request, params) => {
4473
+ try {
4474
+ const auth = await requireAuth(request);
4475
+ if (auth.error)
4476
+ return auth.error;
4477
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4478
+ if (roleErr)
4479
+ return roleErr;
4480
+ const settings = await getFormEmbedSettingsService(params.id);
4481
+ return json({ data: settings });
4482
+ }
4483
+ catch (err) {
4484
+ return mapFormError(err) ?? internalError(err);
4485
+ }
4486
+ });
4487
+ router.put('/forms/:id/embed', async (request, params) => {
4488
+ try {
4489
+ const auth = await requireAuth(request);
4490
+ if (auth.error)
4491
+ return auth.error;
4492
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4493
+ if (roleErr)
4494
+ return roleErr;
4495
+ const body = (await request.json().catch(() => ({})));
4496
+ const ctx = buildActionContext(auth.session, db());
4497
+ const settings = await updateFormEmbedSettingsService(ctx, params.id, body);
4498
+ return json({ data: settings });
4499
+ }
4500
+ catch (err) {
4501
+ return mapFormError(err) ?? internalError(err);
4502
+ }
4503
+ });
4504
+ // ---------------------------------------------------------------------------
4130
4505
  // Redirects routes
4131
4506
  // ---------------------------------------------------------------------------
4132
4507
  router.get('/redirects', async (request) => {
@@ -4438,8 +4813,10 @@ export function registerCMSRoutes(router) {
4438
4813
  }
4439
4814
  const { suggestRedirectTarget } = await import('../redirects/suggest.js');
4440
4815
  // Candidate set: published page/post URLs.
4441
- const { gatherAuditEntities } = await import('../seo/audit-runner.js');
4442
- const entities = await gatherAuditEntities(db());
4816
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
4817
+ const entities = await gatherAuditEntities(db(), {
4818
+ collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
4819
+ });
4443
4820
  const candidates = entities.map((e) => ({ url: e.url, title: e.title }));
4444
4821
  const unresolved = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
4445
4822
  let created = 0;
@@ -5046,60 +5423,13 @@ export function registerCMSRoutes(router) {
5046
5423
  if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
5047
5424
  return errorResponse('Too many scans. Please try again later.', 429);
5048
5425
  }
5049
- const documents = await db().document.findMany({
5050
- where: { status: 'PUBLISHED', deletedAt: null },
5051
- select: {
5052
- id: true,
5053
- title: true,
5054
- slug: true,
5055
- collection: true,
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 } });
5426
+ // Scope the scan to configured front-end content collections so structural
5427
+ // (navigation/footer) and benchmark documents are never reported. Shares
5428
+ // the exact scan logic with the cron path via processSeoScan.
5429
+ const { frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5430
+ const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
5431
+ const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
5432
+ return json({ data: result });
5103
5433
  }
5104
5434
  catch (err) {
5105
5435
  return internalError(err);
@@ -5152,9 +5482,10 @@ export function registerCMSRoutes(router) {
5152
5482
  };
5153
5483
  }
5154
5484
  async function buildSeoContentRecords() {
5155
- const { gatherAuditEntities } = await import('../seo/audit-runner.js');
5485
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5156
5486
  const { calculatePageSeoScore } = await import('../seo/score.js');
5157
- const entities = await gatherAuditEntities(db());
5487
+ const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
5488
+ const entities = await gatherAuditEntities(db(), { collectionTypes });
5158
5489
  return entities.map((e) => {
5159
5490
  const score = calculatePageSeoScore(e);
5160
5491
  return {
@@ -5232,8 +5563,12 @@ export function registerCMSRoutes(router) {
5232
5563
  }
5233
5564
  const body = (await request.json().catch(() => ({})));
5234
5565
  const scope = body?.scope ?? 'full';
5235
- const { runAndPersistAudit } = await import('../seo/audit-runner.js');
5236
- const run = await runAndPersistAudit(db(), { scope, startedById: auth.session.userId });
5566
+ const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
5567
+ const run = await runAndPersistAudit(db(), {
5568
+ scope,
5569
+ startedById: auth.session.userId,
5570
+ collectionTypes: frontEndContentCollectionTypes(getActuateConfig()),
5571
+ });
5237
5572
  try {
5238
5573
  await logEvent({
5239
5574
  event: 'settings_changed',
@@ -5827,6 +6162,13 @@ export function registerCMSRoutes(router) {
5827
6162
  const excluded = new Set(resolved.seo?.sitemap?.excludeCollections ?? []);
5828
6163
  const out = [];
5829
6164
  for (const [slug, col] of Object.entries(resolved.collections ?? {})) {
6165
+ // Only publicly-rendered content types belong in the sitemap. Structural
6166
+ // / utility collections (navigation menus, footers, tags, templates) have
6167
+ // no `type` and must never produce indexable sitemap URLs.
6168
+ if (col.type !== 'page' && col.type !== 'post')
6169
+ continue;
6170
+ if (col.admin?.hidden)
6171
+ continue;
5830
6172
  if (excluded.has(slug))
5831
6173
  continue;
5832
6174
  if (col.seo?.excludeFromSitemap)
@@ -5907,6 +6249,12 @@ export function registerCMSRoutes(router) {
5907
6249
  const collection = cfg?.collections?.[rawSlug];
5908
6250
  if (!collection)
5909
6251
  return errorResponse('Collection not found', 404);
6252
+ // Only publicly-rendered page/post collections are sitemap-eligible —
6253
+ // structural/utility collections (menus, tags, templates) are not.
6254
+ if (collection.type !== 'page' && collection.type !== 'post')
6255
+ return errorResponse('Collection excluded from sitemap', 404);
6256
+ if (collection.admin?.hidden)
6257
+ return errorResponse('Collection excluded from sitemap', 404);
5910
6258
  if (collection.seo?.excludeFromSitemap)
5911
6259
  return errorResponse('Collection excluded from sitemap', 404);
5912
6260
  const base = siteUrlFromRequest(request, cfg);
@@ -8023,7 +8371,9 @@ export function registerCMSRoutes(router) {
8023
8371
  if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
8024
8372
  return errorResponse('Unauthorized', 401);
8025
8373
  }
8026
- const result = await processSeoScan(db());
8374
+ const { frontEndContentCollectionTypes } = await import('../seo/audit-runner.js');
8375
+ const allowedCollections = Object.keys(frontEndContentCollectionTypes(getActuateConfig()));
8376
+ const result = await processSeoScan(db(), { allowedCollections });
8027
8377
  return json({ data: result });
8028
8378
  }
8029
8379
  catch (err) {