@prenta/core 4.1.0 → 4.2.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 (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/api/routes/seo.d.ts.map +1 -1
  3. package/dist/api/routes/seo.js +330 -108
  4. package/dist/api/routes/seo.js.map +1 -1
  5. package/dist/content/slug.d.ts +3 -3
  6. package/dist/content/slug.d.ts.map +1 -1
  7. package/dist/content/slug.js +13 -8
  8. package/dist/content/slug.js.map +1 -1
  9. package/dist/seo/audit-engine.d.ts +7 -0
  10. package/dist/seo/audit-engine.d.ts.map +1 -1
  11. package/dist/seo/audit-engine.js +24 -0
  12. package/dist/seo/audit-engine.js.map +1 -1
  13. package/dist/seo/audit-runner.d.ts.map +1 -1
  14. package/dist/seo/audit-runner.js +14 -0
  15. package/dist/seo/audit-runner.js.map +1 -1
  16. package/dist/seo/index.d.ts +7 -0
  17. package/dist/seo/index.d.ts.map +1 -1
  18. package/dist/seo/index.js +4 -0
  19. package/dist/seo/index.js.map +1 -1
  20. package/dist/seo/issue-fix-link-insert.d.ts +105 -0
  21. package/dist/seo/issue-fix-link-insert.d.ts.map +1 -0
  22. package/dist/seo/issue-fix-link-insert.js +461 -0
  23. package/dist/seo/issue-fix-link-insert.js.map +1 -0
  24. package/dist/seo/issue-fix-verify.d.ts +31 -0
  25. package/dist/seo/issue-fix-verify.d.ts.map +1 -0
  26. package/dist/seo/issue-fix-verify.js +76 -0
  27. package/dist/seo/issue-fix-verify.js.map +1 -0
  28. package/dist/seo/issue-fix.d.ts +2 -2
  29. package/dist/seo/issue-fix.d.ts.map +1 -1
  30. package/dist/seo/issue-fix.js +9 -0
  31. package/dist/seo/issue-fix.js.map +1 -1
  32. package/dist/seo/link-suggest.d.ts +49 -0
  33. package/dist/seo/link-suggest.d.ts.map +1 -0
  34. package/dist/seo/link-suggest.js +147 -0
  35. package/dist/seo/link-suggest.js.map +1 -0
  36. package/dist/seo/nav-link-targets.d.ts +9 -0
  37. package/dist/seo/nav-link-targets.d.ts.map +1 -0
  38. package/dist/seo/nav-link-targets.js +47 -0
  39. package/dist/seo/nav-link-targets.js.map +1 -0
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @actuate-media/cms-core
2
2
 
3
+ ## 4.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2ff1c74: Approve-gated internal link insert and orphan-page execute, with verify-before-resolve.
8
+
9
+ ## 4.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - b86fa37: Accept slash-separated nested slugs (help/faq) and label the published-page save as Update live.
14
+
3
15
  ## 4.1.0
4
16
 
5
17
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../../../src/api/routes/seo.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AA4G7C,+EAA+E;AAC/E,wBAAgB,oBAAoB,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,EAAE,GACV,CAAC,EAAE,CAQL;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAi8EzD"}
1
+ {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../../../src/api/routes/seo.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAgG7C,+EAA+E;AAC/E,wBAAgB,oBAAoB,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,cAAc,CAAC,EAAE,OAAO,CAAA;CAAE,EAC7F,MAAM,EAAE,CAAC,EAAE,GACV,CAAC,EAAE,CAQL;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAquFzD"}
@@ -4,7 +4,7 @@ import { DEFAULT_DIRECTIVES, coerceMaxImagePreview } from '../../seo/robots-dire
4
4
  import { processSeoScan } from '../../cron/index.js';
5
5
  import { isResolvedIp } from '../../security/client-ip.js';
6
6
  import { classifyLinkHealthIssue, extractOutboundUrlsFromDocumentData, probeOutboundLink, } from '../../seo/link-probe.js';
7
- import { resolveTrailingSlashPolicy, trailingSlashMismatchTarget } from '../../seo/document-path.js';
7
+ import { documentPublicPath, resolveTrailingSlashPolicy, trailingSlashMismatchTarget, } from '../../seo/document-path.js';
8
8
  import { getPrentaConfig } from '../../config/runtime.js';
9
9
  import { assertPlanFeature, hasPlanFeature } from '../../plan/features.js';
10
10
  import { extraImagesForSeoAnalysis, firstSectionHeading } from '../../seo/document-content.js';
@@ -13,6 +13,8 @@ import { guardedDb } from '../guarded-db.js';
13
13
  import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
14
14
  import { detectAiBot } from '../../seo/ai-bots.js';
15
15
  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';
16
+ import { gatherNavigationLinkTargets } from '../../seo/nav-link-targets.js';
17
+ import { suggestInboundLinks } from '../../seo/link-suggest.js';
16
18
  // Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
17
19
  // alphabetically (critical, info, warning) which wrongly ranks `info` above
18
20
  // `warning`. Unknown severities sort last.
@@ -20,43 +22,6 @@ const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
20
22
  function severityRank(severity) {
21
23
  return SEVERITY_RANK[String(severity)] ?? 99;
22
24
  }
23
- /**
24
- * Collect internal link targets from global navigation menus (the `navigations`
25
- * collection) so the link-graph can treat nav-linked pages as non-orphaned.
26
- * Best-effort: returns `[]` when the navigation plugin isn't installed or on any
27
- * read error.
28
- */
29
- async function gatherNavigationLinkTargets(db) {
30
- try {
31
- if (!hasModel(db, 'document'))
32
- return [];
33
- const { coercePublicNavItems } = await import('../../site.js');
34
- const navDocs = await db.document
35
- .findMany({
36
- where: { collection: 'navigations', deletedAt: null, status: { not: 'ARCHIVED' } },
37
- select: { data: true },
38
- take: 50,
39
- })
40
- .catch(() => []);
41
- const targets = [];
42
- const walk = (items) => {
43
- for (const item of items) {
44
- if (typeof item.url === 'string' && item.url.trim())
45
- targets.push(item.url);
46
- if (item.children)
47
- walk(item.children);
48
- }
49
- };
50
- for (const doc of navDocs) {
51
- const data = doc?.data && typeof doc.data === 'object' ? doc.data : {};
52
- walk(coercePublicNavItems(data.items));
53
- }
54
- return targets;
55
- }
56
- catch {
57
- return [];
58
- }
59
- }
60
25
  function planRequiredResponse(denial) {
61
26
  return json({
62
27
  error: denial.message,
@@ -65,6 +30,23 @@ function planRequiredResponse(denial) {
65
30
  feature: denial.feature,
66
31
  }, 403);
67
32
  }
33
+ function mapInternalLinkInsertError(err) {
34
+ const msg = err instanceof Error ? err.message : '';
35
+ switch (msg) {
36
+ case 'SOURCE_NOT_FOUND':
37
+ return errorResponse('Source document not found', 404);
38
+ case 'TARGET_NOT_FOUND':
39
+ return errorResponse('Target document not found', 404);
40
+ case 'NOT_WRAPPABLE':
41
+ return errorResponse('Anchor is not wrappable in the live source document.', 422);
42
+ case 'ALREADY_LINKED':
43
+ return errorResponse('Source already links to this target.', 422);
44
+ case 'TARGET_PATH_MISMATCH':
45
+ return errorResponse('Target path does not match the live document.', 422);
46
+ default:
47
+ return null;
48
+ }
49
+ }
68
50
  function seoAiGenerationError(result) {
69
51
  if (result.code === 'unavailable' || result.code === 'feature_disabled') {
70
52
  return errorResponse(result.message, 503);
@@ -352,76 +334,25 @@ export function registerSeoRoutes(router) {
352
334
  return errorResponse('targetEntityId and targetEntityType are required', 400);
353
335
  }
354
336
  const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
355
- const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
356
- const cfg = getPrentaConfig();
357
- const collectionTypes = frontEndContentCollectionTypes(cfg);
358
- const entities = await gatherAuditEntities(db(), { collectionTypes });
359
- const target = entities.find((e) => e.entityId === body.targetEntityId);
360
- if (!target)
361
- return errorResponse('Target document not found in link graph', 404);
362
- const sources = entities.filter((e) => e.entityId !== target.entityId);
363
- const query = [target.title, target.focusKeyword, target.metaDescription]
364
- .filter(Boolean)
365
- .join(' — ');
366
- const docRows = await db()
367
- .document.findMany({
368
- where: { id: { in: sources.map((s) => s.entityId) } },
369
- select: { id: true, collection: true },
370
- })
371
- .catch(() => []);
372
- const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
373
- let suggestions = [];
337
+ let result;
374
338
  try {
375
- const aiModule = await importAIPlugin();
376
- if (typeof aiModule?.semanticSearch === 'function') {
377
- const corpus = sources.map((s) => ({
378
- id: s.entityId,
379
- collectionSlug: collections[s.entityId] ?? 'pages',
380
- title: s.title,
381
- content: (s.content ?? '').slice(0, 8000),
382
- }));
383
- const results = await aiModule.semanticSearch(query, { limit, threshold: 0.35, corpus });
384
- suggestions = results.map((r) => ({
385
- sourceEntityId: r.id,
386
- sourceTitle: r.title,
387
- sourceUrl: sources.find((s) => s.entityId === r.id)?.url ?? '',
388
- anchorText: target.title,
389
- confidence: Math.round(r.score * 100) / 100,
390
- excerpt: r.excerpt,
391
- }));
392
- }
393
- else if (typeof aiModule?.suggestInternalLinks === 'function') {
394
- for (const source of sources.slice(0, 40)) {
395
- const found = await aiModule.suggestInternalLinks(source.content ?? '', [
396
- {
397
- id: target.entityId,
398
- title: target.title,
399
- url: target.url,
400
- keywords: target.focusKeyword ? [target.focusKeyword] : [],
401
- },
402
- ]);
403
- for (const f of found.slice(0, 2)) {
404
- suggestions.push({
405
- sourceEntityId: source.entityId,
406
- sourceTitle: source.title,
407
- sourceUrl: source.url,
408
- anchorText: f.anchorText,
409
- confidence: f.confidence,
410
- excerpt: (source.content ?? '').slice(f.position.start, f.position.end + 40),
411
- });
412
- }
413
- }
414
- suggestions.sort((a, b) => b.confidence - a.confidence);
415
- suggestions = suggestions.slice(0, limit);
416
- }
339
+ result = await suggestInboundLinks({
340
+ db: db(),
341
+ targetEntityId: body.targetEntityId,
342
+ targetEntityType: body.targetEntityType,
343
+ limit,
344
+ });
417
345
  }
418
- catch {
419
- suggestions = [];
346
+ catch (err) {
347
+ if (err instanceof Error && err.message === 'TARGET_NOT_FOUND') {
348
+ return errorResponse('Target document not found in link graph', 404);
349
+ }
350
+ throw err;
420
351
  }
421
352
  return json({
422
353
  data: {
423
- target: { id: target.entityId, title: target.title, url: target.url },
424
- suggestions,
354
+ target: result.target,
355
+ suggestions: result.suggestions,
425
356
  },
426
357
  });
427
358
  }
@@ -429,6 +360,124 @@ export function registerSeoRoutes(router) {
429
360
  return internalError(err, 'seo/links/suggest');
430
361
  }
431
362
  });
363
+ router.post('/seo/links/apply', async (request) => {
364
+ try {
365
+ const auth = await requireAuth(request);
366
+ if (auth.error)
367
+ return auth.error;
368
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
369
+ if (roleErr)
370
+ return roleErr;
371
+ const inlinePlanErr = assertPlanFeature('seo.inlineApply');
372
+ if (inlinePlanErr)
373
+ return planRequiredResponse(inlinePlanErr);
374
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-apply:${auth.session.userId}`))) {
375
+ return errorResponse('Link apply rate limit reached. Try again later.', 429);
376
+ }
377
+ const body = (await request.json().catch(() => ({})));
378
+ const sourceEntityId = typeof body?.sourceEntityId === 'string' ? body.sourceEntityId.trim() : '';
379
+ const targetEntityId = typeof body?.targetEntityId === 'string' ? body.targetEntityId.trim() : '';
380
+ const anchorText = typeof body?.anchorText === 'string' ? body.anchorText : '';
381
+ const fingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : '';
382
+ if (!sourceEntityId || !targetEntityId || !anchorText.trim() || !fingerprint) {
383
+ return errorResponse('sourceEntityId, targetEntityId, anchorText, and fingerprint are required', 400);
384
+ }
385
+ const source = await db().document.findFirst({
386
+ where: { id: sourceEntityId, deletedAt: null },
387
+ select: {
388
+ id: true,
389
+ collection: true,
390
+ updatedAt: true,
391
+ data: true,
392
+ slug: true,
393
+ status: true,
394
+ },
395
+ });
396
+ if (!source)
397
+ return errorResponse('Source document not found', 404);
398
+ const target = await db().document.findFirst({
399
+ where: { id: targetEntityId, deletedAt: null },
400
+ select: { id: true, collection: true, data: true, slug: true, status: true },
401
+ });
402
+ if (!target)
403
+ return errorResponse('Target document not found', 404);
404
+ const targetData = asRecord(target.data);
405
+ const targetSlug = (typeof targetData.slug === 'string' && targetData.slug) ||
406
+ (typeof target.slug === 'string' && target.slug) ||
407
+ target.id;
408
+ const collectionDef = getPrentaConfig()?.collections?.[target.collection];
409
+ const targetPath = documentPublicPath({
410
+ urlPrefix: collectionDef?.urlPrefix,
411
+ slug: targetSlug,
412
+ data: targetData,
413
+ });
414
+ const { applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint } = await import('../../seo/issue-fix-link-insert.js');
415
+ const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
416
+ if (fingerprint !== liveFingerprint) {
417
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
418
+ }
419
+ const ctx = buildActionContext(auth.session, db());
420
+ let verified;
421
+ try {
422
+ const result = await applyAndVerifyInternalLinkInsert({
423
+ db: db(),
424
+ ctx,
425
+ sourceEntityId,
426
+ targetEntityId,
427
+ anchorText,
428
+ targetPath,
429
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? '',
430
+ navLinks: await gatherNavigationLinkTargets(db()),
431
+ });
432
+ verified = result.verify;
433
+ }
434
+ catch (err) {
435
+ const mapped = mapInternalLinkInsertError(err);
436
+ if (mapped)
437
+ return mapped;
438
+ throw err;
439
+ }
440
+ if (!verified.ok) {
441
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
442
+ }
443
+ if (hasModel(db(), 'seoIssue')) {
444
+ await db().seoIssue.updateMany({
445
+ where: {
446
+ type: 'orphan-page',
447
+ entityId: targetEntityId,
448
+ status: { in: ['open', 'recurring'] },
449
+ },
450
+ data: {
451
+ status: 'resolved',
452
+ resolvedAt: new Date(),
453
+ resolvedById: auth.session.userId,
454
+ },
455
+ });
456
+ }
457
+ try {
458
+ await logEvent({
459
+ event: 'document_updated',
460
+ userId: auth.session.userId,
461
+ details: {
462
+ action: 'seo_internal_link_applied',
463
+ seoFix: 'insert-link',
464
+ sourceEntityId,
465
+ targetEntityId,
466
+ targetPath,
467
+ },
468
+ });
469
+ }
470
+ catch {
471
+ /* noop */
472
+ }
473
+ return json({
474
+ data: { sourceEntityId, targetEntityId, targetPath, applied: true },
475
+ });
476
+ }
477
+ catch (err) {
478
+ return internalError(err, 'seo/links/apply');
479
+ }
480
+ });
432
481
  router.post('/seo/scan', async (request) => {
433
482
  try {
434
483
  const auth = await requireAuth(request);
@@ -1295,7 +1344,8 @@ export function registerSeoRoutes(router) {
1295
1344
  };
1296
1345
  let aiDeps;
1297
1346
  const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
1298
- if (REDIRECT_FIX_TYPES.has(issue.type)) {
1347
+ const { INSERT_LINK_FIX_TYPES } = await import('../../seo/issue-fix-link-insert.js');
1348
+ if (REDIRECT_FIX_TYPES.has(issue.type) || INSERT_LINK_FIX_TYPES.has(issue.type)) {
1299
1349
  aiDeps = {
1300
1350
  db: db(),
1301
1351
  userId: auth.session.userId,
@@ -1365,10 +1415,114 @@ export function registerSeoRoutes(router) {
1365
1415
  metadata: asRecord(issue.metadata),
1366
1416
  };
1367
1417
  const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
1418
+ const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1419
+ const { verifySeoIssueFix } = await import('../../seo/issue-fix-verify.js');
1420
+ const { INSERT_LINK_FIX_TYPES, applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint, } = await import('../../seo/issue-fix-link-insert.js');
1421
+ const cachedPending = pendingFixFromMetadata(issue.metadata);
1422
+ const isInsertLinkFix = INSERT_LINK_FIX_TYPES.has(issue.type) || cachedPending?.patch?.fixStrategy === 'insert-link';
1423
+ if (isInsertLinkFix) {
1424
+ const inlinePlanErr = assertPlanFeature('seo.inlineApply');
1425
+ if (inlinePlanErr)
1426
+ return planRequiredResponse(inlinePlanErr);
1427
+ if (!cachedPending?.autoFixable ||
1428
+ (cachedPending.patch.fixStrategy !== 'insert-link' &&
1429
+ !INSERT_LINK_FIX_TYPES.has(issue.type))) {
1430
+ return errorResponse('This issue does not support an automatic fix.', 422);
1431
+ }
1432
+ if (INSERT_LINK_FIX_TYPES.has(issue.type) &&
1433
+ cachedPending.patch.fixStrategy &&
1434
+ cachedPending.patch.fixStrategy !== 'insert-link') {
1435
+ return errorResponse('This issue does not support an automatic fix.', 422);
1436
+ }
1437
+ const sourceEntityId = typeof cachedPending.patch.sourceEntityId === 'string'
1438
+ ? cachedPending.patch.sourceEntityId
1439
+ : '';
1440
+ const targetEntityId = typeof cachedPending.patch.targetEntityId === 'string'
1441
+ ? cachedPending.patch.targetEntityId
1442
+ : (issue.entityId ?? '');
1443
+ const targetPath = typeof cachedPending.patch.targetPath === 'string'
1444
+ ? cachedPending.patch.targetPath
1445
+ : loaded.entity.url;
1446
+ const anchorText = typeof cachedPending.patch.anchorText === 'string' ? cachedPending.patch.anchorText : '';
1447
+ if (!sourceEntityId || !targetEntityId || !anchorText) {
1448
+ return errorResponse('This issue does not support an automatic fix.', 422);
1449
+ }
1450
+ if (body?.fingerprint && body.fingerprint !== cachedPending.fingerprint) {
1451
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1452
+ }
1453
+ const source = await db().document.findFirst({
1454
+ where: { id: sourceEntityId, deletedAt: null },
1455
+ select: { id: true, updatedAt: true },
1456
+ });
1457
+ if (!source)
1458
+ return errorResponse('Source document not found', 404);
1459
+ const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
1460
+ if (cachedPending.fingerprint !== liveFingerprint) {
1461
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1462
+ }
1463
+ const ctx = buildActionContext(auth.session, db());
1464
+ let verified;
1465
+ try {
1466
+ const result = await applyAndVerifyInternalLinkInsert({
1467
+ db: db(),
1468
+ ctx,
1469
+ sourceEntityId,
1470
+ targetEntityId,
1471
+ anchorText,
1472
+ targetPath,
1473
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1474
+ navLinks: await gatherNavigationLinkTargets(db()),
1475
+ });
1476
+ verified = result.verify;
1477
+ }
1478
+ catch (err) {
1479
+ const mapped = mapInternalLinkInsertError(err);
1480
+ if (mapped)
1481
+ return mapped;
1482
+ throw err;
1483
+ }
1484
+ if (!verified.ok) {
1485
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1486
+ }
1487
+ const existingMeta = asRecord(issue.metadata);
1488
+ const resolved = await db().seoIssue.update({
1489
+ where: { id: issue.id },
1490
+ data: {
1491
+ status: 'resolved',
1492
+ resolvedAt: new Date(),
1493
+ resolvedById: auth.session.userId,
1494
+ relatedEntityIds: [sourceEntityId],
1495
+ metadata: { ...existingMeta, pendingFix: null },
1496
+ },
1497
+ });
1498
+ try {
1499
+ await logEvent({
1500
+ event: 'document_updated',
1501
+ userId: auth.session.userId,
1502
+ details: {
1503
+ action: 'seo_issue_fix_applied',
1504
+ seoFix: 'insert-link',
1505
+ issueId: issue.id,
1506
+ issueType: issue.type,
1507
+ sourceEntityId,
1508
+ targetEntityId,
1509
+ targetPath,
1510
+ },
1511
+ });
1512
+ }
1513
+ catch {
1514
+ /* noop */
1515
+ }
1516
+ return json({
1517
+ data: {
1518
+ issue: seoIssueToApi(resolved),
1519
+ applied: cachedPending.changes,
1520
+ },
1521
+ });
1522
+ }
1368
1523
  if (REDIRECT_FIX_TYPES.has(issue.type)) {
1369
- const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1370
1524
  const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
1371
- const cached = pendingFixFromMetadata(issue.metadata);
1525
+ const cached = cachedPending;
1372
1526
  const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1373
1527
  if (!cached?.autoFixable) {
1374
1528
  return errorResponse('This issue does not support an automatic fix.', 422);
@@ -1393,6 +1547,31 @@ export function registerSeoRoutes(router) {
1393
1547
  const msg = err instanceof Error ? err.message : 'Fix application failed';
1394
1548
  return errorResponse(msg, 400);
1395
1549
  }
1550
+ const afterDoc = await db().document.findFirst({
1551
+ where: { id: loaded.entity.entityId, deletedAt: null },
1552
+ select: { data: true },
1553
+ });
1554
+ const redirectSuggestion = {
1555
+ autoFixable: true,
1556
+ fingerprint: cached.fingerprint,
1557
+ fixStrategy: fixStrategy,
1558
+ patch: cached.patch,
1559
+ changes: cached.changes,
1560
+ justification: cached.justification,
1561
+ source: cached.source,
1562
+ };
1563
+ const verified = await verifySeoIssueFix({
1564
+ issueType: issue.type,
1565
+ suggestion: redirectSuggestion,
1566
+ entityId: loaded.entity.entityId,
1567
+ live: { data: asRecord(afterDoc?.data) },
1568
+ db: db(),
1569
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1570
+ navLinks: await gatherNavigationLinkTargets(db()),
1571
+ });
1572
+ if (!verified.ok) {
1573
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1574
+ }
1396
1575
  const existingMeta = asRecord(issue.metadata);
1397
1576
  const resolved = await db().seoIssue.update({
1398
1577
  where: { id: issue.id },
@@ -1436,14 +1615,13 @@ export function registerSeoRoutes(router) {
1436
1615
  });
1437
1616
  }
1438
1617
  const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
1439
- const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1440
1618
  const deterministic = suggestDeterministicFix(issueInput, fixCtx);
1441
1619
  let suggestion = deterministic;
1442
1620
  if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
1443
1621
  const inlinePlanErr = assertPlanFeature('seo.inlineApply');
1444
1622
  if (inlinePlanErr)
1445
1623
  return planRequiredResponse(inlinePlanErr);
1446
- const cached = pendingFixFromMetadata(issue.metadata);
1624
+ const cached = cachedPending;
1447
1625
  const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1448
1626
  if (!cached || !cached.autoFixable) {
1449
1627
  return errorResponse('This issue does not support an automatic fix.', 422);
@@ -1481,6 +1659,50 @@ export function registerSeoRoutes(router) {
1481
1659
  return errorResponse('Document not found', 404);
1482
1660
  throw err;
1483
1661
  }
1662
+ const afterDoc = await db().document.findFirst({
1663
+ where: { id: loaded.entity.entityId, deletedAt: null },
1664
+ select: {
1665
+ id: true,
1666
+ collection: true,
1667
+ title: true,
1668
+ slug: true,
1669
+ data: true,
1670
+ structuredData: true,
1671
+ plainText: true,
1672
+ publishedAt: true,
1673
+ status: true,
1674
+ },
1675
+ });
1676
+ const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
1677
+ const liveEntity = afterDoc
1678
+ ? documentToAuditEntity(afterDoc, frontEndContentCollectionTypes(getPrentaConfig()), loaded.siteSeo ?? getPrentaConfig()?.seo)
1679
+ : loaded.entity;
1680
+ // Prefer the rendered/effective meta title so verify matches changes.after
1681
+ // (page-title portion in the patch ≠ rendered title when a template applies).
1682
+ const metaTitleLive = suggestion.changes.some((c) => c.field === 'metaTitle')
1683
+ ? (liveEntity.metaTitle ??
1684
+ (typeof suggestion.patch.metaTitle === 'string' ? suggestion.patch.metaTitle : null))
1685
+ : (liveEntity.metaTitle ?? null);
1686
+ const verified = await verifySeoIssueFix({
1687
+ issueType: issue.type,
1688
+ suggestion,
1689
+ entityId: loaded.entity.entityId,
1690
+ live: {
1691
+ data: asRecord(afterDoc?.data),
1692
+ metaTitle: metaTitleLive,
1693
+ metaDescription: liveEntity.metaDescription ?? null,
1694
+ canonical: liveEntity.canonicalUrl ?? null,
1695
+ noindex: liveEntity.noindex ?? null,
1696
+ structuredDataType: liveEntity.structuredDataType ?? null,
1697
+ status: afterDoc ? String(afterDoc.status) : undefined,
1698
+ },
1699
+ db: db(),
1700
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1701
+ navLinks: await gatherNavigationLinkTargets(db()),
1702
+ });
1703
+ if (!verified.ok) {
1704
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1705
+ }
1484
1706
  const existingMeta = asRecord(issue.metadata);
1485
1707
  const resolved = await db().seoIssue.update({
1486
1708
  where: { id: issue.id },