@actuate-media/cms-core 0.89.0 → 0.90.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 (126) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/api/route-helpers.d.ts.map +1 -1
  3. package/dist/api/route-helpers.js +1 -0
  4. package/dist/api/route-helpers.js.map +1 -1
  5. package/dist/api/routes/ai-seo.d.ts.map +1 -1
  6. package/dist/api/routes/ai-seo.js +65 -16
  7. package/dist/api/routes/ai-seo.js.map +1 -1
  8. package/dist/api/routes/media.d.ts.map +1 -1
  9. package/dist/api/routes/media.js +3 -0
  10. package/dist/api/routes/media.js.map +1 -1
  11. package/dist/api/routes/redirects.d.ts.map +1 -1
  12. package/dist/api/routes/redirects.js +185 -106
  13. package/dist/api/routes/redirects.js.map +1 -1
  14. package/dist/api/routes/saved-sections.d.ts.map +1 -1
  15. package/dist/api/routes/saved-sections.js +35 -3
  16. package/dist/api/routes/saved-sections.js.map +1 -1
  17. package/dist/api/routes/seo-public.d.ts.map +1 -1
  18. package/dist/api/routes/seo-public.js +42 -56
  19. package/dist/api/routes/seo-public.js.map +1 -1
  20. package/dist/api/routes/seo.d.ts.map +1 -1
  21. package/dist/api/routes/seo.js +325 -30
  22. package/dist/api/routes/seo.js.map +1 -1
  23. package/dist/api/routes/url-resolution.d.ts.map +1 -1
  24. package/dist/api/routes/url-resolution.js +1 -0
  25. package/dist/api/routes/url-resolution.js.map +1 -1
  26. package/dist/client.d.ts +1 -0
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +1 -0
  29. package/dist/client.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/media/index.d.ts +1 -0
  35. package/dist/media/index.d.ts.map +1 -1
  36. package/dist/media/index.js +1 -0
  37. package/dist/media/index.js.map +1 -1
  38. package/dist/media/optimize.d.ts +24 -2
  39. package/dist/media/optimize.d.ts.map +1 -1
  40. package/dist/media/optimize.js +55 -36
  41. package/dist/media/optimize.js.map +1 -1
  42. package/dist/redirects/fix-link.d.ts +15 -0
  43. package/dist/redirects/fix-link.d.ts.map +1 -0
  44. package/dist/redirects/fix-link.js +66 -0
  45. package/dist/redirects/fix-link.js.map +1 -0
  46. package/dist/redirects/index.d.ts +10 -0
  47. package/dist/redirects/index.d.ts.map +1 -1
  48. package/dist/redirects/index.js +6 -0
  49. package/dist/redirects/index.js.map +1 -1
  50. package/dist/redirects/legitimacy.d.ts +23 -0
  51. package/dist/redirects/legitimacy.d.ts.map +1 -0
  52. package/dist/redirects/legitimacy.js +64 -0
  53. package/dist/redirects/legitimacy.js.map +1 -0
  54. package/dist/redirects/recovery.d.ts +85 -0
  55. package/dist/redirects/recovery.d.ts.map +1 -0
  56. package/dist/redirects/recovery.js +176 -0
  57. package/dist/redirects/recovery.js.map +1 -0
  58. package/dist/redirects/rerank.d.ts +24 -0
  59. package/dist/redirects/rerank.d.ts.map +1 -0
  60. package/dist/redirects/rerank.js +82 -0
  61. package/dist/redirects/rerank.js.map +1 -0
  62. package/dist/redirects/suggest.d.ts +2 -0
  63. package/dist/redirects/suggest.d.ts.map +1 -1
  64. package/dist/redirects/suggest.js +25 -0
  65. package/dist/redirects/suggest.js.map +1 -1
  66. package/dist/redirects/typo.d.ts +10 -0
  67. package/dist/redirects/typo.d.ts.map +1 -0
  68. package/dist/redirects/typo.js +51 -0
  69. package/dist/redirects/typo.js.map +1 -0
  70. package/dist/redirects/validate-destination.d.ts +13 -0
  71. package/dist/redirects/validate-destination.d.ts.map +1 -0
  72. package/dist/redirects/validate-destination.js +51 -0
  73. package/dist/redirects/validate-destination.js.map +1 -0
  74. package/dist/sections/validate.d.ts +2 -0
  75. package/dist/sections/validate.d.ts.map +1 -1
  76. package/dist/sections/validate.js +23 -0
  77. package/dist/sections/validate.js.map +1 -1
  78. package/dist/seo/audit-engine.d.ts +4 -0
  79. package/dist/seo/audit-engine.d.ts.map +1 -1
  80. package/dist/seo/audit-engine.js +28 -3
  81. package/dist/seo/audit-engine.js.map +1 -1
  82. package/dist/seo/audit-runner.d.ts.map +1 -1
  83. package/dist/seo/audit-runner.js +29 -2
  84. package/dist/seo/audit-runner.js.map +1 -1
  85. package/dist/seo/config-store.d.ts +10 -0
  86. package/dist/seo/config-store.d.ts.map +1 -1
  87. package/dist/seo/config-store.js +14 -0
  88. package/dist/seo/config-store.js.map +1 -1
  89. package/dist/seo/index.d.ts +1 -1
  90. package/dist/seo/index.d.ts.map +1 -1
  91. package/dist/seo/index.js +1 -1
  92. package/dist/seo/index.js.map +1 -1
  93. package/dist/seo/issue-fix-ai.d.ts.map +1 -1
  94. package/dist/seo/issue-fix-ai.js +22 -13
  95. package/dist/seo/issue-fix-ai.js.map +1 -1
  96. package/dist/seo/issue-fix-redirect.d.ts +18 -0
  97. package/dist/seo/issue-fix-redirect.d.ts.map +1 -0
  98. package/dist/seo/issue-fix-redirect.js +152 -0
  99. package/dist/seo/issue-fix-redirect.js.map +1 -0
  100. package/dist/seo/issue-fix.d.ts +4 -0
  101. package/dist/seo/issue-fix.d.ts.map +1 -1
  102. package/dist/seo/issue-fix.js +51 -29
  103. package/dist/seo/issue-fix.js.map +1 -1
  104. package/dist/seo/link-graph.d.ts +46 -0
  105. package/dist/seo/link-graph.d.ts.map +1 -0
  106. package/dist/seo/link-graph.js +141 -0
  107. package/dist/seo/link-graph.js.map +1 -0
  108. package/dist/seo/migrate-meta.js +3 -2
  109. package/dist/seo/migrate-meta.js.map +1 -1
  110. package/dist/seo/page-meta.d.ts +5 -0
  111. package/dist/seo/page-meta.d.ts.map +1 -1
  112. package/dist/seo/page-meta.js +58 -66
  113. package/dist/seo/page-meta.js.map +1 -1
  114. package/dist/seo/robots.d.ts +22 -0
  115. package/dist/seo/robots.d.ts.map +1 -1
  116. package/dist/seo/robots.js +35 -0
  117. package/dist/seo/robots.js.map +1 -1
  118. package/dist/seo/title-templates.d.ts +28 -0
  119. package/dist/seo/title-templates.d.ts.map +1 -1
  120. package/dist/seo/title-templates.js +62 -2
  121. package/dist/seo/title-templates.js.map +1 -1
  122. package/dist/site.d.ts +7 -0
  123. package/dist/site.d.ts.map +1 -1
  124. package/dist/site.js +20 -0
  125. package/dist/site.js.map +1 -1
  126. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
9
9
  import { guardedDb } from '../guarded-db.js';
10
10
  import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
11
11
  import { detectAiBot } from '../../seo/ai-bots.js';
12
- import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, } from '../route-helpers.js';
12
+ import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, importAIPlugin, } from '../route-helpers.js';
13
13
  // Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
14
14
  // alphabetically (critical, info, warning) which wrongly ranks `info` above
15
15
  // `warning`. Unknown severities sort last.
@@ -91,37 +91,49 @@ export function registerSeoRoutes(router) {
91
91
  }
92
92
  const MAX_LINKS_PER_PAGE = 50;
93
93
  const MAX_TOTAL_LINKS = 500;
94
+ const DOC_BATCH_SIZE = 100;
94
95
  const PER_LINK_TIMEOUT_MS = 4000;
95
96
  const CONCURRENCY = 8;
96
- const docs = await db().document.findMany({
97
- where: { deletedAt: null, status: 'PUBLISHED' },
98
- select: { id: true, title: true, data: true, collection: true },
99
- });
100
97
  const urlRegex = /https?:\/\/[^\s"'<>]+/g;
101
98
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
102
99
  const queue = [];
103
100
  const seenGlobal = new Set();
104
- outer: for (const doc of docs) {
105
- const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
106
- const content = JSON.stringify(doc.data ?? {});
107
- const urls = content.match(urlRegex) ?? [];
108
- const seenInPage = new Set();
109
- let countInPage = 0;
110
- for (const url of urls) {
111
- const clean = url.replace(/[",;)}\]]+$/, '');
112
- if (seenInPage.has(clean))
113
- continue;
114
- seenInPage.add(clean);
115
- if (seenGlobal.has(clean))
116
- continue;
117
- seenGlobal.add(clean);
118
- if (countInPage >= MAX_LINKS_PER_PAGE)
119
- break;
120
- if (queue.length >= MAX_TOTAL_LINKS)
121
- break outer;
122
- const isInternal = !!siteUrl && clean.startsWith(siteUrl);
123
- queue.push({ docId: doc.id, pageTitle, clean, isInternal });
124
- countInPage++;
101
+ let docsScanned = 0;
102
+ let skip = 0;
103
+ scanLoop: while (queue.length < MAX_TOTAL_LINKS) {
104
+ const batch = await db().document.findMany({
105
+ where: { deletedAt: null, status: 'PUBLISHED' },
106
+ select: { id: true, title: true, data: true, collection: true },
107
+ orderBy: { updatedAt: 'desc' },
108
+ skip,
109
+ take: DOC_BATCH_SIZE,
110
+ });
111
+ if (batch.length === 0)
112
+ break;
113
+ docsScanned += batch.length;
114
+ skip += batch.length;
115
+ for (const doc of batch) {
116
+ const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
117
+ const content = JSON.stringify(doc.data ?? {});
118
+ const urls = content.match(urlRegex) ?? [];
119
+ const seenInPage = new Set();
120
+ let countInPage = 0;
121
+ for (const url of urls) {
122
+ const clean = url.replace(/[",;)}\]]+$/, '');
123
+ if (seenInPage.has(clean))
124
+ continue;
125
+ seenInPage.add(clean);
126
+ if (seenGlobal.has(clean))
127
+ continue;
128
+ seenGlobal.add(clean);
129
+ if (countInPage >= MAX_LINKS_PER_PAGE)
130
+ break;
131
+ if (queue.length >= MAX_TOTAL_LINKS)
132
+ break scanLoop;
133
+ const isInternal = !!siteUrl && clean.startsWith(siteUrl);
134
+ queue.push({ docId: doc.id, pageTitle, clean, isInternal });
135
+ countInPage++;
136
+ }
125
137
  }
126
138
  }
127
139
  const linkResults = [];
@@ -165,6 +177,7 @@ export function registerSeoRoutes(router) {
165
177
  return json({
166
178
  data: {
167
179
  truncated: queue.length >= MAX_TOTAL_LINKS,
180
+ docsScanned,
168
181
  checked: queue.length,
169
182
  issues: linkResults,
170
183
  },
@@ -174,6 +187,197 @@ export function registerSeoRoutes(router) {
174
187
  return internalError(err);
175
188
  }
176
189
  });
190
+ router.get('/seo/links/snapshot', async (request) => {
191
+ try {
192
+ const auth = await requireAuth(request);
193
+ if (auth.error)
194
+ return auth.error;
195
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
196
+ if (roleErr)
197
+ return roleErr;
198
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
199
+ const { buildLinkGraphSnapshot } = await import('../../seo/link-graph.js');
200
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
201
+ const cfg = getActuateConfig();
202
+ const collectionTypes = frontEndContentCollectionTypes(cfg);
203
+ const siteUrl = cfg?.seo?.siteUrl?.replace(/\/+$/, '') ?? '';
204
+ const entities = await gatherAuditEntities(db(), { collectionTypes });
205
+ const docRows = await db()
206
+ .document.findMany({
207
+ where: {
208
+ deletedAt: null,
209
+ status: 'PUBLISHED',
210
+ collection: { in: Object.keys(collectionTypes) },
211
+ },
212
+ select: { id: true, collection: true },
213
+ })
214
+ .catch(() => []);
215
+ const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
216
+ let brokenLinkCount = 0;
217
+ if (hasModel(db(), 'seoIssue')) {
218
+ brokenLinkCount = await db()
219
+ .seoIssue.count({
220
+ where: {
221
+ status: { in: ['open', 'recurring'] },
222
+ type: 'broken-internal-link',
223
+ },
224
+ })
225
+ .catch(() => 0);
226
+ }
227
+ const overrides = await getSeoOverrides(db()).catch(() => null);
228
+ const moduleSettings = resolveSeoModuleSettings(overrides);
229
+ const snapshot = buildLinkGraphSnapshot({
230
+ entities,
231
+ siteUrl,
232
+ collections,
233
+ brokenLinkCount,
234
+ aiIndexLastRunAt: moduleSettings.linkAiIndexLastRunAt,
235
+ });
236
+ return json({ data: snapshot });
237
+ }
238
+ catch (err) {
239
+ return internalError(err, 'seo/links/snapshot');
240
+ }
241
+ });
242
+ router.post('/seo/links/ai-index', async (request) => {
243
+ try {
244
+ const auth = await requireAuth(request);
245
+ if (auth.error)
246
+ return auth.error;
247
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
248
+ if (roleErr)
249
+ return roleErr;
250
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-ai-index:${auth.session.userId}`))) {
251
+ return errorResponse('AI link index rate limit reached. Try again later.', 429);
252
+ }
253
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
254
+ const { patchSeoModuleSettings } = await import('../../seo/config-store.js');
255
+ const cfg = getActuateConfig();
256
+ const collectionTypes = frontEndContentCollectionTypes(cfg);
257
+ const entities = await gatherAuditEntities(db(), { collectionTypes });
258
+ let aiAvailable = false;
259
+ try {
260
+ await importAIPlugin();
261
+ aiAvailable = true;
262
+ }
263
+ catch {
264
+ aiAvailable = false;
265
+ }
266
+ const now = new Date().toISOString();
267
+ await patchSeoModuleSettings(db(), { linkAiIndexLastRunAt: now }, auth.session.userId);
268
+ await logEvent({
269
+ event: 'seo_link_ai_index',
270
+ userId: auth.session.userId,
271
+ details: { entitiesIndexed: entities.length, aiAvailable },
272
+ });
273
+ return json({
274
+ data: {
275
+ indexedAt: now,
276
+ entitiesIndexed: entities.length,
277
+ aiAvailable,
278
+ message: aiAvailable
279
+ ? 'Content indexed for AI link suggestions. Run suggestions on orphaned or under-linked pages.'
280
+ : 'Link graph refreshed. Install @actuate-media/plugin-ai for semantic AI suggestions.',
281
+ },
282
+ });
283
+ }
284
+ catch (err) {
285
+ return internalError(err, 'seo/links/ai-index');
286
+ }
287
+ });
288
+ router.post('/seo/links/suggest', async (request) => {
289
+ try {
290
+ const auth = await requireAuth(request);
291
+ if (auth.error)
292
+ return auth.error;
293
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
294
+ if (roleErr)
295
+ return roleErr;
296
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-suggest:${auth.session.userId}`))) {
297
+ return errorResponse('Link suggestion rate limit reached. Try again later.', 429);
298
+ }
299
+ const body = (await request.json());
300
+ if (!body.targetEntityId || !body.targetEntityType) {
301
+ return errorResponse('targetEntityId and targetEntityType are required', 400);
302
+ }
303
+ const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
304
+ const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
305
+ const cfg = getActuateConfig();
306
+ const collectionTypes = frontEndContentCollectionTypes(cfg);
307
+ const entities = await gatherAuditEntities(db(), { collectionTypes });
308
+ const target = entities.find((e) => e.entityId === body.targetEntityId);
309
+ if (!target)
310
+ return errorResponse('Target document not found in link graph', 404);
311
+ const sources = entities.filter((e) => e.entityId !== target.entityId);
312
+ const query = [target.title, target.focusKeyword, target.metaDescription]
313
+ .filter(Boolean)
314
+ .join(' — ');
315
+ const docRows = await db()
316
+ .document.findMany({
317
+ where: { id: { in: sources.map((s) => s.entityId) } },
318
+ select: { id: true, collection: true },
319
+ })
320
+ .catch(() => []);
321
+ const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
322
+ let suggestions = [];
323
+ try {
324
+ const aiModule = await importAIPlugin();
325
+ if (typeof aiModule?.semanticSearch === 'function') {
326
+ const corpus = sources.map((s) => ({
327
+ id: s.entityId,
328
+ collectionSlug: collections[s.entityId] ?? 'pages',
329
+ title: s.title,
330
+ content: (s.content ?? '').slice(0, 8000),
331
+ }));
332
+ const results = await aiModule.semanticSearch(query, { limit, threshold: 0.35, corpus });
333
+ suggestions = results.map((r) => ({
334
+ sourceEntityId: r.id,
335
+ sourceTitle: r.title,
336
+ sourceUrl: sources.find((s) => s.entityId === r.id)?.url ?? '',
337
+ anchorText: target.title,
338
+ confidence: Math.round(r.score * 100) / 100,
339
+ excerpt: r.excerpt,
340
+ }));
341
+ }
342
+ else if (typeof aiModule?.suggestInternalLinks === 'function') {
343
+ for (const source of sources.slice(0, 40)) {
344
+ const found = await aiModule.suggestInternalLinks(source.content ?? '', [
345
+ {
346
+ id: target.entityId,
347
+ title: target.title,
348
+ url: target.url,
349
+ keywords: target.focusKeyword ? [target.focusKeyword] : [],
350
+ },
351
+ ]);
352
+ for (const f of found.slice(0, 2)) {
353
+ suggestions.push({
354
+ sourceEntityId: source.entityId,
355
+ sourceTitle: source.title,
356
+ sourceUrl: source.url,
357
+ anchorText: f.anchorText,
358
+ confidence: f.confidence,
359
+ excerpt: (source.content ?? '').slice(f.position.start, f.position.end + 40),
360
+ });
361
+ }
362
+ }
363
+ suggestions.sort((a, b) => b.confidence - a.confidence);
364
+ suggestions = suggestions.slice(0, limit);
365
+ }
366
+ }
367
+ catch {
368
+ suggestions = [];
369
+ }
370
+ return json({
371
+ data: {
372
+ target: { id: target.entityId, title: target.title, url: target.url },
373
+ suggestions,
374
+ },
375
+ });
376
+ }
377
+ catch (err) {
378
+ return internalError(err, 'seo/links/suggest');
379
+ }
380
+ });
177
381
  router.post('/seo/scan', async (request) => {
178
382
  try {
179
383
  const auth = await requireAuth(request);
@@ -233,8 +437,11 @@ export function registerSeoRoutes(router) {
233
437
  }
234
438
  async function buildSeoContentRecords() {
235
439
  const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
440
+ const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
236
441
  const { calculatePageSeoScore } = await import('../../seo/score.js');
237
442
  const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
443
+ const overrides = await getSeoOverrides(db()).catch(() => null);
444
+ const siteSeo = applySeoOverrides(getActuateConfig(), overrides)?.seo;
238
445
  const entities = await gatherAuditEntities(db(), { collectionTypes });
239
446
  return entities.map((e) => {
240
447
  const score = calculatePageSeoScore(e);
@@ -245,7 +452,12 @@ export function registerSeoRoutes(router) {
245
452
  title: e.title,
246
453
  url: e.url,
247
454
  canonicalUrl: e.canonicalUrl ?? null,
455
+ /** Rendered public title (after site title template). */
248
456
  metaTitle: e.metaTitle ?? null,
457
+ /** Stored page-specific portion (`%page%` token). */
458
+ metaTitlePage: e.metaTitlePage ?? null,
459
+ siteName: siteSeo?.siteName ?? null,
460
+ titleTemplate: siteSeo?.titleTemplate ?? null,
249
461
  metaDescription: e.metaDescription ?? null,
250
462
  focusKeyword: e.focusKeyword ?? null,
251
463
  keyTakeaway: e.keyTakeaway ?? null,
@@ -983,18 +1195,29 @@ export function registerSeoRoutes(router) {
983
1195
  entity: loaded.entity,
984
1196
  siteSeo: loaded.siteSeo,
985
1197
  updatedAt: loaded.updatedAt,
1198
+ metadata: asRecord(issue.metadata),
986
1199
  };
987
1200
  let aiDeps;
988
- const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
989
- if (!adminErr &&
990
- hasPlanFeature('seo.inlineApply') &&
991
- (await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
1201
+ const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
1202
+ if (REDIRECT_FIX_TYPES.has(issue.type)) {
992
1203
  aiDeps = {
993
1204
  db: db(),
994
1205
  userId: auth.session.userId,
995
1206
  collection: loaded.collection,
996
1207
  };
997
1208
  }
1209
+ else {
1210
+ const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
1211
+ if (!adminErr &&
1212
+ hasPlanFeature('seo.inlineApply') &&
1213
+ (await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
1214
+ aiDeps = {
1215
+ db: db(),
1216
+ userId: auth.session.userId,
1217
+ collection: loaded.collection,
1218
+ };
1219
+ }
1220
+ }
998
1221
  const { suggestIssueFix } = await import('../../seo/issue-fix.js');
999
1222
  const { pendingFixToMetadata } = await import('../../seo/issue-fix-ai.js');
1000
1223
  const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
@@ -1043,7 +1266,79 @@ export function registerSeoRoutes(router) {
1043
1266
  entity: loaded.entity,
1044
1267
  siteSeo: loaded.siteSeo,
1045
1268
  updatedAt: loaded.updatedAt,
1269
+ metadata: asRecord(issue.metadata),
1046
1270
  };
1271
+ const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
1272
+ if (REDIRECT_FIX_TYPES.has(issue.type)) {
1273
+ const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1274
+ const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
1275
+ const cached = pendingFixFromMetadata(issue.metadata);
1276
+ const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1277
+ if (!cached?.autoFixable) {
1278
+ return errorResponse('This issue does not support an automatic fix.', 422);
1279
+ }
1280
+ if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
1281
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1282
+ }
1283
+ if (cached.fingerprint !== liveFingerprint) {
1284
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1285
+ }
1286
+ const fixStrategy = cached.patch.fixStrategy === 'fix-link' ? 'fix-link' : 'redirect';
1287
+ try {
1288
+ if (fixStrategy === 'fix-link') {
1289
+ const ctx = buildActionContext(auth.session, db());
1290
+ await applyBrokenLinkContentFix(loaded.collection, loaded.entity.entityId, cached.patch, ctx);
1291
+ }
1292
+ else {
1293
+ await applyBrokenLinkRedirectFix(db(), cached.patch, auth.session.userId);
1294
+ }
1295
+ }
1296
+ catch (err) {
1297
+ const msg = err instanceof Error ? err.message : 'Fix application failed';
1298
+ return errorResponse(msg, 400);
1299
+ }
1300
+ const existingMeta = asRecord(issue.metadata);
1301
+ const resolved = await db().seoIssue.update({
1302
+ where: { id: issue.id },
1303
+ data: {
1304
+ status: 'resolved',
1305
+ resolvedAt: new Date(),
1306
+ resolvedById: auth.session.userId,
1307
+ metadata: { ...existingMeta, pendingFix: null },
1308
+ },
1309
+ });
1310
+ try {
1311
+ await logEvent({
1312
+ event: 'document_updated',
1313
+ userId: auth.session.userId,
1314
+ details: {
1315
+ action: fixStrategy === 'fix-link'
1316
+ ? 'seo_issue_link_fix_applied'
1317
+ : 'seo_issue_redirect_applied',
1318
+ issueId: issue.id,
1319
+ issueType: issue.type,
1320
+ ...(fixStrategy === 'fix-link'
1321
+ ? {
1322
+ fixLinkFrom: cached.patch.fixLinkFrom,
1323
+ fixLinkTo: cached.patch.fixLinkTo,
1324
+ }
1325
+ : {
1326
+ redirectFrom: cached.patch.redirectFrom,
1327
+ redirectTo: cached.patch.redirectTo,
1328
+ }),
1329
+ },
1330
+ });
1331
+ }
1332
+ catch {
1333
+ /* noop */
1334
+ }
1335
+ return json({
1336
+ data: {
1337
+ issue: seoIssueToApi(resolved),
1338
+ applied: cached.changes,
1339
+ },
1340
+ });
1341
+ }
1047
1342
  const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
1048
1343
  const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1049
1344
  const deterministic = suggestDeterministicFix(issueInput, fixCtx);