@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.
- 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-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__/seo/audit-runner.test.js +54 -1
- package/dist/__tests__/seo/audit-runner.test.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +451 -101
- package/dist/api/handlers.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/index.d.ts +4 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +3 -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/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/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/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,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
|
|
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 });
|
|
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
|
-
|
|
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 } });
|
|
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
|
|
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(), {
|
|
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
|
|
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) {
|