@actuate-media/cms-core 0.28.0 → 0.29.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 (86) hide show
  1. package/dist/__tests__/diagnostics/env.test.js +18 -0
  2. package/dist/__tests__/diagnostics/env.test.js.map +1 -1
  3. package/dist/__tests__/media/seo.test.d.ts +2 -0
  4. package/dist/__tests__/media/seo.test.d.ts.map +1 -0
  5. package/dist/__tests__/media/seo.test.js +186 -0
  6. package/dist/__tests__/media/seo.test.js.map +1 -0
  7. package/dist/__tests__/redirects/graph.test.d.ts +2 -0
  8. package/dist/__tests__/redirects/graph.test.d.ts.map +1 -0
  9. package/dist/__tests__/redirects/graph.test.js +104 -0
  10. package/dist/__tests__/redirects/graph.test.js.map +1 -0
  11. package/dist/__tests__/redirects/suggest.test.d.ts +2 -0
  12. package/dist/__tests__/redirects/suggest.test.d.ts.map +1 -0
  13. package/dist/__tests__/redirects/suggest.test.js +28 -0
  14. package/dist/__tests__/redirects/suggest.test.js.map +1 -0
  15. package/dist/__tests__/seo/audit-engine.test.d.ts +2 -0
  16. package/dist/__tests__/seo/audit-engine.test.d.ts.map +1 -0
  17. package/dist/__tests__/seo/audit-engine.test.js +152 -0
  18. package/dist/__tests__/seo/audit-engine.test.js.map +1 -0
  19. package/dist/__tests__/seo/config-store.test.js +24 -160
  20. package/dist/__tests__/seo/config-store.test.js.map +1 -1
  21. package/dist/__tests__/seo/score.test.d.ts +2 -0
  22. package/dist/__tests__/seo/score.test.d.ts.map +1 -0
  23. package/dist/__tests__/seo/score.test.js +152 -0
  24. package/dist/__tests__/seo/score.test.js.map +1 -0
  25. package/dist/__tests__/setup/index.test.d.ts +2 -0
  26. package/dist/__tests__/setup/index.test.d.ts.map +1 -0
  27. package/dist/__tests__/setup/index.test.js +82 -0
  28. package/dist/__tests__/setup/index.test.js.map +1 -0
  29. package/dist/api/handlers.d.ts.map +1 -1
  30. package/dist/api/handlers.js +2095 -228
  31. package/dist/api/handlers.js.map +1 -1
  32. package/dist/diagnostics/env.d.ts.map +1 -1
  33. package/dist/diagnostics/env.js +9 -5
  34. package/dist/diagnostics/env.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/media/index.d.ts +2 -0
  40. package/dist/media/index.d.ts.map +1 -1
  41. package/dist/media/index.js +1 -0
  42. package/dist/media/index.js.map +1 -1
  43. package/dist/media/seo.d.ts +88 -0
  44. package/dist/media/seo.d.ts.map +1 -0
  45. package/dist/media/seo.js +215 -0
  46. package/dist/media/seo.js.map +1 -0
  47. package/dist/redirects/graph.d.ts +68 -0
  48. package/dist/redirects/graph.d.ts.map +1 -0
  49. package/dist/redirects/graph.js +136 -0
  50. package/dist/redirects/graph.js.map +1 -0
  51. package/dist/redirects/index.d.ts +5 -0
  52. package/dist/redirects/index.d.ts.map +1 -0
  53. package/dist/redirects/index.js +3 -0
  54. package/dist/redirects/index.js.map +1 -0
  55. package/dist/redirects/suggest.d.ts +21 -0
  56. package/dist/redirects/suggest.d.ts.map +1 -0
  57. package/dist/redirects/suggest.js +55 -0
  58. package/dist/redirects/suggest.js.map +1 -0
  59. package/dist/seo/audit-engine.d.ts +119 -0
  60. package/dist/seo/audit-engine.d.ts.map +1 -0
  61. package/dist/seo/audit-engine.js +402 -0
  62. package/dist/seo/audit-engine.js.map +1 -0
  63. package/dist/seo/audit-runner.d.ts +41 -0
  64. package/dist/seo/audit-runner.d.ts.map +1 -0
  65. package/dist/seo/audit-runner.js +223 -0
  66. package/dist/seo/audit-runner.js.map +1 -0
  67. package/dist/seo/config-store.d.ts +28 -0
  68. package/dist/seo/config-store.d.ts.map +1 -1
  69. package/dist/seo/config-store.js +22 -0
  70. package/dist/seo/config-store.js.map +1 -1
  71. package/dist/seo/index.d.ts +7 -2
  72. package/dist/seo/index.d.ts.map +1 -1
  73. package/dist/seo/index.js +4 -1
  74. package/dist/seo/index.js.map +1 -1
  75. package/dist/seo/score.d.ts +128 -0
  76. package/dist/seo/score.d.ts.map +1 -0
  77. package/dist/seo/score.js +213 -0
  78. package/dist/seo/score.js.map +1 -0
  79. package/dist/setup/index.d.ts +16 -1
  80. package/dist/setup/index.d.ts.map +1 -1
  81. package/dist/setup/index.js +52 -2
  82. package/dist/setup/index.js.map +1 -1
  83. package/package.json +1 -1
  84. package/prisma/migrations/0008_media_center/migration.sql +47 -0
  85. package/prisma/migrations/0009_seo/migration.sql +143 -0
  86. package/prisma/schema.prisma +183 -8
@@ -165,6 +165,82 @@ function normalizeMediaItem(media) {
165
165
  title: media.title ?? '',
166
166
  };
167
167
  }
168
+ /**
169
+ * Build the admin editor route for a document that references a media asset.
170
+ * Pages own the `/pages/*` namespace; post-type collections use
171
+ * `/posts/:type/:id/edit`; everything else falls back to the generic
172
+ * collection editor. Kept in sync with the route table in `AdminRoot.tsx`.
173
+ */
174
+ function mediaUsageEditPath(collection, id) {
175
+ if (collection === 'pages')
176
+ return `/pages/${id}/edit`;
177
+ const def = getActuateConfig()?.collections?.[collection];
178
+ if (def?.type === 'post')
179
+ return `/posts/${collection}/${id}/edit`;
180
+ return `/collections/${collection}/${id}`;
181
+ }
182
+ /**
183
+ * Map the `MediaUsage` join rows (with their related document) into the
184
+ * `{ page, path }[]` shape the admin drawer renders. Soft-deleted documents
185
+ * are excluded so trashed pages don't keep an asset looking "in use".
186
+ */
187
+ function buildMediaUsedOn(mediaUsages) {
188
+ if (!Array.isArray(mediaUsages))
189
+ return [];
190
+ const seen = new Set();
191
+ const out = [];
192
+ for (const usage of mediaUsages) {
193
+ const doc = usage?.document;
194
+ if (!doc || doc.deletedAt || seen.has(doc.id))
195
+ continue;
196
+ seen.add(doc.id);
197
+ out.push({
198
+ page: doc.title || '(untitled)',
199
+ path: mediaUsageEditPath(doc.collection, doc.id),
200
+ });
201
+ }
202
+ return out;
203
+ }
204
+ const MEDIA_TITLE_STOPWORDS = new Set([
205
+ 'a',
206
+ 'an',
207
+ 'the',
208
+ 'of',
209
+ 'and',
210
+ 'or',
211
+ 'to',
212
+ 'in',
213
+ 'on',
214
+ 'for',
215
+ 'with',
216
+ 'at',
217
+ 'by',
218
+ 'from',
219
+ 'is',
220
+ 'are',
221
+ 'as',
222
+ ]);
223
+ /**
224
+ * Derive a concise, Title-Cased title from AI-generated alt text. The alt
225
+ * text is already a model output describing the image, so the title stays
226
+ * grounded in real content rather than the filename.
227
+ */
228
+ function deriveMediaTitleFromAlt(alt) {
229
+ if (!alt)
230
+ return '';
231
+ const firstClause = (alt.split(/[.;:\n]/)[0] ?? '').trim();
232
+ const titled = firstClause
233
+ .split(/\s+/)
234
+ .slice(0, 9)
235
+ .map((word, i) => {
236
+ const lower = word.toLowerCase();
237
+ if (i > 0 && MEDIA_TITLE_STOPWORDS.has(lower))
238
+ return lower;
239
+ return word.charAt(0).toUpperCase() + word.slice(1);
240
+ })
241
+ .join(' ');
242
+ return titled.slice(0, 80).replace(/[\s,]+$/, '');
243
+ }
168
244
  function asRecord(value) {
169
245
  return value && typeof value === 'object' && !Array.isArray(value)
170
246
  ? value
@@ -241,6 +317,61 @@ function hasModel(d, name) {
241
317
  return false;
242
318
  }
243
319
  }
320
+ /** Format a number compactly, e.g. 8400 -> "8.4K", 1_200_000 -> "1.2M". */
321
+ function formatCompactNumber(n) {
322
+ if (!Number.isFinite(n))
323
+ return '0';
324
+ if (Math.abs(n) >= 1_000_000)
325
+ return `${(n / 1_000_000).toFixed(1)}M`;
326
+ if (Math.abs(n) >= 1_000)
327
+ return `${(n / 1_000).toFixed(1)}K`;
328
+ return String(Math.round(n));
329
+ }
330
+ /** Map a 0-100 site score to a grade band (mirrors seo/score.gradeForSeoScore). */
331
+ function gradeForScoreBand(score) {
332
+ if (score >= 80)
333
+ return 'good';
334
+ if (score >= 60)
335
+ return 'fair';
336
+ if (score >= 1)
337
+ return 'poor';
338
+ return 'critical';
339
+ }
340
+ /** Minimal CSV line parser supporting double-quoted fields with escaped quotes. */
341
+ function parseCsvLine(line) {
342
+ const out = [];
343
+ let cur = '';
344
+ let inQuotes = false;
345
+ for (let i = 0; i < line.length; i++) {
346
+ const ch = line[i];
347
+ if (inQuotes) {
348
+ if (ch === '"') {
349
+ if (line[i + 1] === '"') {
350
+ cur += '"';
351
+ i++;
352
+ }
353
+ else {
354
+ inQuotes = false;
355
+ }
356
+ }
357
+ else {
358
+ cur += ch;
359
+ }
360
+ }
361
+ else if (ch === '"') {
362
+ inQuotes = true;
363
+ }
364
+ else if (ch === ',') {
365
+ out.push(cur);
366
+ cur = '';
367
+ }
368
+ else {
369
+ cur += ch;
370
+ }
371
+ }
372
+ out.push(cur);
373
+ return out;
374
+ }
244
375
  function modelNotAvailable(name) {
245
376
  return errorResponse(`The "${name}" model is not available in your Prisma schema. ` +
246
377
  'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
@@ -672,6 +803,7 @@ const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 100
672
803
  const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
673
804
  const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
674
805
  const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
806
+ const seoAuditLimiter = createRateLimiter({ maxRequests: 10, windowMs: 60 * 60 * 1000 });
675
807
  async function checkRateLimitAsync(limiter, key) {
676
808
  // Explicit, environment-gated bypass for test harnesses. Production never
677
809
  // sets this — Vercel + the deploy guide both omit it. We deliberately do
@@ -2255,10 +2387,25 @@ export function registerCMSRoutes(router) {
2255
2387
  where.folderId = folderParam;
2256
2388
  }
2257
2389
  const [items, total] = await Promise.all([
2258
- db().media.findMany({ where, skip, take: pageSize, orderBy: { createdAt: 'desc' } }),
2390
+ db().media.findMany({
2391
+ where,
2392
+ skip,
2393
+ take: pageSize,
2394
+ orderBy: { createdAt: 'desc' },
2395
+ include: {
2396
+ mediaUsages: {
2397
+ include: {
2398
+ document: { select: { id: true, collection: true, title: true, deletedAt: true } },
2399
+ },
2400
+ },
2401
+ },
2402
+ }),
2259
2403
  db().media.count({ where }),
2260
2404
  ]);
2261
- const normalized = items.map(normalizeMediaItem);
2405
+ const normalized = items.map((item) => {
2406
+ const { mediaUsages, ...rest } = normalizeMediaItem(item);
2407
+ return { ...rest, usedOn: buildMediaUsedOn(mediaUsages) };
2408
+ });
2262
2409
  return json({
2263
2410
  data: {
2264
2411
  data: normalized,
@@ -4058,133 +4205,208 @@ export function registerCMSRoutes(router) {
4058
4205
  }
4059
4206
  });
4060
4207
  // ---------------------------------------------------------------------------
4061
- // SEO routes
4208
+ // Redirect engine — /seo/redirects/* (CRUD, chains/loops, 404 recovery, CSV)
4062
4209
  // ---------------------------------------------------------------------------
4063
- router.get('/seo/pages', async (request) => {
4210
+ const REDIRECT_STATUS_CODES = [301, 302, 307, 308, 410];
4211
+ function redirectToRule(r) {
4212
+ return {
4213
+ id: r.id,
4214
+ type: Number(r.statusCode ?? 301),
4215
+ fromPath: r.source ?? '',
4216
+ toPath: r.destination ?? '',
4217
+ enabled: r.enabled ?? true,
4218
+ priority: Number(r.priority ?? 0),
4219
+ preserveQuery: r.preserveQuery ?? false,
4220
+ hits: Number(r.hits ?? 0),
4221
+ lastHitAt: r.lastHitAt ?? null,
4222
+ stale: requireRedirectGraph().isRedirectStale(r.lastHitAt),
4223
+ source: r.source_kind ?? 'manual',
4224
+ notes: r.notes ?? null,
4225
+ createdAt: r.createdAt ?? null,
4226
+ updatedAt: r.updatedAt ?? null,
4227
+ };
4228
+ }
4229
+ // Lazily-loaded pure graph helpers (sync after first load is fine because
4230
+ // the module is tiny and the dynamic import resolves before any route runs
4231
+ // that needs it; we cache the namespace).
4232
+ let _graph = null;
4233
+ function requireRedirectGraph() {
4234
+ if (!_graph)
4235
+ throw new Error('redirect graph not loaded');
4236
+ return _graph;
4237
+ }
4238
+ async function ensureRedirectGraph() {
4239
+ if (!_graph)
4240
+ _graph = await import('../redirects/graph.js');
4241
+ return _graph;
4242
+ }
4243
+ /** Validate a redirect destination (open-redirect defence). */
4244
+ function validateRedirectDestination(destination, statusCode) {
4245
+ if (statusCode === 410)
4246
+ return null; // Gone: destination is informational
4247
+ if (destination.startsWith('http://') || destination.startsWith('https://')) {
4248
+ let destUrl;
4249
+ try {
4250
+ destUrl = new URL(destination);
4251
+ }
4252
+ catch {
4253
+ return 'Invalid destination URL';
4254
+ }
4255
+ if (!['http:', 'https:'].includes(destUrl.protocol))
4256
+ return 'Invalid destination URL';
4257
+ const cmsConfig = getActuateConfig();
4258
+ const allowed = new Set(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
4259
+ ? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
4260
+ : []);
4261
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
4262
+ if (siteUrl) {
4263
+ try {
4264
+ allowed.add(new URL(siteUrl).hostname.toLowerCase());
4265
+ }
4266
+ catch {
4267
+ /* noop */
4268
+ }
4269
+ }
4270
+ if (!allowed.has(destUrl.hostname.toLowerCase())) {
4271
+ return 'External redirect destinations must be to an allowlisted host.';
4272
+ }
4273
+ }
4274
+ else if (!destination.startsWith('/')) {
4275
+ return 'Destination must be an absolute URL or a path beginning with /';
4276
+ }
4277
+ return null;
4278
+ }
4279
+ router.get('/seo/redirects', async (request) => {
4064
4280
  try {
4065
4281
  const auth = await requireAuth(request);
4066
4282
  if (auth.error)
4067
4283
  return auth.error;
4068
- const pages = await db().document.findMany({
4069
- where: { deletedAt: null, status: 'PUBLISHED' },
4070
- select: {
4071
- id: true,
4072
- collection: true,
4073
- data: true,
4074
- updatedAt: true,
4075
- structuredData: true,
4284
+ await ensureRedirectGraph();
4285
+ const rows = await db().redirect.findMany({
4286
+ orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
4287
+ });
4288
+ return json({ data: { rules: rows.map(redirectToRule) } });
4289
+ }
4290
+ catch (err) {
4291
+ return internalError(err, 'seo/redirects');
4292
+ }
4293
+ });
4294
+ router.get('/seo/redirects/overview', async (request) => {
4295
+ try {
4296
+ const auth = await requireAuth(request);
4297
+ if (auth.error)
4298
+ return auth.error;
4299
+ const graph = await ensureRedirectGraph();
4300
+ const rows = await db().redirect.findMany({});
4301
+ const active = rows.filter((r) => r.enabled !== false);
4302
+ const stale = active.filter((r) => graph.isRedirectStale(r.lastHitAt)).length;
4303
+ const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
4304
+ let notFoundCount = 0;
4305
+ let notFoundHits = 0;
4306
+ if (hasModel(db(), 'redirect404Hit')) {
4307
+ const hits = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
4308
+ notFoundCount = hits.length;
4309
+ notFoundHits = hits.reduce((s, h) => s + Number(h.hits ?? 0), 0);
4310
+ }
4311
+ const trafficRecovered = active.reduce((s, r) => s + Number(r.hits ?? 0), 0);
4312
+ return json({
4313
+ data: {
4314
+ activeRedirects: active.length,
4315
+ staleRedirects: stale,
4316
+ notFoundCount,
4317
+ notFoundHits,
4318
+ chainsAndLoops: chains.length,
4319
+ trafficRecovered,
4076
4320
  },
4077
- orderBy: { updatedAt: 'desc' },
4078
4321
  });
4079
- return json({ data: pages.map(normalizeSeoPage) });
4080
4322
  }
4081
4323
  catch (err) {
4082
- return internalError(err);
4324
+ return internalError(err, 'seo/redirects/overview');
4083
4325
  }
4084
4326
  });
4085
- router.get('/seo/link-health', async (request) => {
4327
+ router.get('/seo/redirects/chains', async (request) => {
4086
4328
  try {
4087
4329
  const auth = await requireAuth(request);
4088
4330
  if (auth.error)
4089
4331
  return auth.error;
4090
- // EDITOR+ only this endpoint hits arbitrary URLs and can be expensive.
4091
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4332
+ const graph = await ensureRedirectGraph();
4333
+ const rows = await db().redirect.findMany({});
4334
+ const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
4335
+ return json({ data: { chains } });
4336
+ }
4337
+ catch (err) {
4338
+ return internalError(err, 'seo/redirects/chains');
4339
+ }
4340
+ });
4341
+ router.post('/seo/redirects/flatten', async (request) => {
4342
+ try {
4343
+ const auth = await requireAuth(request);
4344
+ if (auth.error)
4345
+ return auth.error;
4346
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4092
4347
  if (roleErr)
4093
4348
  return roleErr;
4094
- // Tight rate limit because each call fans out to many outbound requests.
4095
- const ip = clientIp(request);
4096
- const rateKey = isResolvedIp(ip)
4097
- ? `link-health:${ip}`
4098
- : `link-health-user:${auth.session.userId}`;
4099
- if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
4100
- return errorResponse('Too many link-health scans. Please try again later.', 429);
4349
+ const graph = await ensureRedirectGraph();
4350
+ const body = (await request.json().catch(() => ({})));
4351
+ const rows = await db().redirect.findMany({});
4352
+ const chains = graph.detectChainsAndLoops(rows.map((r) => ({ id: r.id, from: r.source, to: r.destination, enabled: r.enabled })));
4353
+ const chain = chains.find((c) => c.id === body.chainId);
4354
+ if (!chain)
4355
+ return errorResponse('Chain not found', 404);
4356
+ if (chain.kind === 'loop') {
4357
+ return errorResponse('Cannot auto-flatten a loop — break the cycle manually.', 400);
4101
4358
  }
4102
- const MAX_LINKS_PER_PAGE = 50;
4103
- const MAX_TOTAL_LINKS = 500;
4104
- const PER_LINK_TIMEOUT_MS = 4000;
4105
- const CONCURRENCY = 8;
4106
- const docs = await db().document.findMany({
4107
- where: { deletedAt: null, status: 'PUBLISHED' },
4108
- select: { id: true, title: true, data: true, collection: true },
4359
+ // Point the chain source directly at the terminal destination.
4360
+ const sourceRow = rows.find((r) => graph.normalizeRedirectPath(r.source) === chain.suggestedFrom);
4361
+ if (!sourceRow)
4362
+ return errorResponse('Chain source rule not found', 404);
4363
+ const updated = await db().redirect.update({
4364
+ where: { id: sourceRow.id },
4365
+ data: { destination: chain.suggestedTo, source_kind: 'chain-flatten' },
4109
4366
  });
4110
- const urlRegex = /https?:\/\/[^\s"'<>]+/g;
4111
- const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
4112
- const queue = [];
4113
- const seenGlobal = new Set();
4114
- outer: for (const doc of docs) {
4115
- const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
4116
- const content = JSON.stringify(doc.data ?? {});
4117
- const urls = content.match(urlRegex) ?? [];
4118
- const seenInPage = new Set();
4119
- let countInPage = 0;
4120
- for (const url of urls) {
4121
- const clean = url.replace(/[",;)}\]]+$/, '');
4122
- if (seenInPage.has(clean))
4123
- continue;
4124
- seenInPage.add(clean);
4125
- if (seenGlobal.has(clean))
4126
- continue;
4127
- seenGlobal.add(clean);
4128
- if (countInPage >= MAX_LINKS_PER_PAGE)
4129
- break;
4130
- if (queue.length >= MAX_TOTAL_LINKS)
4131
- break outer;
4132
- const isInternal = !!siteUrl && clean.startsWith(siteUrl);
4133
- queue.push({ docId: doc.id, pageTitle, clean, isInternal });
4134
- countInPage++;
4135
- }
4367
+ return json({ data: redirectToRule(updated) });
4368
+ }
4369
+ catch (err) {
4370
+ return internalError(err, 'seo/redirects/flatten');
4371
+ }
4372
+ });
4373
+ router.get('/seo/redirects/suggestions', async (request) => {
4374
+ try {
4375
+ const auth = await requireAuth(request);
4376
+ if (auth.error)
4377
+ return auth.error;
4378
+ if (!hasModel(db(), 'redirectSuggestion'))
4379
+ return json({ data: { suggestions: [] } });
4380
+ const rows = await db().redirectSuggestion.findMany({
4381
+ where: { status: 'open' },
4382
+ orderBy: { confidence: 'desc' },
4383
+ });
4384
+ let hitsByPath = new Map();
4385
+ if (hasModel(db(), 'redirect404Hit')) {
4386
+ const hits = await db().redirect404Hit.findMany({});
4387
+ hitsByPath = new Map(hits.map((h) => [h.path, Number(h.hits ?? 0)]));
4136
4388
  }
4137
- const linkResults = [];
4138
- let cursor = 0;
4139
- const worker = async () => {
4140
- while (cursor < queue.length) {
4141
- const idx = cursor++;
4142
- const job = queue[idx];
4143
- let status = 0;
4144
- try {
4145
- // safeFetch rejects private/loopback IPs and disables redirect
4146
- // following so 302->internal can't smuggle the scanner past SSRF.
4147
- const resp = await safeFetch(job.clean, {
4148
- method: 'HEAD',
4149
- timeoutMs: PER_LINK_TIMEOUT_MS,
4150
- });
4151
- status = resp.status;
4152
- // Drain the body so the connection can be reused.
4153
- try {
4154
- await resp.body?.cancel();
4155
- }
4156
- catch {
4157
- /* noop */
4158
- }
4159
- }
4160
- catch (err) {
4161
- status = err instanceof SsrfBlockedError ? -1 : 0;
4162
- }
4163
- if (status === -1 || status === 0 || status >= 300) {
4164
- linkResults.push({
4165
- id: `${job.docId}-${idx}`,
4166
- page: job.pageTitle,
4167
- url: job.clean,
4168
- status: status === -1 ? 0 : status,
4169
- type: job.isInternal ? 'internal' : 'external',
4170
- });
4171
- }
4172
- }
4173
- };
4174
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
4175
4389
  return json({
4176
4390
  data: {
4177
- truncated: queue.length >= MAX_TOTAL_LINKS,
4178
- checked: queue.length,
4179
- issues: linkResults,
4391
+ suggestions: rows.map((s) => ({
4392
+ id: s.id,
4393
+ fromPath: s.fromPath,
4394
+ toPath: s.toPath,
4395
+ // Stored as a 0-100 Int; the UI contract is a 0-1 fraction.
4396
+ confidence: Number(s.confidence ?? 0) / 100,
4397
+ reason: s.reason ?? null,
4398
+ status: s.status,
4399
+ hits: hitsByPath.get(s.fromPath) ?? 0,
4400
+ trend: null,
4401
+ })),
4180
4402
  },
4181
4403
  });
4182
4404
  }
4183
4405
  catch (err) {
4184
- return internalError(err);
4406
+ return internalError(err, 'seo/redirects/suggestions');
4185
4407
  }
4186
4408
  });
4187
- router.post('/seo/scan', async (request) => {
4409
+ router.post('/seo/redirects/suggest', async (request) => {
4188
4410
  try {
4189
4411
  const auth = await requireAuth(request);
4190
4412
  if (auth.error)
@@ -4192,75 +4414,1144 @@ export function registerCMSRoutes(router) {
4192
4414
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4193
4415
  if (roleErr)
4194
4416
  return roleErr;
4195
- const documents = await db().document.findMany({
4196
- where: { status: 'PUBLISHED', deletedAt: null },
4197
- select: {
4198
- id: true,
4199
- title: true,
4200
- slug: true,
4201
- collection: true,
4202
- data: true,
4203
- plainText: true,
4204
- },
4205
- });
4206
- const issues = [];
4207
- for (const doc of documents) {
4208
- const data = (doc.data ?? {});
4209
- const problems = [];
4210
- if (!data.metaTitle && !data.seoTitle)
4211
- problems.push('Missing meta title');
4212
- if (!data.metaDescription && !data.seoDescription)
4213
- problems.push('Missing meta description');
4214
- if (!data.canonical)
4215
- problems.push('No canonical URL set');
4216
- if (!data.schemaType)
4217
- problems.push('No Schema.org type');
4218
- const plainText = (doc.plainText ?? '');
4219
- if (plainText.length > 0 && plainText.length < 300)
4220
- problems.push('Content is too short (< 300 characters)');
4221
- const content = typeof data.body === 'string'
4222
- ? data.body
4223
- : typeof data.content === 'string'
4224
- ? data.content
4225
- : '';
4226
- if (content) {
4227
- const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
4228
- const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
4229
- if (missingAlt > 0)
4230
- problems.push(`${missingAlt} image(s) missing alt text`);
4231
- if (!content.includes('<h1') && !content.includes('<h1>'))
4232
- problems.push('No H1 heading found in content');
4233
- }
4234
- if (problems.length > 0) {
4235
- issues.push({
4236
- documentId: doc.id,
4237
- title: doc.title ?? 'Untitled',
4238
- slug: doc.slug ?? '',
4239
- problems,
4240
- });
4241
- }
4417
+ if (!hasModel(db(), 'redirectSuggestion') || !hasModel(db(), 'redirect404Hit')) {
4418
+ return modelNotAvailable('RedirectSuggestion');
4419
+ }
4420
+ const { suggestRedirectTarget } = await import('../redirects/suggest.js');
4421
+ // Candidate set: published page/post URLs.
4422
+ const { gatherAuditEntities } = await import('../seo/audit-runner.js');
4423
+ const entities = await gatherAuditEntities(db());
4424
+ const candidates = entities.map((e) => ({ url: e.url, title: e.title }));
4425
+ const unresolved = await db().redirect404Hit.findMany({ where: { resolvedAt: null } });
4426
+ let created = 0;
4427
+ for (const hit of unresolved) {
4428
+ const existing = await db().redirectSuggestion.findFirst({
4429
+ where: { fromPath: hit.path, status: 'open' },
4430
+ });
4431
+ if (existing)
4432
+ continue;
4433
+ const suggestion = suggestRedirectTarget(hit.path, candidates);
4434
+ if (!suggestion)
4435
+ continue;
4436
+ await db().redirectSuggestion.create({
4437
+ data: {
4438
+ fromPath: hit.path,
4439
+ toPath: suggestion.toPath,
4440
+ // DB column is an Int — store confidence as a 0-100 percentage.
4441
+ // The GET endpoint converts back to the 0-1 fraction the UI expects.
4442
+ confidence: Math.round(suggestion.confidence * 100),
4443
+ reason: suggestion.reason,
4444
+ status: 'open',
4445
+ source: 'ai-404-recovery',
4446
+ },
4447
+ });
4448
+ created++;
4242
4449
  }
4243
- const total = documents.length;
4244
- const pagesWithIssues = issues.length;
4245
- const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
4246
- return json({ data: { total, pagesWithIssues, totalProblems, issues } });
4450
+ return json({ data: { created } });
4247
4451
  }
4248
4452
  catch (err) {
4249
- return internalError(err);
4453
+ return internalError(err, 'seo/redirects/suggest');
4250
4454
  }
4251
4455
  });
4252
- // ---------------------------------------------------------------------------
4253
- // SEO analysis, readability, internal links, schema, meta
4254
- // ---------------------------------------------------------------------------
4255
- router.get('/seo/analysis/:documentId', async (request, params) => {
4456
+ router.post('/seo/redirects/suggestions/:id/accept', async (request, params) => {
4256
4457
  try {
4257
4458
  const auth = await requireAuth(request);
4258
4459
  if (auth.error)
4259
4460
  return auth.error;
4260
- const doc = await db().document.findFirst({
4261
- where: { id: params.documentId, deletedAt: null },
4461
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4462
+ if (roleErr)
4463
+ return roleErr;
4464
+ if (!hasModel(db(), 'redirectSuggestion'))
4465
+ return modelNotAvailable('RedirectSuggestion');
4466
+ const suggestion = await db().redirectSuggestion.findUnique({ where: { id: params.id } });
4467
+ if (!suggestion)
4468
+ return errorResponse('Suggestion not found', 404);
4469
+ const graph = await ensureRedirectGraph();
4470
+ const rows = await db().redirect.findMany({});
4471
+ if (graph.wouldCreateLoop(rows.map((r) => ({
4472
+ id: r.id,
4473
+ from: r.source,
4474
+ to: r.destination,
4475
+ enabled: r.enabled,
4476
+ })), suggestion.fromPath, suggestion.toPath)) {
4477
+ return errorResponse('Accepting this suggestion would create a redirect loop.', 400);
4478
+ }
4479
+ await db().redirect.create({
4480
+ data: {
4481
+ source: suggestion.fromPath,
4482
+ destination: suggestion.toPath,
4483
+ statusCode: 301,
4484
+ enabled: true,
4485
+ source_kind: 'ai-404-recovery',
4486
+ createdById: auth.session.userId,
4487
+ },
4262
4488
  });
4263
- if (!doc)
4489
+ await db().redirectSuggestion.update({
4490
+ where: { id: suggestion.id },
4491
+ data: { status: 'accepted' },
4492
+ });
4493
+ // Mark the 404 resolved.
4494
+ if (hasModel(db(), 'redirect404Hit')) {
4495
+ await db()
4496
+ .redirect404Hit.updateMany({
4497
+ where: { path: suggestion.fromPath },
4498
+ data: { resolvedAt: new Date() },
4499
+ })
4500
+ .catch(() => { });
4501
+ }
4502
+ return json({ data: { success: true } });
4503
+ }
4504
+ catch (err) {
4505
+ return internalError(err, 'seo/redirects/suggestions/accept');
4506
+ }
4507
+ });
4508
+ router.post('/seo/redirects/suggestions/:id/dismiss', async (request, params) => {
4509
+ try {
4510
+ const auth = await requireAuth(request);
4511
+ if (auth.error)
4512
+ return auth.error;
4513
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4514
+ if (roleErr)
4515
+ return roleErr;
4516
+ if (!hasModel(db(), 'redirectSuggestion'))
4517
+ return modelNotAvailable('RedirectSuggestion');
4518
+ await db().redirectSuggestion.update({
4519
+ where: { id: params.id },
4520
+ data: { status: 'dismissed' },
4521
+ });
4522
+ return json({ data: { success: true } });
4523
+ }
4524
+ catch (err) {
4525
+ return internalError(err, 'seo/redirects/suggestions/dismiss');
4526
+ }
4527
+ });
4528
+ router.get('/seo/redirects/export', async (request) => {
4529
+ try {
4530
+ const auth = await requireAuth(request);
4531
+ if (auth.error)
4532
+ return auth.error;
4533
+ const rows = await db().redirect.findMany({ orderBy: { createdAt: 'desc' } });
4534
+ const header = 'from,to,type,enabled';
4535
+ const lines = rows.map((r) => {
4536
+ const esc = (v) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
4537
+ return [
4538
+ esc(String(r.source ?? '')),
4539
+ esc(String(r.destination ?? '')),
4540
+ String(r.statusCode ?? 301),
4541
+ r.enabled === false ? 'false' : 'true',
4542
+ ].join(',');
4543
+ });
4544
+ return json({ data: { csv: [header, ...lines].join('\n') } });
4545
+ }
4546
+ catch (err) {
4547
+ return internalError(err, 'seo/redirects/export');
4548
+ }
4549
+ });
4550
+ router.post('/seo/redirects/import', async (request) => {
4551
+ try {
4552
+ const auth = await requireAuth(request);
4553
+ if (auth.error)
4554
+ return auth.error;
4555
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4556
+ if (roleErr)
4557
+ return roleErr;
4558
+ const graph = await ensureRedirectGraph();
4559
+ const body = (await request.json().catch(() => ({})));
4560
+ const csv = String(body.csv ?? '').trim();
4561
+ if (!csv)
4562
+ return errorResponse('CSV body is required', 400);
4563
+ const lines = csv.split(/\r?\n/).filter((l) => l.trim().length > 0);
4564
+ // Skip an optional header row.
4565
+ if (lines[0] && /from\s*,\s*to/i.test(lines[0]))
4566
+ lines.shift();
4567
+ const errors = [];
4568
+ let imported = 0;
4569
+ const existing = await db().redirect.findMany({});
4570
+ const working = existing.map((r) => ({
4571
+ id: r.id,
4572
+ from: r.source,
4573
+ to: r.destination,
4574
+ enabled: r.enabled,
4575
+ }));
4576
+ for (let idx = 0; idx < lines.length; idx++) {
4577
+ const cols = parseCsvLine(lines[idx]);
4578
+ const from = (cols[0] ?? '').trim();
4579
+ const to = (cols[1] ?? '').trim();
4580
+ const type = Number(cols[2] ?? 301);
4581
+ if (!from || !to) {
4582
+ errors.push(`Row ${idx + 1}: missing from/to`);
4583
+ continue;
4584
+ }
4585
+ const destErr = validateRedirectDestination(to, type);
4586
+ if (destErr) {
4587
+ errors.push(`Row ${idx + 1}: ${destErr}`);
4588
+ continue;
4589
+ }
4590
+ if (graph.wouldCreateLoop(working, from, to)) {
4591
+ errors.push(`Row ${idx + 1}: would create a redirect loop`);
4592
+ continue;
4593
+ }
4594
+ try {
4595
+ await db().redirect.upsert({
4596
+ where: { source: from },
4597
+ update: {
4598
+ destination: to,
4599
+ statusCode: REDIRECT_STATUS_CODES.includes(type) ? type : 301,
4600
+ },
4601
+ create: {
4602
+ source: from,
4603
+ destination: to,
4604
+ statusCode: REDIRECT_STATUS_CODES.includes(type) ? type : 301,
4605
+ source_kind: 'import',
4606
+ createdById: auth.session.userId,
4607
+ },
4608
+ });
4609
+ working.push({ id: `imp-${idx}`, from, to, enabled: true });
4610
+ imported++;
4611
+ }
4612
+ catch (e) {
4613
+ errors.push(`Row ${idx + 1}: ${e instanceof Error ? e.message : 'insert failed'}`);
4614
+ }
4615
+ }
4616
+ return json({ data: { imported, errors } });
4617
+ }
4618
+ catch (err) {
4619
+ return internalError(err, 'seo/redirects/import');
4620
+ }
4621
+ });
4622
+ router.post('/seo/redirects', async (request) => {
4623
+ try {
4624
+ const auth = await requireAuth(request);
4625
+ if (auth.error)
4626
+ return auth.error;
4627
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4628
+ if (roleErr)
4629
+ return roleErr;
4630
+ const graph = await ensureRedirectGraph();
4631
+ const body = (await request.json().catch(() => ({})));
4632
+ const source = String(body.fromPath ?? body.source ?? '').trim();
4633
+ const destination = String(body.toPath ?? body.destination ?? '').trim();
4634
+ const statusCode = Number(body.type ?? body.statusCode ?? 301);
4635
+ if (!source)
4636
+ return errorResponse('fromPath is required', 400);
4637
+ if (!destination && statusCode !== 410)
4638
+ return errorResponse('toPath is required', 400);
4639
+ if (!REDIRECT_STATUS_CODES.includes(statusCode)) {
4640
+ return errorResponse('type must be one of 301, 302, 307, 308, 410', 400);
4641
+ }
4642
+ const destErr = validateRedirectDestination(destination, statusCode);
4643
+ if (destErr)
4644
+ return errorResponse(destErr, 400);
4645
+ const rows = await db().redirect.findMany({});
4646
+ if (rows.some((r) => graph.normalizeRedirectPath(r.source) === graph.normalizeRedirectPath(source))) {
4647
+ return errorResponse('A redirect with this source already exists.', 409);
4648
+ }
4649
+ if (graph.wouldCreateLoop(rows.map((r) => ({
4650
+ id: r.id,
4651
+ from: r.source,
4652
+ to: r.destination,
4653
+ enabled: r.enabled,
4654
+ })), source, destination || source)) {
4655
+ return errorResponse('This redirect would create a loop.', 400);
4656
+ }
4657
+ const created = await db().redirect.create({
4658
+ data: {
4659
+ source,
4660
+ destination: destination || source,
4661
+ statusCode,
4662
+ enabled: body.enabled !== false,
4663
+ preserveQuery: body.preserveQuery === true,
4664
+ notes: typeof body.notes === 'string' ? body.notes : null,
4665
+ source_kind: 'manual',
4666
+ createdById: auth.session.userId,
4667
+ },
4668
+ });
4669
+ return json({ data: redirectToRule(created) }, 201);
4670
+ }
4671
+ catch (err) {
4672
+ return internalError(err, 'seo/redirects POST');
4673
+ }
4674
+ });
4675
+ router.get('/seo/redirects/:id', async (request, params) => {
4676
+ try {
4677
+ const auth = await requireAuth(request);
4678
+ if (auth.error)
4679
+ return auth.error;
4680
+ await ensureRedirectGraph();
4681
+ const row = await db().redirect.findUnique({ where: { id: params.id } });
4682
+ if (!row)
4683
+ return errorResponse('Redirect not found', 404);
4684
+ return json({ data: redirectToRule(row) });
4685
+ }
4686
+ catch (err) {
4687
+ return internalError(err, 'seo/redirects/:id GET');
4688
+ }
4689
+ });
4690
+ router.put('/seo/redirects/:id', async (request, params) => {
4691
+ try {
4692
+ const auth = await requireAuth(request);
4693
+ if (auth.error)
4694
+ return auth.error;
4695
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4696
+ if (roleErr)
4697
+ return roleErr;
4698
+ const graph = await ensureRedirectGraph();
4699
+ const existing = await db().redirect.findUnique({ where: { id: params.id } });
4700
+ if (!existing)
4701
+ return errorResponse('Redirect not found', 404);
4702
+ const body = (await request.json().catch(() => ({})));
4703
+ const source = body.fromPath !== undefined || body.source !== undefined
4704
+ ? String(body.fromPath ?? body.source ?? '').trim()
4705
+ : existing.source;
4706
+ const destination = body.toPath !== undefined || body.destination !== undefined
4707
+ ? String(body.toPath ?? body.destination ?? '').trim()
4708
+ : existing.destination;
4709
+ const statusCode = body.type !== undefined || body.statusCode !== undefined
4710
+ ? Number(body.type ?? body.statusCode)
4711
+ : existing.statusCode;
4712
+ if (!source)
4713
+ return errorResponse('fromPath is required', 400);
4714
+ if (!REDIRECT_STATUS_CODES.includes(statusCode)) {
4715
+ return errorResponse('type must be one of 301, 302, 307, 308, 410', 400);
4716
+ }
4717
+ const destErr = validateRedirectDestination(destination, statusCode);
4718
+ if (destErr)
4719
+ return errorResponse(destErr, 400);
4720
+ const rows = await db().redirect.findMany({});
4721
+ if (graph.wouldCreateLoop(rows.map((r) => ({
4722
+ id: r.id,
4723
+ from: r.source,
4724
+ to: r.destination,
4725
+ enabled: r.enabled,
4726
+ })), source, destination || source, existing.id)) {
4727
+ return errorResponse('This change would create a redirect loop.', 400);
4728
+ }
4729
+ const updated = await db().redirect.update({
4730
+ where: { id: existing.id },
4731
+ data: {
4732
+ source,
4733
+ destination: destination || source,
4734
+ statusCode,
4735
+ ...(body.enabled !== undefined ? { enabled: body.enabled === true } : {}),
4736
+ ...(body.preserveQuery !== undefined
4737
+ ? { preserveQuery: body.preserveQuery === true }
4738
+ : {}),
4739
+ ...(body.notes !== undefined
4740
+ ? { notes: typeof body.notes === 'string' ? body.notes : null }
4741
+ : {}),
4742
+ },
4743
+ });
4744
+ return json({ data: redirectToRule(updated) });
4745
+ }
4746
+ catch (err) {
4747
+ return internalError(err, 'seo/redirects/:id PUT');
4748
+ }
4749
+ });
4750
+ router.delete('/seo/redirects/:id', async (request, params) => {
4751
+ try {
4752
+ const auth = await requireAuth(request);
4753
+ if (auth.error)
4754
+ return auth.error;
4755
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4756
+ if (roleErr)
4757
+ return roleErr;
4758
+ await db().redirect.delete({ where: { id: params.id } });
4759
+ return json({ data: { success: true } });
4760
+ }
4761
+ catch (err) {
4762
+ return internalError(err, 'seo/redirects/:id DELETE');
4763
+ }
4764
+ });
4765
+ // Public: resolve a path against enabled redirects. Used by the redirect
4766
+ // middleware. Increments hit counters on a match; logs a 404 otherwise.
4767
+ router.get('/redirects/resolve', async (request) => {
4768
+ try {
4769
+ const graph = await ensureRedirectGraph();
4770
+ const url = new URL(request.url);
4771
+ const path = url.searchParams.get('path') ?? '';
4772
+ if (!path)
4773
+ return json({ data: { match: null } });
4774
+ const norm = graph.normalizeRedirectPath(path);
4775
+ const rows = await db().redirect.findMany({
4776
+ where: { enabled: true },
4777
+ orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
4778
+ });
4779
+ const match = rows.find((r) => graph.normalizeRedirectPath(r.source) === norm);
4780
+ if (!match) {
4781
+ return json({ data: { match: null } });
4782
+ }
4783
+ db()
4784
+ .redirect.update({
4785
+ where: { id: match.id },
4786
+ data: { hits: { increment: 1 }, lastHitAt: new Date() },
4787
+ })
4788
+ .catch(() => { });
4789
+ return json({
4790
+ data: {
4791
+ match: {
4792
+ to: match.destination,
4793
+ statusCode: Number(match.statusCode ?? 301),
4794
+ preserveQuery: match.preserveQuery ?? false,
4795
+ },
4796
+ },
4797
+ });
4798
+ }
4799
+ catch (err) {
4800
+ return internalError(err, 'redirects/resolve');
4801
+ }
4802
+ });
4803
+ // Public: enabled redirects in the plugin-redirects middleware shape. Backs
4804
+ // `createRedirectMiddleware`'s `getRedirects` (cached by the factory).
4805
+ router.get('/redirects/public', async () => {
4806
+ try {
4807
+ const rows = await db().redirect.findMany({
4808
+ where: { enabled: true },
4809
+ orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
4810
+ select: {
4811
+ id: true,
4812
+ source: true,
4813
+ destination: true,
4814
+ statusCode: true,
4815
+ preserveQuery: true,
4816
+ },
4817
+ });
4818
+ return json({
4819
+ data: {
4820
+ redirects: rows.map((r) => ({
4821
+ id: r.id,
4822
+ fromUrl: r.source,
4823
+ toUrl: r.destination,
4824
+ type: String([301, 302].includes(Number(r.statusCode)) ? r.statusCode : 301),
4825
+ statusCode: Number(r.statusCode ?? 301),
4826
+ preserveQuery: r.preserveQuery ?? false,
4827
+ isActive: true,
4828
+ })),
4829
+ },
4830
+ });
4831
+ }
4832
+ catch (err) {
4833
+ return internalError(err, 'redirects/public');
4834
+ }
4835
+ });
4836
+ // Public: increment hit counters for a matched source path. Fire-and-forget
4837
+ // from the middleware so request latency is unaffected.
4838
+ router.post('/redirects/record-hit', async (request) => {
4839
+ try {
4840
+ const graph = await ensureRedirectGraph();
4841
+ const body = (await request.json().catch(() => ({})));
4842
+ const norm = graph.normalizeRedirectPath(String(body.path ?? ''));
4843
+ if (!norm)
4844
+ return json({ data: { ok: false } });
4845
+ const rows = await db().redirect.findMany({
4846
+ where: { enabled: true },
4847
+ select: { id: true, source: true },
4848
+ });
4849
+ const match = rows.find((r) => graph.normalizeRedirectPath(r.source) === norm);
4850
+ if (!match)
4851
+ return json({ data: { ok: false } });
4852
+ await db()
4853
+ .redirect.update({
4854
+ where: { id: match.id },
4855
+ data: { hits: { increment: 1 }, lastHitAt: new Date() },
4856
+ })
4857
+ .catch(() => { });
4858
+ return json({ data: { ok: true } });
4859
+ }
4860
+ catch (err) {
4861
+ return internalError(err, 'redirects/record-hit');
4862
+ }
4863
+ });
4864
+ // Public: record a 404 for a path (called by the site when it renders
4865
+ // notFound). Aggregates per-path so the Redirects tab can surface dead URLs.
4866
+ router.post('/redirects/record-404', async (request) => {
4867
+ try {
4868
+ if (!hasModel(db(), 'redirect404Hit'))
4869
+ return json({ data: { ok: false } });
4870
+ const graph = await ensureRedirectGraph();
4871
+ const body = (await request.json().catch(() => ({})));
4872
+ const path = graph.normalizeRedirectPath(String(body.path ?? ''));
4873
+ if (!path || path === '/')
4874
+ return json({ data: { ok: false } });
4875
+ await db().redirect404Hit.upsert({
4876
+ where: { path },
4877
+ update: { hits: { increment: 1 }, lastSeenAt: new Date() },
4878
+ create: { path, hits: 1, referrer: body.referrer ?? null },
4879
+ });
4880
+ return json({ data: { ok: true } });
4881
+ }
4882
+ catch (err) {
4883
+ return internalError(err, 'redirects/record-404');
4884
+ }
4885
+ });
4886
+ // ---------------------------------------------------------------------------
4887
+ // SEO routes
4888
+ // ---------------------------------------------------------------------------
4889
+ router.get('/seo/pages', async (request) => {
4890
+ try {
4891
+ const auth = await requireAuth(request);
4892
+ if (auth.error)
4893
+ return auth.error;
4894
+ const pages = await db().document.findMany({
4895
+ where: { deletedAt: null, status: 'PUBLISHED' },
4896
+ select: {
4897
+ id: true,
4898
+ collection: true,
4899
+ data: true,
4900
+ updatedAt: true,
4901
+ structuredData: true,
4902
+ },
4903
+ orderBy: { updatedAt: 'desc' },
4904
+ });
4905
+ return json({ data: pages.map(normalizeSeoPage) });
4906
+ }
4907
+ catch (err) {
4908
+ return internalError(err);
4909
+ }
4910
+ });
4911
+ router.get('/seo/link-health', async (request) => {
4912
+ try {
4913
+ const auth = await requireAuth(request);
4914
+ if (auth.error)
4915
+ return auth.error;
4916
+ // EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
4917
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
4918
+ if (roleErr)
4919
+ return roleErr;
4920
+ // Tight rate limit because each call fans out to many outbound requests.
4921
+ const ip = clientIp(request);
4922
+ const rateKey = isResolvedIp(ip)
4923
+ ? `link-health:${ip}`
4924
+ : `link-health-user:${auth.session.userId}`;
4925
+ if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
4926
+ return errorResponse('Too many link-health scans. Please try again later.', 429);
4927
+ }
4928
+ const MAX_LINKS_PER_PAGE = 50;
4929
+ const MAX_TOTAL_LINKS = 500;
4930
+ const PER_LINK_TIMEOUT_MS = 4000;
4931
+ const CONCURRENCY = 8;
4932
+ const docs = await db().document.findMany({
4933
+ where: { deletedAt: null, status: 'PUBLISHED' },
4934
+ select: { id: true, title: true, data: true, collection: true },
4935
+ });
4936
+ const urlRegex = /https?:\/\/[^\s"'<>]+/g;
4937
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
4938
+ const queue = [];
4939
+ const seenGlobal = new Set();
4940
+ outer: for (const doc of docs) {
4941
+ const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
4942
+ const content = JSON.stringify(doc.data ?? {});
4943
+ const urls = content.match(urlRegex) ?? [];
4944
+ const seenInPage = new Set();
4945
+ let countInPage = 0;
4946
+ for (const url of urls) {
4947
+ const clean = url.replace(/[",;)}\]]+$/, '');
4948
+ if (seenInPage.has(clean))
4949
+ continue;
4950
+ seenInPage.add(clean);
4951
+ if (seenGlobal.has(clean))
4952
+ continue;
4953
+ seenGlobal.add(clean);
4954
+ if (countInPage >= MAX_LINKS_PER_PAGE)
4955
+ break;
4956
+ if (queue.length >= MAX_TOTAL_LINKS)
4957
+ break outer;
4958
+ const isInternal = !!siteUrl && clean.startsWith(siteUrl);
4959
+ queue.push({ docId: doc.id, pageTitle, clean, isInternal });
4960
+ countInPage++;
4961
+ }
4962
+ }
4963
+ const linkResults = [];
4964
+ let cursor = 0;
4965
+ const worker = async () => {
4966
+ while (cursor < queue.length) {
4967
+ const idx = cursor++;
4968
+ const job = queue[idx];
4969
+ let status = 0;
4970
+ try {
4971
+ // safeFetch rejects private/loopback IPs and disables redirect
4972
+ // following so 302->internal can't smuggle the scanner past SSRF.
4973
+ const resp = await safeFetch(job.clean, {
4974
+ method: 'HEAD',
4975
+ timeoutMs: PER_LINK_TIMEOUT_MS,
4976
+ });
4977
+ status = resp.status;
4978
+ // Drain the body so the connection can be reused.
4979
+ try {
4980
+ await resp.body?.cancel();
4981
+ }
4982
+ catch {
4983
+ /* noop */
4984
+ }
4985
+ }
4986
+ catch (err) {
4987
+ status = err instanceof SsrfBlockedError ? -1 : 0;
4988
+ }
4989
+ if (status === -1 || status === 0 || status >= 300) {
4990
+ linkResults.push({
4991
+ id: `${job.docId}-${idx}`,
4992
+ page: job.pageTitle,
4993
+ url: job.clean,
4994
+ status: status === -1 ? 0 : status,
4995
+ type: job.isInternal ? 'internal' : 'external',
4996
+ });
4997
+ }
4998
+ }
4999
+ };
5000
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
5001
+ return json({
5002
+ data: {
5003
+ truncated: queue.length >= MAX_TOTAL_LINKS,
5004
+ checked: queue.length,
5005
+ issues: linkResults,
5006
+ },
5007
+ });
5008
+ }
5009
+ catch (err) {
5010
+ return internalError(err);
5011
+ }
5012
+ });
5013
+ router.post('/seo/scan', async (request) => {
5014
+ try {
5015
+ const auth = await requireAuth(request);
5016
+ if (auth.error)
5017
+ return auth.error;
5018
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
5019
+ if (roleErr)
5020
+ return roleErr;
5021
+ const documents = await db().document.findMany({
5022
+ where: { status: 'PUBLISHED', deletedAt: null },
5023
+ select: {
5024
+ id: true,
5025
+ title: true,
5026
+ slug: true,
5027
+ collection: true,
5028
+ data: true,
5029
+ plainText: true,
5030
+ },
5031
+ });
5032
+ const issues = [];
5033
+ for (const doc of documents) {
5034
+ const data = (doc.data ?? {});
5035
+ const problems = [];
5036
+ if (!data.metaTitle && !data.seoTitle)
5037
+ problems.push('Missing meta title');
5038
+ if (!data.metaDescription && !data.seoDescription)
5039
+ problems.push('Missing meta description');
5040
+ if (!data.canonical)
5041
+ problems.push('No canonical URL set');
5042
+ if (!data.schemaType)
5043
+ problems.push('No Schema.org type');
5044
+ const plainText = (doc.plainText ?? '');
5045
+ if (plainText.length > 0 && plainText.length < 300)
5046
+ problems.push('Content is too short (< 300 characters)');
5047
+ const content = typeof data.body === 'string'
5048
+ ? data.body
5049
+ : typeof data.content === 'string'
5050
+ ? data.content
5051
+ : '';
5052
+ if (content) {
5053
+ const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
5054
+ const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
5055
+ if (missingAlt > 0)
5056
+ problems.push(`${missingAlt} image(s) missing alt text`);
5057
+ if (!content.includes('<h1') && !content.includes('<h1>'))
5058
+ problems.push('No H1 heading found in content');
5059
+ }
5060
+ if (problems.length > 0) {
5061
+ issues.push({
5062
+ documentId: doc.id,
5063
+ title: doc.title ?? 'Untitled',
5064
+ slug: doc.slug ?? '',
5065
+ problems,
5066
+ });
5067
+ }
5068
+ }
5069
+ const total = documents.length;
5070
+ const pagesWithIssues = issues.length;
5071
+ const totalProblems = issues.reduce((sum, i) => sum + i.problems.length, 0);
5072
+ return json({ data: { total, pagesWithIssues, totalProblems, issues } });
5073
+ }
5074
+ catch (err) {
5075
+ return internalError(err);
5076
+ }
5077
+ });
5078
+ // ---------------------------------------------------------------------------
5079
+ // SEO Operations Center — audit engine, issues, overview, content records
5080
+ // ---------------------------------------------------------------------------
5081
+ function seoIssueToApi(i) {
5082
+ return {
5083
+ id: i.id,
5084
+ auditRunId: i.auditRunId ?? null,
5085
+ entityType: i.entityType ?? null,
5086
+ entityId: i.entityId ?? null,
5087
+ entityTitle: i.entityTitle ?? null,
5088
+ url: i.url ?? null,
5089
+ severity: i.severity,
5090
+ category: i.category,
5091
+ type: i.type,
5092
+ title: i.title,
5093
+ description: i.description ?? null,
5094
+ impact: i.impact ?? null,
5095
+ recommendation: i.recommendation ?? null,
5096
+ status: i.status,
5097
+ fixable: i.fixable ?? false,
5098
+ fixActionType: i.fixActionType ?? null,
5099
+ relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
5100
+ source: i.source ?? 'audit',
5101
+ firstDetectedAt: i.firstDetectedAt ?? null,
5102
+ lastDetectedAt: i.lastDetectedAt ?? null,
5103
+ resolvedAt: i.resolvedAt ?? null,
5104
+ };
5105
+ }
5106
+ async function buildSeoContentRecords() {
5107
+ const { gatherAuditEntities } = await import('../seo/audit-runner.js');
5108
+ const { calculatePageSeoScore } = await import('../seo/score.js');
5109
+ const entities = await gatherAuditEntities(db());
5110
+ return entities.map((e) => {
5111
+ const score = calculatePageSeoScore(e);
5112
+ return {
5113
+ id: `${e.entityType}:${e.entityId}`,
5114
+ entityType: e.entityType,
5115
+ entityId: e.entityId,
5116
+ title: e.title,
5117
+ url: e.url,
5118
+ canonicalUrl: e.canonicalUrl ?? null,
5119
+ metaTitle: e.metaTitle ?? null,
5120
+ metaDescription: e.metaDescription ?? null,
5121
+ focusKeyword: e.focusKeyword ?? null,
5122
+ ogTitle: e.ogTitle ?? null,
5123
+ ogDescription: e.ogDescription ?? null,
5124
+ ogImageId: null,
5125
+ ogImage: e.ogImage ?? null,
5126
+ twitterTitle: null,
5127
+ twitterDescription: null,
5128
+ twitterImageId: null,
5129
+ noindex: e.noindex ?? false,
5130
+ nofollow: false,
5131
+ structuredDataType: e.structuredDataType ?? null,
5132
+ structuredData: null,
5133
+ seoScore: score.score,
5134
+ seoGrade: score.grade,
5135
+ updatedAt: null,
5136
+ };
5137
+ });
5138
+ }
5139
+ // Demo/seed search-performance rows live in a hidden collection so they can
5140
+ // be inserted by the dev seed script and never ship to npm. In production
5141
+ // with a connected Search Console these would come from the GSC API.
5142
+ const SEO_PERF_COLLECTION = '__seo_perf__';
5143
+ async function loadSearchPerformance(limit = 10) {
5144
+ try {
5145
+ const docs = await db().document.findMany({
5146
+ where: { collection: SEO_PERF_COLLECTION, deletedAt: null },
5147
+ select: { data: true },
5148
+ });
5149
+ const rows = docs
5150
+ .map((d) => {
5151
+ const data = asRecord(d.data);
5152
+ return {
5153
+ url: String(data.url ?? ''),
5154
+ title: String(data.title ?? ''),
5155
+ impressions: Number(data.impressions ?? 0),
5156
+ clicks: Number(data.clicks ?? 0),
5157
+ ctr: Number(data.ctr ?? 0),
5158
+ averagePosition: Number(data.averagePosition ?? 0),
5159
+ };
5160
+ })
5161
+ .filter((r) => r.url);
5162
+ rows.sort((a, b) => b.impressions - a.impressions);
5163
+ return rows.slice(0, limit);
5164
+ }
5165
+ catch {
5166
+ return [];
5167
+ }
5168
+ }
5169
+ router.post('/seo/audit', async (request) => {
5170
+ try {
5171
+ const auth = await requireAuth(request);
5172
+ if (auth.error)
5173
+ return auth.error;
5174
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
5175
+ if (roleErr)
5176
+ return roleErr;
5177
+ if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
5178
+ return modelNotAvailable('SeoAuditRun');
5179
+ }
5180
+ const ip = clientIp(request);
5181
+ const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
5182
+ if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
5183
+ return errorResponse('Too many audits. Please try again later.', 429);
5184
+ }
5185
+ const body = (await request.json().catch(() => ({})));
5186
+ const scope = body?.scope ?? 'full';
5187
+ const { runAndPersistAudit } = await import('../seo/audit-runner.js');
5188
+ const run = await runAndPersistAudit(db(), { scope, startedById: auth.session.userId });
5189
+ try {
5190
+ await logEvent({
5191
+ event: 'settings_changed',
5192
+ userId: auth.session.userId,
5193
+ details: { action: 'seo_audit', auditRunId: run.id, scope },
5194
+ });
5195
+ }
5196
+ catch {
5197
+ /* audit-log failures never block */
5198
+ }
5199
+ return json({ data: run });
5200
+ }
5201
+ catch (err) {
5202
+ return internalError(err, 'seo/audit');
5203
+ }
5204
+ });
5205
+ router.get('/seo/audits', async (request) => {
5206
+ try {
5207
+ const auth = await requireAuth(request);
5208
+ if (auth.error)
5209
+ return auth.error;
5210
+ if (!hasModel(db(), 'seoAuditRun'))
5211
+ return json({ data: { runs: [] } });
5212
+ const runs = await db().seoAuditRun.findMany({
5213
+ orderBy: { startedAt: 'desc' },
5214
+ take: 50,
5215
+ });
5216
+ return json({ data: { runs } });
5217
+ }
5218
+ catch (err) {
5219
+ return internalError(err, 'seo/audits');
5220
+ }
5221
+ });
5222
+ router.get('/seo/audits/:id', async (request, params) => {
5223
+ try {
5224
+ const auth = await requireAuth(request);
5225
+ if (auth.error)
5226
+ return auth.error;
5227
+ if (!hasModel(db(), 'seoAuditRun'))
5228
+ return modelNotAvailable('SeoAuditRun');
5229
+ const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
5230
+ if (!run)
5231
+ return errorResponse('Audit run not found', 404);
5232
+ return json({ data: run });
5233
+ }
5234
+ catch (err) {
5235
+ return internalError(err, 'seo/audits/:id');
5236
+ }
5237
+ });
5238
+ router.get('/seo/issues', async (request) => {
5239
+ try {
5240
+ const auth = await requireAuth(request);
5241
+ if (auth.error)
5242
+ return auth.error;
5243
+ if (!hasModel(db(), 'seoIssue'))
5244
+ return json({ data: { issues: [] } });
5245
+ const url = new URL(request.url);
5246
+ const where = {};
5247
+ const severity = url.searchParams.get('severity');
5248
+ const category = url.searchParams.get('category');
5249
+ const status = url.searchParams.get('status');
5250
+ const auditRunId = url.searchParams.get('auditRunId');
5251
+ if (severity)
5252
+ where.severity = severity;
5253
+ if (category)
5254
+ where.category = category;
5255
+ if (status)
5256
+ where.status = status;
5257
+ else
5258
+ where.status = { in: ['open', 'recurring'] };
5259
+ if (auditRunId)
5260
+ where.auditRunId = auditRunId;
5261
+ const issues = await db().seoIssue.findMany({
5262
+ where,
5263
+ orderBy: [{ severity: 'asc' }, { lastDetectedAt: 'desc' }],
5264
+ take: 500,
5265
+ });
5266
+ return json({ data: { issues: issues.map(seoIssueToApi) } });
5267
+ }
5268
+ catch (err) {
5269
+ return internalError(err, 'seo/issues');
5270
+ }
5271
+ });
5272
+ router.get('/seo/issues/:id', async (request, params) => {
5273
+ try {
5274
+ const auth = await requireAuth(request);
5275
+ if (auth.error)
5276
+ return auth.error;
5277
+ if (!hasModel(db(), 'seoIssue'))
5278
+ return modelNotAvailable('SeoIssue');
5279
+ const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
5280
+ if (!issue)
5281
+ return errorResponse('Issue not found', 404);
5282
+ return json({ data: seoIssueToApi(issue) });
5283
+ }
5284
+ catch (err) {
5285
+ return internalError(err, 'seo/issues/:id');
5286
+ }
5287
+ });
5288
+ async function setIssueStatus(request, issueId, status) {
5289
+ const auth = await requireAuth(request);
5290
+ if (auth.error)
5291
+ return auth.error;
5292
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
5293
+ if (roleErr)
5294
+ return roleErr;
5295
+ if (!hasModel(db(), 'seoIssue'))
5296
+ return modelNotAvailable('SeoIssue');
5297
+ const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
5298
+ if (!existing)
5299
+ return errorResponse('Issue not found', 404);
5300
+ const issue = await db().seoIssue.update({
5301
+ where: { id: issueId },
5302
+ data: {
5303
+ status,
5304
+ resolvedAt: status === 'resolved' ? new Date() : null,
5305
+ resolvedById: status === 'resolved' ? auth.session.userId : null,
5306
+ },
5307
+ });
5308
+ try {
5309
+ await logEvent({
5310
+ event: 'settings_changed',
5311
+ userId: auth.session.userId,
5312
+ details: { action: 'seo_issue_status', issueId, status },
5313
+ });
5314
+ }
5315
+ catch {
5316
+ /* noop */
5317
+ }
5318
+ return json({ data: seoIssueToApi(issue) });
5319
+ }
5320
+ router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
5321
+ router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
5322
+ router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
5323
+ router.get('/seo/overview', async (request) => {
5324
+ try {
5325
+ const auth = await requireAuth(request);
5326
+ if (auth.error)
5327
+ return auth.error;
5328
+ const { resolveSeoModuleSettings, getSeoOverrides } = await import('../seo/config-store.js');
5329
+ const { buildQuickWins } = await import('../seo/audit-engine.js');
5330
+ const overrides = await getSeoOverrides(db()).catch(() => null);
5331
+ const moduleSettings = resolveSeoModuleSettings(overrides);
5332
+ let lastRun = null;
5333
+ let openIssues = [];
5334
+ if (hasModel(db(), 'seoAuditRun')) {
5335
+ lastRun = await db().seoAuditRun.findFirst({
5336
+ where: { status: 'COMPLETED' },
5337
+ orderBy: { startedAt: 'desc' },
5338
+ });
5339
+ }
5340
+ if (hasModel(db(), 'seoIssue')) {
5341
+ openIssues = await db().seoIssue.findMany({
5342
+ where: { status: { in: ['open', 'recurring'] } },
5343
+ orderBy: [{ severity: 'asc' }, { lastDetectedAt: 'desc' }],
5344
+ take: 200,
5345
+ });
5346
+ }
5347
+ const records = await buildSeoContentRecords().catch(() => []);
5348
+ const indexable = records.filter((r) => !r.noindex).length;
5349
+ const perf = await loadSearchPerformance(10);
5350
+ const summary = {
5351
+ total: openIssues.length,
5352
+ critical: openIssues.filter((i) => i.severity === 'critical').length,
5353
+ warning: openIssues.filter((i) => i.severity === 'warning').length,
5354
+ info: openIssues.filter((i) => i.severity === 'info').length,
5355
+ };
5356
+ const scoreAfter = lastRun?.siteScoreAfter ?? null;
5357
+ const scoreBefore = lastRun?.siteScoreBefore ?? null;
5358
+ const breakdown = lastRun?.metadata?.breakdown ?? [];
5359
+ const avgPosition = perf.length > 0
5360
+ ? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
5361
+ : null;
5362
+ const weeklyImpressions = perf.reduce((s, r) => s + r.impressions, 0);
5363
+ const metrics = [
5364
+ {
5365
+ label: 'Pages Indexed',
5366
+ value: String(indexable),
5367
+ helper: `${records.length} total pages`,
5368
+ trend: 'flat',
5369
+ },
5370
+ {
5371
+ label: 'Open Issues',
5372
+ value: String(summary.total),
5373
+ helper: `${summary.critical} critical`,
5374
+ trend: summary.critical > 0 ? 'down' : 'flat',
5375
+ },
5376
+ {
5377
+ label: 'Avg. Position',
5378
+ value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
5379
+ helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
5380
+ trend: 'flat',
5381
+ },
5382
+ {
5383
+ label: 'Weekly Impressions',
5384
+ value: weeklyImpressions > 0 ? formatCompactNumber(weeklyImpressions) : '—',
5385
+ helper: moduleSettings.searchConsoleConnected ? 'last 28 days' : 'Connect Search Console',
5386
+ trend: 'flat',
5387
+ },
5388
+ ];
5389
+ const quickWins = buildQuickWins(openIssues.map((i) => ({
5390
+ issueKey: i.issueKey,
5391
+ entityType: i.entityType,
5392
+ entityId: i.entityId,
5393
+ entityTitle: i.entityTitle,
5394
+ url: i.url,
5395
+ severity: i.severity,
5396
+ category: i.category,
5397
+ type: i.type,
5398
+ title: i.title,
5399
+ description: i.description ?? '',
5400
+ impact: i.impact ?? 'Low',
5401
+ recommendation: i.recommendation ?? '',
5402
+ fixable: i.fixable ?? false,
5403
+ fixActionType: i.fixActionType ?? null,
5404
+ relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
5405
+ }))).map((w) => ({
5406
+ id: w.id,
5407
+ title: w.title,
5408
+ explanation: w.explanation,
5409
+ impact: w.impact,
5410
+ issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
5411
+ fixActionType: w.fixActionType,
5412
+ }));
5413
+ return json({
5414
+ data: {
5415
+ siteScore: {
5416
+ score: scoreAfter ?? 0,
5417
+ grade: gradeForScoreBand(scoreAfter ?? 0),
5418
+ delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
5419
+ breakdown,
5420
+ },
5421
+ metrics,
5422
+ issues: openIssues.slice(0, 7).map(seoIssueToApi),
5423
+ issuesSummary: summary,
5424
+ quickWins,
5425
+ searchPerformance: perf,
5426
+ searchConsoleConnected: moduleSettings.searchConsoleConnected,
5427
+ lastAuditRunId: lastRun?.id ?? null,
5428
+ },
5429
+ });
5430
+ }
5431
+ catch (err) {
5432
+ return internalError(err, 'seo/overview');
5433
+ }
5434
+ });
5435
+ router.get('/seo/search-performance', async (request) => {
5436
+ try {
5437
+ const auth = await requireAuth(request);
5438
+ if (auth.error)
5439
+ return auth.error;
5440
+ const url = new URL(request.url);
5441
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
5442
+ const rows = await loadSearchPerformance(limit);
5443
+ return json({ data: { rows } });
5444
+ }
5445
+ catch (err) {
5446
+ return internalError(err, 'seo/search-performance');
5447
+ }
5448
+ });
5449
+ router.get('/seo/content', async (request) => {
5450
+ try {
5451
+ const auth = await requireAuth(request);
5452
+ if (auth.error)
5453
+ return auth.error;
5454
+ const records = await buildSeoContentRecords();
5455
+ return json({ data: { records } });
5456
+ }
5457
+ catch (err) {
5458
+ return internalError(err, 'seo/content');
5459
+ }
5460
+ });
5461
+ router.get('/seo/content/:entityType/:entityId', async (request, params) => {
5462
+ try {
5463
+ const auth = await requireAuth(request);
5464
+ if (auth.error)
5465
+ return auth.error;
5466
+ const records = await buildSeoContentRecords();
5467
+ const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
5468
+ if (!record)
5469
+ return errorResponse('Content record not found', 404);
5470
+ return json({ data: record });
5471
+ }
5472
+ catch (err) {
5473
+ return internalError(err, 'seo/content/:id');
5474
+ }
5475
+ });
5476
+ router.put('/seo/content/:entityType/:entityId', async (request, params) => {
5477
+ try {
5478
+ const auth = await requireAuth(request);
5479
+ if (auth.error)
5480
+ return auth.error;
5481
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
5482
+ if (roleErr)
5483
+ return roleErr;
5484
+ const body = (await request.json().catch(() => null));
5485
+ if (!body || typeof body !== 'object') {
5486
+ return errorResponse('Request body must be an object', 400);
5487
+ }
5488
+ const doc = await db().document.findFirst({
5489
+ where: { id: params.entityId, deletedAt: null },
5490
+ select: { id: true, data: true },
5491
+ });
5492
+ if (!doc)
5493
+ return errorResponse('Document not found', 404);
5494
+ const data = asRecord(doc.data);
5495
+ // Map SEO field payload onto the document data JSON. Canonical is stored
5496
+ // as `canonical` to match the existing reader in normalizeSeoPage.
5497
+ const fieldMap = {
5498
+ metaTitle: 'metaTitle',
5499
+ metaDescription: 'metaDescription',
5500
+ canonicalUrl: 'canonical',
5501
+ focusKeyword: 'focusKeyword',
5502
+ ogTitle: 'ogTitle',
5503
+ ogDescription: 'ogDescription',
5504
+ ogImage: 'ogImage',
5505
+ twitterTitle: 'twitterTitle',
5506
+ twitterDescription: 'twitterDescription',
5507
+ twitterImage: 'twitterImage',
5508
+ structuredDataType: 'structuredDataType',
5509
+ };
5510
+ const next = { ...data };
5511
+ for (const [key, target] of Object.entries(fieldMap)) {
5512
+ if (key in body)
5513
+ next[target] = body[key];
5514
+ }
5515
+ if ('noindex' in body)
5516
+ next.noindex = body.noindex === true;
5517
+ if ('nofollow' in body)
5518
+ next.nofollow = body.nofollow === true;
5519
+ if ('structuredData' in body)
5520
+ next.structuredData = body.structuredData;
5521
+ await db().document.update({
5522
+ where: { id: doc.id },
5523
+ data: { data: next, updatedById: auth.session.userId },
5524
+ });
5525
+ try {
5526
+ await logEvent({
5527
+ event: 'document_updated',
5528
+ userId: auth.session.userId,
5529
+ details: { action: 'seo_fields_update', documentId: doc.id },
5530
+ });
5531
+ }
5532
+ catch {
5533
+ /* noop */
5534
+ }
5535
+ const records = await buildSeoContentRecords();
5536
+ const record = records.find((r) => r.entityId === doc.id);
5537
+ return json({ data: record ?? { ok: true } });
5538
+ }
5539
+ catch (err) {
5540
+ return internalError(err, 'seo/content PUT');
5541
+ }
5542
+ });
5543
+ // ---------------------------------------------------------------------------
5544
+ // SEO analysis, readability, internal links, schema, meta
5545
+ // ---------------------------------------------------------------------------
5546
+ router.get('/seo/analysis/:documentId', async (request, params) => {
5547
+ try {
5548
+ const auth = await requireAuth(request);
5549
+ if (auth.error)
5550
+ return auth.error;
5551
+ const doc = await db().document.findFirst({
5552
+ where: { id: params.documentId, deletedAt: null },
5553
+ });
5554
+ if (!doc)
4264
5555
  return errorResponse('Not found', 404);
4265
5556
  const { analyzeContent } = await import('../seo/analysis.js');
4266
5557
  const data = doc.data || {};
@@ -4562,6 +5853,28 @@ export function registerCMSRoutes(router) {
4562
5853
  const seo = cfg?.seo;
4563
5854
  if (seo?.robots?.disabled)
4564
5855
  return errorResponse('robots.txt disabled', 404);
5856
+ // Admin-edited custom robots.txt body (Technical tab) wins when set —
5857
+ // the editor is the single source of truth for operators who want full
5858
+ // control over directives.
5859
+ try {
5860
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
5861
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
5862
+ if (settings.robotsEnabled === false)
5863
+ return errorResponse('robots.txt disabled', 404);
5864
+ const custom = settings.robotsTxt?.trim();
5865
+ if (custom) {
5866
+ return new Response(settings.robotsTxt, {
5867
+ status: 200,
5868
+ headers: {
5869
+ 'Content-Type': 'text/plain; charset=utf-8',
5870
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
5871
+ },
5872
+ });
5873
+ }
5874
+ }
5875
+ catch {
5876
+ // Fall through to generated defaults on any override-read failure.
5877
+ }
4565
5878
  const base = siteUrlFromRequest(request, cfg);
4566
5879
  const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
4567
5880
  const lines = [
@@ -4812,23 +6125,231 @@ export function registerCMSRoutes(router) {
4812
6125
  });
4813
6126
  }
4814
6127
  catch (err) {
4815
- return internalError(err);
6128
+ return internalError(err);
6129
+ }
6130
+ });
6131
+ // ---------------------------------------------------------------------------
6132
+ // SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
6133
+ // ---------------------------------------------------------------------------
6134
+ /**
6135
+ * Returns:
6136
+ * - `static` → the SEO config from `actuate.config.ts` (read-only, code-level)
6137
+ * - `overrides` → the user-edited DB overrides (or null on first boot)
6138
+ * - `effective` → static + overrides merged (what the runtime actually uses)
6139
+ * - `collections` → list of collection slugs + labels the UI should iterate
6140
+ *
6141
+ * The UI uses `static` to show grey "default" placeholders, `overrides` to
6142
+ * populate the form inputs, and writes back just the changed fields via PUT.
6143
+ */
6144
+ router.get('/seo/config', async (request) => {
6145
+ try {
6146
+ const auth = await requireAuth(request);
6147
+ if (auth.error)
6148
+ return auth.error;
6149
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
6150
+ if (roleErr)
6151
+ return roleErr;
6152
+ const { getSeoOverrides, applySeoOverrides } = await import('../seo/config-store.js');
6153
+ const staticCfg = getActuateConfig();
6154
+ const overrides = await getSeoOverrides(db());
6155
+ const effective = applySeoOverrides(staticCfg, overrides);
6156
+ const collections = Object.entries(staticCfg?.collections ?? {})
6157
+ .map(([slug, col]) => ({
6158
+ slug,
6159
+ label: col.labels?.plural ?? slug,
6160
+ type: col.type ?? 'page',
6161
+ urlPrefix: col.urlPrefix,
6162
+ staticSeo: col.seo ?? null,
6163
+ effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
6164
+ }))
6165
+ .sort((a, b) => a.label.localeCompare(b.label));
6166
+ return json({
6167
+ data: {
6168
+ static: { site: staticCfg?.seo ?? null },
6169
+ overrides: overrides ?? null,
6170
+ effective: { site: effective?.seo ?? null },
6171
+ collections,
6172
+ },
6173
+ });
6174
+ }
6175
+ catch (err) {
6176
+ return internalError(err, 'seo/config GET');
6177
+ }
6178
+ });
6179
+ router.put('/seo/config', async (request) => {
6180
+ try {
6181
+ const auth = await requireAuth(request);
6182
+ if (auth.error)
6183
+ return auth.error;
6184
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
6185
+ if (roleErr)
6186
+ return roleErr;
6187
+ const body = (await request.json().catch(() => null));
6188
+ if (!body || typeof body !== 'object') {
6189
+ return errorResponse('Request body must be an object', 400);
6190
+ }
6191
+ // Shallow shape validation — we don't want to mirror the entire type
6192
+ // here (the config-store merger ignores unknown keys anyway), but we do
6193
+ // want to reject obviously malformed payloads early so the UI gets a
6194
+ // clear 400 instead of a silent no-op.
6195
+ const site = body.site && typeof body.site === 'object' ? body.site : undefined;
6196
+ const collections = body.collections && typeof body.collections === 'object' ? body.collections : undefined;
6197
+ if (site === undefined && collections === undefined) {
6198
+ return errorResponse('Body must include at least one of `site` or `collections`', 400);
6199
+ }
6200
+ const { putSeoOverrides, getSeoOverrides } = await import('../seo/config-store.js');
6201
+ // Merge with whatever's currently stored so the UI can PATCH a single
6202
+ // section without wiping the other — `PUT /seo/config { site: {...} }`
6203
+ // shouldn't blow away per-collection settings.
6204
+ const current = (await getSeoOverrides(db())) ?? {};
6205
+ const next = {
6206
+ site: site !== undefined ? site : current.site,
6207
+ collections: collections !== undefined
6208
+ ? { ...(current.collections ?? {}), ...collections }
6209
+ : current.collections,
6210
+ // Preserve Technical-tab module settings so editing site/collection
6211
+ // defaults never silently wipes crawl/robots/sitemap configuration.
6212
+ module: current.module,
6213
+ };
6214
+ const saved = await putSeoOverrides(db(), next, auth.session.userId);
6215
+ // Audit so changes to public SEO surface are traceable.
6216
+ try {
6217
+ await db().auditLog?.create?.({
6218
+ data: {
6219
+ action: 'update_seo_config',
6220
+ actorId: auth.session.userId,
6221
+ metadata: {
6222
+ siteKeys: Object.keys(site ?? {}),
6223
+ collectionKeys: Object.keys(collections ?? {}),
6224
+ },
6225
+ },
6226
+ });
6227
+ }
6228
+ catch {
6229
+ // Audit failures must never block the write.
6230
+ }
6231
+ return json({ data: saved });
6232
+ }
6233
+ catch (err) {
6234
+ return internalError(err, 'seo/config PUT');
6235
+ }
6236
+ });
6237
+ // ---------------------------------------------------------------------------
6238
+ // Technical SEO — sitemap status, crawl settings, robots.txt editor.
6239
+ // All backed by the `module` block of the SEO overrides document.
6240
+ // ---------------------------------------------------------------------------
6241
+ /** Persist a partial patch onto the SEO module settings, preserving the rest. */
6242
+ async function patchSeoModule(patch, userId) {
6243
+ const { getSeoOverrides, putSeoOverrides } = await import('../seo/config-store.js');
6244
+ const current = (await getSeoOverrides(db())) ?? {};
6245
+ await putSeoOverrides(db(), {
6246
+ site: current.site,
6247
+ collections: current.collections,
6248
+ module: { ...(current.module ?? {}), ...patch },
6249
+ }, userId);
6250
+ }
6251
+ /** Build the default robots.txt body (mirrors the public /robots.txt route). */
6252
+ function buildDefaultRobots(request, cfg) {
6253
+ const base = siteUrlFromRequest(request, cfg);
6254
+ const sitemapUrl = cfg?.seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
6255
+ const lines = ['User-agent: *', 'Allow: /', 'Disallow: /admin', 'Disallow: /api/', ''];
6256
+ if (sitemapUrl)
6257
+ lines.push(`Sitemap: ${sitemapUrl}`, '');
6258
+ return lines.join('\n');
6259
+ }
6260
+ router.get('/seo/sitemap/status', async (request) => {
6261
+ try {
6262
+ const auth = await requireAuth(request);
6263
+ if (auth.error)
6264
+ return auth.error;
6265
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
6266
+ if (roleErr)
6267
+ return roleErr;
6268
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
6269
+ const cfg = await loadEffectiveConfig();
6270
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
6271
+ const base = siteUrlFromRequest(request, cfg);
6272
+ const cols = sitemapEligibleCollections(cfg);
6273
+ let pagesIncluded = 0;
6274
+ let pagesExcluded = 0;
6275
+ for (const col of cols) {
6276
+ const count = await db().document.count({
6277
+ where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
6278
+ });
6279
+ pagesIncluded += count;
6280
+ }
6281
+ // Best-effort: count published docs explicitly flagged noindex.
6282
+ try {
6283
+ pagesExcluded = await db().document.count({
6284
+ where: {
6285
+ deletedAt: null,
6286
+ status: 'PUBLISHED',
6287
+ data: { path: ['noindex'], equals: true },
6288
+ },
6289
+ });
6290
+ }
6291
+ catch {
6292
+ pagesExcluded = 0;
6293
+ }
6294
+ const warnings = [];
6295
+ if (!settings.sitemapEnabled)
6296
+ warnings.push('Sitemap generation is disabled in crawl settings.');
6297
+ if (pagesIncluded === 0)
6298
+ warnings.push('No published pages are eligible for the sitemap.');
6299
+ return json({
6300
+ data: {
6301
+ sitemapUrl: `${base}/api/cms/sitemap.xml`,
6302
+ lastGeneratedAt: settings.sitemapLastGeneratedAt,
6303
+ pagesIncluded,
6304
+ pagesExcluded,
6305
+ autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
6306
+ errors: [],
6307
+ warnings,
6308
+ },
6309
+ });
6310
+ }
6311
+ catch (err) {
6312
+ return internalError(err, 'seo/sitemap/status');
6313
+ }
6314
+ });
6315
+ router.post('/seo/sitemap/regenerate', async (request) => {
6316
+ try {
6317
+ const auth = await requireAuth(request);
6318
+ if (auth.error)
6319
+ return auth.error;
6320
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
6321
+ if (roleErr)
6322
+ return roleErr;
6323
+ const now = new Date().toISOString();
6324
+ await patchSeoModule({ sitemapLastGeneratedAt: now }, auth.session.userId);
6325
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
6326
+ const cfg = await loadEffectiveConfig();
6327
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
6328
+ const base = siteUrlFromRequest(request, cfg);
6329
+ const cols = sitemapEligibleCollections(cfg);
6330
+ let pagesIncluded = 0;
6331
+ for (const col of cols) {
6332
+ pagesIncluded += await db().document.count({
6333
+ where: { collection: col.slug, deletedAt: null, status: 'PUBLISHED' },
6334
+ });
6335
+ }
6336
+ return json({
6337
+ data: {
6338
+ sitemapUrl: `${base}/api/cms/sitemap.xml`,
6339
+ lastGeneratedAt: now,
6340
+ pagesIncluded,
6341
+ pagesExcluded: 0,
6342
+ autoRegenerateOnPublish: settings.autoRegenerateSitemapOnPublish,
6343
+ errors: [],
6344
+ warnings: [],
6345
+ },
6346
+ });
6347
+ }
6348
+ catch (err) {
6349
+ return internalError(err, 'seo/sitemap/regenerate');
4816
6350
  }
4817
6351
  });
4818
- // ---------------------------------------------------------------------------
4819
- // SEO config (admin-editable overrides for `actuate.config.ts` seo defaults)
4820
- // ---------------------------------------------------------------------------
4821
- /**
4822
- * Returns:
4823
- * - `static` → the SEO config from `actuate.config.ts` (read-only, code-level)
4824
- * - `overrides` → the user-edited DB overrides (or null on first boot)
4825
- * - `effective` → static + overrides merged (what the runtime actually uses)
4826
- * - `collections` → list of collection slugs + labels the UI should iterate
4827
- *
4828
- * The UI uses `static` to show grey "default" placeholders, `overrides` to
4829
- * populate the form inputs, and writes back just the changed fields via PUT.
4830
- */
4831
- router.get('/seo/config', async (request) => {
6352
+ router.get('/seo/crawl-settings', async (request) => {
4832
6353
  try {
4833
6354
  const auth = await requireAuth(request);
4834
6355
  if (auth.error)
@@ -4836,34 +6357,23 @@ export function registerCMSRoutes(router) {
4836
6357
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4837
6358
  if (roleErr)
4838
6359
  return roleErr;
4839
- const { getSeoOverrides, applySeoOverrides } = await import('../seo/config-store.js');
4840
- const staticCfg = getActuateConfig();
4841
- const overrides = await getSeoOverrides(db());
4842
- const effective = applySeoOverrides(staticCfg, overrides);
4843
- const collections = Object.entries(staticCfg?.collections ?? {})
4844
- .map(([slug, col]) => ({
4845
- slug,
4846
- label: col.labels?.plural ?? slug,
4847
- type: col.type ?? 'page',
4848
- urlPrefix: col.urlPrefix,
4849
- staticSeo: col.seo ?? null,
4850
- effectiveSeo: effective?.collections?.[slug]?.seo ?? null,
4851
- }))
4852
- .sort((a, b) => a.label.localeCompare(b.label));
6360
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
6361
+ const s = resolveSeoModuleSettings(await getSeoOverrides(db()));
4853
6362
  return json({
4854
6363
  data: {
4855
- static: { site: staticCfg?.seo ?? null },
4856
- overrides: overrides ?? null,
4857
- effective: { site: effective?.seo ?? null },
4858
- collections,
6364
+ indexEntireSite: s.indexEntireSite,
6365
+ includeImagesInSitemap: s.includeImagesInSitemap,
6366
+ includePostsInSitemap: s.includePostsInSitemap,
6367
+ noindexPaginatedArchives: s.noindexPaginatedArchives,
6368
+ noindexTagPages: s.noindexTagPages,
4859
6369
  },
4860
6370
  });
4861
6371
  }
4862
6372
  catch (err) {
4863
- return internalError(err, 'seo/config GET');
6373
+ return internalError(err, 'seo/crawl-settings GET');
4864
6374
  }
4865
6375
  });
4866
- router.put('/seo/config', async (request) => {
6376
+ router.put('/seo/crawl-settings', async (request) => {
4867
6377
  try {
4868
6378
  const auth = await requireAuth(request);
4869
6379
  if (auth.error)
@@ -4875,47 +6385,87 @@ export function registerCMSRoutes(router) {
4875
6385
  if (!body || typeof body !== 'object') {
4876
6386
  return errorResponse('Request body must be an object', 400);
4877
6387
  }
4878
- // Shallow shape validation — we don't want to mirror the entire type
4879
- // here (the config-store merger ignores unknown keys anyway), but we do
4880
- // want to reject obviously malformed payloads early so the UI gets a
4881
- // clear 400 instead of a silent no-op.
4882
- const site = body.site && typeof body.site === 'object' ? body.site : undefined;
4883
- const collections = body.collections && typeof body.collections === 'object' ? body.collections : undefined;
4884
- if (site === undefined && collections === undefined) {
4885
- return errorResponse('Body must include at least one of `site` or `collections`', 400);
6388
+ const allowed = [
6389
+ 'indexEntireSite',
6390
+ 'includeImagesInSitemap',
6391
+ 'includePostsInSitemap',
6392
+ 'noindexPaginatedArchives',
6393
+ 'noindexTagPages',
6394
+ ];
6395
+ const patch = {};
6396
+ for (const key of allowed) {
6397
+ if (typeof body[key] === 'boolean')
6398
+ patch[key] = body[key];
4886
6399
  }
4887
- const { putSeoOverrides, getSeoOverrides } = await import('../seo/config-store.js');
4888
- // Merge with whatever's currently stored so the UI can PATCH a single
4889
- // section without wiping the other — `PUT /seo/config { site: {...} }`
4890
- // shouldn't blow away per-collection settings.
4891
- const current = (await getSeoOverrides(db())) ?? {};
4892
- const next = {
4893
- site: site !== undefined ? site : current.site,
4894
- collections: collections !== undefined
4895
- ? { ...(current.collections ?? {}), ...collections }
4896
- : current.collections,
4897
- };
4898
- const saved = await putSeoOverrides(db(), next, auth.session.userId);
4899
- // Audit so changes to public SEO surface are traceable.
6400
+ if (Object.keys(patch).length === 0) {
6401
+ return errorResponse('No valid crawl settings provided', 400);
6402
+ }
6403
+ await patchSeoModule(patch, auth.session.userId);
4900
6404
  try {
4901
- await db().auditLog?.create?.({
4902
- data: {
4903
- action: 'update_seo_config',
4904
- actorId: auth.session.userId,
4905
- metadata: {
4906
- siteKeys: Object.keys(site ?? {}),
4907
- collectionKeys: Object.keys(collections ?? {}),
4908
- },
4909
- },
6405
+ await logEvent({
6406
+ event: 'update_seo_config',
6407
+ userId: auth.session.userId,
6408
+ details: { section: 'crawl-settings', keys: Object.keys(patch) },
4910
6409
  });
4911
6410
  }
4912
6411
  catch {
4913
- // Audit failures must never block the write.
6412
+ /* audit best-effort */
4914
6413
  }
4915
- return json({ data: saved });
6414
+ return json({ data: { ok: true } });
4916
6415
  }
4917
6416
  catch (err) {
4918
- return internalError(err, 'seo/config PUT');
6417
+ return internalError(err, 'seo/crawl-settings PUT');
6418
+ }
6419
+ });
6420
+ router.get('/seo/robots', async (request) => {
6421
+ try {
6422
+ const auth = await requireAuth(request);
6423
+ if (auth.error)
6424
+ return auth.error;
6425
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
6426
+ if (roleErr)
6427
+ return roleErr;
6428
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
6429
+ const cfg = await loadEffectiveConfig();
6430
+ const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
6431
+ const custom = settings.robotsTxt?.trim();
6432
+ const body = custom ? settings.robotsTxt : buildDefaultRobots(request, cfg);
6433
+ return json({ data: { body, generated: !custom } });
6434
+ }
6435
+ catch (err) {
6436
+ return internalError(err, 'seo/robots GET');
6437
+ }
6438
+ });
6439
+ router.put('/seo/robots', async (request) => {
6440
+ try {
6441
+ const auth = await requireAuth(request);
6442
+ if (auth.error)
6443
+ return auth.error;
6444
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
6445
+ if (roleErr)
6446
+ return roleErr;
6447
+ const body = (await request.json().catch(() => null));
6448
+ if (!body || typeof body.body !== 'string') {
6449
+ return errorResponse('`body` (string) is required', 400);
6450
+ }
6451
+ if (body.body.length > 50_000) {
6452
+ return errorResponse('robots.txt is too large (max 50KB)', 400);
6453
+ }
6454
+ await patchSeoModule({ robotsTxt: body.body }, auth.session.userId);
6455
+ try {
6456
+ await logEvent({
6457
+ event: 'update_seo_config',
6458
+ userId: auth.session.userId,
6459
+ details: { section: 'robots.txt' },
6460
+ });
6461
+ }
6462
+ catch {
6463
+ /* audit best-effort */
6464
+ }
6465
+ return json({ data: { ok: true } });
6466
+ }
6467
+ catch (err) {
6468
+ return internalError(err, 'seo/robots PUT');
4919
6469
  }
4920
6470
  });
4921
6471
  // ---------------------------------------------------------------------------
@@ -5265,6 +6815,9 @@ export function registerCMSRoutes(router) {
5265
6815
  tags: composed.meta,
5266
6816
  html: composed.metaHtml,
5267
6817
  },
6818
+ // Next.js `generateMetadata()`-compatible object so the public site
6819
+ // can spread it directly without re-deriving OG/Twitter/robots tags.
6820
+ metadata: composed.metadata,
5268
6821
  jsonLd: composed.jsonLd,
5269
6822
  jsonLdHtml: composed.jsonLdHtml,
5270
6823
  ...(templateHeader ? { template: { header: templateHeader } } : {}),
@@ -7940,5 +9493,319 @@ export function registerCMSRoutes(router) {
7940
9493
  return internalError(err, 'ai suggest-internal-links');
7941
9494
  }
7942
9495
  });
9496
+ /**
9497
+ * Generate accessible alt text (and a derived title) for an image asset
9498
+ * using the AI vision provider. Replaces the old client-side filename mock.
9499
+ * Returns 501 when the AI plugin/provider isn't configured so the admin can
9500
+ * fall back gracefully instead of silently failing.
9501
+ */
9502
+ router.post('/ai/media-metadata', async (request) => {
9503
+ try {
9504
+ const auth = await requireAuth(request);
9505
+ if (auth.error)
9506
+ return auth.error;
9507
+ const scopeErr = requireMediaScope(auth.session);
9508
+ if (scopeErr)
9509
+ return scopeErr;
9510
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-media-metadata:${auth.session.userId}`))) {
9511
+ return errorResponse('AI media metadata rate limit reached. Try again in an hour.', 429);
9512
+ }
9513
+ const body = (await request.json());
9514
+ // Resolve a safe image URL. Prefer a media id we own; only accept a raw
9515
+ // URL if it passes the storage allowlist (SSRF guard).
9516
+ let imageUrl = '';
9517
+ let mimeType = '';
9518
+ if (body.mediaId) {
9519
+ const media = await db().media.findUnique({ where: { id: body.mediaId } });
9520
+ if (!media)
9521
+ return errorResponse('Media not found', 404);
9522
+ mimeType = media.mimeType;
9523
+ imageUrl = media.publicUrl ?? mediaUrl(media.storageKey);
9524
+ }
9525
+ else if (body.imageUrl) {
9526
+ if (!isAllowedStorageUrl(body.imageUrl)) {
9527
+ return errorResponse('Invalid image URL', 400);
9528
+ }
9529
+ imageUrl = body.imageUrl;
9530
+ }
9531
+ else {
9532
+ return errorResponse('mediaId or imageUrl is required', 400);
9533
+ }
9534
+ if (mimeType && !mimeType.startsWith('image/')) {
9535
+ return errorResponse('AI metadata is only available for images', 400);
9536
+ }
9537
+ let aiModule;
9538
+ try {
9539
+ aiModule = await importAIPlugin();
9540
+ }
9541
+ catch {
9542
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to generate media metadata.', 501);
9543
+ }
9544
+ if (typeof aiModule.generateAltText !== 'function') {
9545
+ return errorResponse('AI plugin missing `generateAltText`.', 501);
9546
+ }
9547
+ let alt = '';
9548
+ let alternatives = [];
9549
+ try {
9550
+ const r = (await aiModule.generateAltText(imageUrl, { pageTitle: body.pageTitle }));
9551
+ alt = (r?.altText ?? '').trim();
9552
+ alternatives = Array.isArray(r?.alternatives) ? r.alternatives : [];
9553
+ }
9554
+ catch (err) {
9555
+ const msg = err instanceof Error ? err.message : 'unknown';
9556
+ if (msg.toLowerCase().includes('not configured') ||
9557
+ msg.toLowerCase().includes('missing') ||
9558
+ msg.toLowerCase().includes('api key')) {
9559
+ return errorResponse(`AI provider is not configured: ${msg}`, 501);
9560
+ }
9561
+ throw err;
9562
+ }
9563
+ await logEvent({
9564
+ event: 'settings_changed',
9565
+ userId: auth.session.userId,
9566
+ details: { action: 'ai_media_metadata', mediaId: body.mediaId ?? null },
9567
+ });
9568
+ return json({ data: { alt, title: deriveMediaTitleFromAlt(alt), alternatives } });
9569
+ }
9570
+ catch (err) {
9571
+ return internalError(err, 'ai media-metadata');
9572
+ }
9573
+ });
9574
+ // ---------------------------------------------------------------------------
9575
+ // AI SEO Copilot — approval-gated assistance for the SEO Operations Center.
9576
+ // Every endpoint degrades gracefully to 501 when plugin-ai is missing or the
9577
+ // provider is unconfigured (the admin maps 501 -> "AI unavailable"). The AI
9578
+ // never writes directly: it returns suggestions the editor must approve.
9579
+ // ---------------------------------------------------------------------------
9580
+ /** Returns true for errors that mean "AI provider isn't set up" (=> 501). */
9581
+ function isAiUnconfigured(err) {
9582
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
9583
+ return msg.includes('not configured') || msg.includes('missing') || msg.includes('api key');
9584
+ }
9585
+ /** Load a page/post document for AI context. Returns null when not found. */
9586
+ async function loadSeoEntity(entityType, entityId) {
9587
+ const doc = await db().document.findFirst({
9588
+ where: { id: entityId, deletedAt: null },
9589
+ select: { title: true, data: true, plainText: true },
9590
+ });
9591
+ if (!doc)
9592
+ return null;
9593
+ const data = doc.data ?? {};
9594
+ const content = (typeof doc.plainText === 'string' && doc.plainText) ||
9595
+ (typeof data.body === 'string' ? data.body : '') ||
9596
+ (typeof data.content === 'string' ? data.content : '') ||
9597
+ (typeof data.excerpt === 'string' ? data.excerpt : '');
9598
+ const title = (typeof data.title === 'string' && data.title) || doc.title || 'Untitled';
9599
+ return { title, content: `${title}\n\n${content}`.trim() };
9600
+ }
9601
+ router.post('/ai/seo/generate-field', async (request) => {
9602
+ try {
9603
+ const auth = await requireAuth(request);
9604
+ if (auth.error)
9605
+ return auth.error;
9606
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
9607
+ if (roleErr)
9608
+ return roleErr;
9609
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-field:${auth.session.userId}`))) {
9610
+ return errorResponse('AI rate limit reached. Try again in an hour.', 429);
9611
+ }
9612
+ const body = (await request.json().catch(() => null));
9613
+ const field = body?.field;
9614
+ const ALLOWED = ['metaTitle', 'metaDescription', 'ogTitle', 'ogDescription', 'focusKeyword'];
9615
+ if (!field || !ALLOWED.includes(field)) {
9616
+ return errorResponse(`field must be one of: ${ALLOWED.join(', ')}`, 400);
9617
+ }
9618
+ if (!body?.entityType || !body?.entityId) {
9619
+ return errorResponse('entityType and entityId are required', 400);
9620
+ }
9621
+ const entity = await loadSeoEntity(body.entityType, body.entityId);
9622
+ if (!entity)
9623
+ return errorResponse('Content not found', 404);
9624
+ let ai;
9625
+ try {
9626
+ ai = await importAIPlugin();
9627
+ }
9628
+ catch {
9629
+ return errorResponse('AI plugin is not installed.', 501);
9630
+ }
9631
+ try {
9632
+ if (field === 'metaTitle' || field === 'ogTitle') {
9633
+ const titles = await ai.generateTitle(entity.content, {
9634
+ count: 4,
9635
+ style: 'seo-optimized',
9636
+ });
9637
+ const cleaned = titles.map((t) => t.trim()).filter(Boolean);
9638
+ if (cleaned.length === 0)
9639
+ return json({ data: {} });
9640
+ return json({ data: { text: cleaned[0], alternatives: cleaned.slice(1) } });
9641
+ }
9642
+ if (field === 'metaDescription' || field === 'ogDescription') {
9643
+ const text = await ai.generateMetaDescription(entity.content, 155);
9644
+ return json({ data: { text } });
9645
+ }
9646
+ // focusKeyword
9647
+ const text = await ai.generate(`Suggest a single primary SEO focus keyword phrase (2-4 words) for this content. Return only the phrase, no quotes or punctuation.\n\n${entity.content.slice(0, 2000)}`);
9648
+ return json({ data: { text: text.replace(/^["']|["']$/g, '').trim() } });
9649
+ }
9650
+ catch (err) {
9651
+ if (isAiUnconfigured(err))
9652
+ return errorResponse('AI provider is not configured.', 501);
9653
+ throw err;
9654
+ }
9655
+ }
9656
+ catch (err) {
9657
+ return internalError(err, 'ai seo generate-field');
9658
+ }
9659
+ });
9660
+ router.post('/ai/seo/explain-issue', async (request) => {
9661
+ try {
9662
+ const auth = await requireAuth(request);
9663
+ if (auth.error)
9664
+ return auth.error;
9665
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
9666
+ if (roleErr)
9667
+ return roleErr;
9668
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-explain:${auth.session.userId}`))) {
9669
+ return errorResponse('AI rate limit reached. Try again in an hour.', 429);
9670
+ }
9671
+ const body = (await request.json().catch(() => null));
9672
+ if (!body?.issueId)
9673
+ return errorResponse('issueId is required', 400);
9674
+ if (!hasModel(db(), 'seoIssue'))
9675
+ return modelNotAvailable('seoIssue');
9676
+ const issue = await db().seoIssue.findUnique({ where: { id: body.issueId } });
9677
+ if (!issue)
9678
+ return errorResponse('Issue not found', 404);
9679
+ let ai;
9680
+ try {
9681
+ ai = await importAIPlugin();
9682
+ }
9683
+ catch {
9684
+ return errorResponse('AI plugin is not installed.', 501);
9685
+ }
9686
+ try {
9687
+ const text = await ai.generate(`Explain this SEO issue to a non-technical content editor in 2-3 sentences, then give one concrete fix. Be specific and actionable.\n\nIssue: ${issue.title}\nCategory: ${issue.category}\nSeverity: ${issue.severity}\nPage: ${issue.entityTitle ?? issue.url ?? 'unknown'}\nDescription: ${issue.description ?? 'n/a'}`);
9688
+ return json({ data: { text: text.trim() } });
9689
+ }
9690
+ catch (err) {
9691
+ if (isAiUnconfigured(err))
9692
+ return errorResponse('AI provider is not configured.', 501);
9693
+ throw err;
9694
+ }
9695
+ }
9696
+ catch (err) {
9697
+ return internalError(err, 'ai seo explain-issue');
9698
+ }
9699
+ });
9700
+ router.post('/ai/seo/generate-schema', async (request) => {
9701
+ try {
9702
+ const auth = await requireAuth(request);
9703
+ if (auth.error)
9704
+ return auth.error;
9705
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
9706
+ if (roleErr)
9707
+ return roleErr;
9708
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-schema:${auth.session.userId}`))) {
9709
+ return errorResponse('AI rate limit reached. Try again in an hour.', 429);
9710
+ }
9711
+ const body = (await request.json().catch(() => null));
9712
+ if (!body?.entityType || !body?.entityId || !body?.schemaType) {
9713
+ return errorResponse('entityType, entityId and schemaType are required', 400);
9714
+ }
9715
+ const entity = await loadSeoEntity(body.entityType, body.entityId);
9716
+ if (!entity)
9717
+ return errorResponse('Content not found', 404);
9718
+ let ai;
9719
+ try {
9720
+ ai = await importAIPlugin();
9721
+ }
9722
+ catch {
9723
+ return errorResponse('AI plugin is not installed.', 501);
9724
+ }
9725
+ try {
9726
+ const raw = await ai.generate(`Generate valid Schema.org JSON-LD of type "${body.schemaType}" for this content. Return ONLY a JSON object (no markdown fences), starting with {"@context":"https://schema.org","@type":"${body.schemaType}"}.\n\nTitle: ${entity.title}\nContent: ${entity.content.slice(0, 2000)}`);
9727
+ const cleaned = raw
9728
+ .replace(/```json?\n?/g, '')
9729
+ .replace(/```/g, '')
9730
+ .trim();
9731
+ let structuredData;
9732
+ try {
9733
+ structuredData = JSON.parse(cleaned);
9734
+ }
9735
+ catch {
9736
+ return errorResponse('AI returned invalid JSON-LD. Please try again.', 422);
9737
+ }
9738
+ // Minimal validation: must be an object carrying an @type. Never trust
9739
+ // raw model output for structured data.
9740
+ if (!structuredData ||
9741
+ typeof structuredData !== 'object' ||
9742
+ Array.isArray(structuredData) ||
9743
+ !('@type' in structuredData)) {
9744
+ return errorResponse('AI output did not contain valid structured data.', 422);
9745
+ }
9746
+ if (!('@context' in structuredData)) {
9747
+ structuredData['@context'] = 'https://schema.org';
9748
+ }
9749
+ return json({ data: { structuredData } });
9750
+ }
9751
+ catch (err) {
9752
+ if (isAiUnconfigured(err))
9753
+ return errorResponse('AI provider is not configured.', 501);
9754
+ throw err;
9755
+ }
9756
+ }
9757
+ catch (err) {
9758
+ return internalError(err, 'ai seo generate-schema');
9759
+ }
9760
+ });
9761
+ router.post('/ai/seo/summarize', async (request) => {
9762
+ try {
9763
+ const auth = await requireAuth(request);
9764
+ if (auth.error)
9765
+ return auth.error;
9766
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
9767
+ if (roleErr)
9768
+ return roleErr;
9769
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-summary:${auth.session.userId}`))) {
9770
+ return errorResponse('AI rate limit reached. Try again in an hour.', 429);
9771
+ }
9772
+ let openIssues = [];
9773
+ if (hasModel(db(), 'seoIssue')) {
9774
+ openIssues = await db().seoIssue.findMany({
9775
+ where: { status: { in: ['open', 'recurring'] } },
9776
+ orderBy: [{ severity: 'asc' }, { lastDetectedAt: 'desc' }],
9777
+ take: 40,
9778
+ });
9779
+ }
9780
+ if (openIssues.length === 0) {
9781
+ return json({
9782
+ data: { text: 'No open SEO issues found. Run an audit to scan for new issues.' },
9783
+ });
9784
+ }
9785
+ let ai;
9786
+ try {
9787
+ ai = await importAIPlugin();
9788
+ }
9789
+ catch {
9790
+ return errorResponse('AI plugin is not installed.', 501);
9791
+ }
9792
+ const issueList = openIssues
9793
+ .slice(0, 25)
9794
+ .map((i) => `- [${i.severity}] ${i.title} (${i.entityTitle ?? i.url ?? 'site-wide'})`)
9795
+ .join('\n');
9796
+ try {
9797
+ const text = await ai.generate(`You are an SEO strategist. Summarize the state of this site's SEO from the open issues below in 3-4 sentences, then list the top 3 priorities to fix first.\n\nOpen issues:\n${issueList}`);
9798
+ return json({ data: { text: text.trim() } });
9799
+ }
9800
+ catch (err) {
9801
+ if (isAiUnconfigured(err))
9802
+ return errorResponse('AI provider is not configured.', 501);
9803
+ throw err;
9804
+ }
9805
+ }
9806
+ catch (err) {
9807
+ return internalError(err, 'ai seo summarize');
9808
+ }
9809
+ });
7943
9810
  }
7944
9811
  //# sourceMappingURL=handlers.js.map