@prenta/core 4.1.1 → 4.3.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 (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +4 -4
  4. package/dist/actions.js.map +1 -1
  5. package/dist/ai/brand-voice-analyze.d.ts.map +1 -1
  6. package/dist/ai/brand-voice-analyze.js +11 -11
  7. package/dist/ai/brand-voice-analyze.js.map +1 -1
  8. package/dist/api/routes/ai-authoring.d.ts.map +1 -1
  9. package/dist/api/routes/ai-authoring.js +24 -12
  10. package/dist/api/routes/ai-authoring.js.map +1 -1
  11. package/dist/api/routes/documents.d.ts.map +1 -1
  12. package/dist/api/routes/documents.js +2 -15
  13. package/dist/api/routes/documents.js.map +1 -1
  14. package/dist/api/routes/seo-public.d.ts +0 -8
  15. package/dist/api/routes/seo-public.d.ts.map +1 -1
  16. package/dist/api/routes/seo-public.js +67 -29
  17. package/dist/api/routes/seo-public.js.map +1 -1
  18. package/dist/api/routes/seo.d.ts.map +1 -1
  19. package/dist/api/routes/seo.js +383 -121
  20. package/dist/api/routes/seo.js.map +1 -1
  21. package/dist/media/usage-sync.d.ts.map +1 -1
  22. package/dist/media/usage-sync.js +15 -11
  23. package/dist/media/usage-sync.js.map +1 -1
  24. package/dist/redirects/recovery.d.ts +8 -2
  25. package/dist/redirects/recovery.d.ts.map +1 -1
  26. package/dist/redirects/recovery.js +43 -24
  27. package/dist/redirects/recovery.js.map +1 -1
  28. package/dist/seo/audit-engine.d.ts +12 -0
  29. package/dist/seo/audit-engine.d.ts.map +1 -1
  30. package/dist/seo/audit-engine.js +24 -0
  31. package/dist/seo/audit-engine.js.map +1 -1
  32. package/dist/seo/audit-runner.d.ts +12 -0
  33. package/dist/seo/audit-runner.d.ts.map +1 -1
  34. package/dist/seo/audit-runner.js +45 -10
  35. package/dist/seo/audit-runner.js.map +1 -1
  36. package/dist/seo/index.d.ts +8 -1
  37. package/dist/seo/index.d.ts.map +1 -1
  38. package/dist/seo/index.js +5 -1
  39. package/dist/seo/index.js.map +1 -1
  40. package/dist/seo/issue-fix-link-insert.d.ts +105 -0
  41. package/dist/seo/issue-fix-link-insert.d.ts.map +1 -0
  42. package/dist/seo/issue-fix-link-insert.js +461 -0
  43. package/dist/seo/issue-fix-link-insert.js.map +1 -0
  44. package/dist/seo/issue-fix-verify.d.ts +31 -0
  45. package/dist/seo/issue-fix-verify.d.ts.map +1 -0
  46. package/dist/seo/issue-fix-verify.js +76 -0
  47. package/dist/seo/issue-fix-verify.js.map +1 -0
  48. package/dist/seo/issue-fix.d.ts +2 -2
  49. package/dist/seo/issue-fix.d.ts.map +1 -1
  50. package/dist/seo/issue-fix.js +9 -0
  51. package/dist/seo/issue-fix.js.map +1 -1
  52. package/dist/seo/link-suggest.d.ts +49 -0
  53. package/dist/seo/link-suggest.d.ts.map +1 -0
  54. package/dist/seo/link-suggest.js +149 -0
  55. package/dist/seo/link-suggest.js.map +1 -0
  56. package/dist/seo/nav-link-targets.d.ts +9 -0
  57. package/dist/seo/nav-link-targets.d.ts.map +1 -0
  58. package/dist/seo/nav-link-targets.js +47 -0
  59. package/dist/seo/nav-link-targets.js.map +1 -0
  60. package/package.json +4 -5
@@ -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';
@@ -12,7 +12,9 @@ import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
12
12
  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
- 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';
15
+ import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, importAIPlugin, clampPage, clampPageSize, } 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);
@@ -510,8 +559,8 @@ export function registerSeoRoutes(router) {
510
559
  * the per-document GET, which must answer for a saved draft — the list
511
560
  * endpoints deliberately stay published-only.
512
561
  */
513
- documentIds) {
514
- const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
562
+ documentIds, page) {
563
+ const { gatherAuditEntities, countAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
515
564
  const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
516
565
  const { calculatePageSeoScore } = await import('../../seo/score.js');
517
566
  const allTypes = frontEndContentCollectionTypes(getPrentaConfig());
@@ -522,8 +571,18 @@ export function registerSeoRoutes(router) {
522
571
  : allTypes;
523
572
  const overrides = await getSeoOverrides(db()).catch(() => null);
524
573
  const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
525
- const entities = await gatherAuditEntities(db(), { collectionTypes, documentIds });
526
- return entities.map((e) => {
574
+ const [entities, total] = await Promise.all([
575
+ gatherAuditEntities(db(), {
576
+ collectionTypes,
577
+ documentIds,
578
+ skip: page?.skip,
579
+ take: page?.take,
580
+ }),
581
+ documentIds
582
+ ? Promise.resolve(documentIds.length)
583
+ : countAuditEntities(db(), { collectionTypes }),
584
+ ]);
585
+ const records = entities.map((e) => {
527
586
  const score = calculatePageSeoScore(e);
528
587
  return {
529
588
  id: `${e.entityType}:${e.entityId}`,
@@ -564,9 +623,18 @@ export function registerSeoRoutes(router) {
564
623
  llmsOptional: false,
565
624
  seoScore: score.score,
566
625
  seoGrade: score.grade,
567
- updatedAt: null,
626
+ updatedAt: e.updatedAt instanceof Date
627
+ ? e.updatedAt.toISOString()
628
+ : typeof e.updatedAt === 'string'
629
+ ? e.updatedAt
630
+ : null,
568
631
  };
569
632
  });
633
+ return {
634
+ records,
635
+ total,
636
+ truncated: !documentIds && total > records.length,
637
+ };
570
638
  }
571
639
  // Demo/seed search-performance rows live in a hidden collection so they can
572
640
  // be inserted by the dev seed script and never ship to npm. In production
@@ -1295,7 +1363,8 @@ export function registerSeoRoutes(router) {
1295
1363
  };
1296
1364
  let aiDeps;
1297
1365
  const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
1298
- if (REDIRECT_FIX_TYPES.has(issue.type)) {
1366
+ const { INSERT_LINK_FIX_TYPES } = await import('../../seo/issue-fix-link-insert.js');
1367
+ if (REDIRECT_FIX_TYPES.has(issue.type) || INSERT_LINK_FIX_TYPES.has(issue.type)) {
1299
1368
  aiDeps = {
1300
1369
  db: db(),
1301
1370
  userId: auth.session.userId,
@@ -1365,10 +1434,114 @@ export function registerSeoRoutes(router) {
1365
1434
  metadata: asRecord(issue.metadata),
1366
1435
  };
1367
1436
  const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
1437
+ const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1438
+ const { verifySeoIssueFix } = await import('../../seo/issue-fix-verify.js');
1439
+ const { INSERT_LINK_FIX_TYPES, applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint, } = await import('../../seo/issue-fix-link-insert.js');
1440
+ const cachedPending = pendingFixFromMetadata(issue.metadata);
1441
+ const isInsertLinkFix = INSERT_LINK_FIX_TYPES.has(issue.type) || cachedPending?.patch?.fixStrategy === 'insert-link';
1442
+ if (isInsertLinkFix) {
1443
+ const inlinePlanErr = assertPlanFeature('seo.inlineApply');
1444
+ if (inlinePlanErr)
1445
+ return planRequiredResponse(inlinePlanErr);
1446
+ if (!cachedPending?.autoFixable ||
1447
+ (cachedPending.patch.fixStrategy !== 'insert-link' &&
1448
+ !INSERT_LINK_FIX_TYPES.has(issue.type))) {
1449
+ return errorResponse('This issue does not support an automatic fix.', 422);
1450
+ }
1451
+ if (INSERT_LINK_FIX_TYPES.has(issue.type) &&
1452
+ cachedPending.patch.fixStrategy &&
1453
+ cachedPending.patch.fixStrategy !== 'insert-link') {
1454
+ return errorResponse('This issue does not support an automatic fix.', 422);
1455
+ }
1456
+ const sourceEntityId = typeof cachedPending.patch.sourceEntityId === 'string'
1457
+ ? cachedPending.patch.sourceEntityId
1458
+ : '';
1459
+ const targetEntityId = typeof cachedPending.patch.targetEntityId === 'string'
1460
+ ? cachedPending.patch.targetEntityId
1461
+ : (issue.entityId ?? '');
1462
+ const targetPath = typeof cachedPending.patch.targetPath === 'string'
1463
+ ? cachedPending.patch.targetPath
1464
+ : loaded.entity.url;
1465
+ const anchorText = typeof cachedPending.patch.anchorText === 'string' ? cachedPending.patch.anchorText : '';
1466
+ if (!sourceEntityId || !targetEntityId || !anchorText) {
1467
+ return errorResponse('This issue does not support an automatic fix.', 422);
1468
+ }
1469
+ if (body?.fingerprint && body.fingerprint !== cachedPending.fingerprint) {
1470
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1471
+ }
1472
+ const source = await db().document.findFirst({
1473
+ where: { id: sourceEntityId, deletedAt: null },
1474
+ select: { id: true, updatedAt: true },
1475
+ });
1476
+ if (!source)
1477
+ return errorResponse('Source document not found', 404);
1478
+ const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
1479
+ if (cachedPending.fingerprint !== liveFingerprint) {
1480
+ return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1481
+ }
1482
+ const ctx = buildActionContext(auth.session, db());
1483
+ let verified;
1484
+ try {
1485
+ const result = await applyAndVerifyInternalLinkInsert({
1486
+ db: db(),
1487
+ ctx,
1488
+ sourceEntityId,
1489
+ targetEntityId,
1490
+ anchorText,
1491
+ targetPath,
1492
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1493
+ navLinks: await gatherNavigationLinkTargets(db()),
1494
+ });
1495
+ verified = result.verify;
1496
+ }
1497
+ catch (err) {
1498
+ const mapped = mapInternalLinkInsertError(err);
1499
+ if (mapped)
1500
+ return mapped;
1501
+ throw err;
1502
+ }
1503
+ if (!verified.ok) {
1504
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1505
+ }
1506
+ const existingMeta = asRecord(issue.metadata);
1507
+ const resolved = await db().seoIssue.update({
1508
+ where: { id: issue.id },
1509
+ data: {
1510
+ status: 'resolved',
1511
+ resolvedAt: new Date(),
1512
+ resolvedById: auth.session.userId,
1513
+ relatedEntityIds: [sourceEntityId],
1514
+ metadata: { ...existingMeta, pendingFix: null },
1515
+ },
1516
+ });
1517
+ try {
1518
+ await logEvent({
1519
+ event: 'document_updated',
1520
+ userId: auth.session.userId,
1521
+ details: {
1522
+ action: 'seo_issue_fix_applied',
1523
+ seoFix: 'insert-link',
1524
+ issueId: issue.id,
1525
+ issueType: issue.type,
1526
+ sourceEntityId,
1527
+ targetEntityId,
1528
+ targetPath,
1529
+ },
1530
+ });
1531
+ }
1532
+ catch {
1533
+ /* noop */
1534
+ }
1535
+ return json({
1536
+ data: {
1537
+ issue: seoIssueToApi(resolved),
1538
+ applied: cachedPending.changes,
1539
+ },
1540
+ });
1541
+ }
1368
1542
  if (REDIRECT_FIX_TYPES.has(issue.type)) {
1369
- const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1370
1543
  const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
1371
- const cached = pendingFixFromMetadata(issue.metadata);
1544
+ const cached = cachedPending;
1372
1545
  const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1373
1546
  if (!cached?.autoFixable) {
1374
1547
  return errorResponse('This issue does not support an automatic fix.', 422);
@@ -1393,6 +1566,31 @@ export function registerSeoRoutes(router) {
1393
1566
  const msg = err instanceof Error ? err.message : 'Fix application failed';
1394
1567
  return errorResponse(msg, 400);
1395
1568
  }
1569
+ const afterDoc = await db().document.findFirst({
1570
+ where: { id: loaded.entity.entityId, deletedAt: null },
1571
+ select: { data: true },
1572
+ });
1573
+ const redirectSuggestion = {
1574
+ autoFixable: true,
1575
+ fingerprint: cached.fingerprint,
1576
+ fixStrategy: fixStrategy,
1577
+ patch: cached.patch,
1578
+ changes: cached.changes,
1579
+ justification: cached.justification,
1580
+ source: cached.source,
1581
+ };
1582
+ const verified = await verifySeoIssueFix({
1583
+ issueType: issue.type,
1584
+ suggestion: redirectSuggestion,
1585
+ entityId: loaded.entity.entityId,
1586
+ live: { data: asRecord(afterDoc?.data) },
1587
+ db: db(),
1588
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1589
+ navLinks: await gatherNavigationLinkTargets(db()),
1590
+ });
1591
+ if (!verified.ok) {
1592
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1593
+ }
1396
1594
  const existingMeta = asRecord(issue.metadata);
1397
1595
  const resolved = await db().seoIssue.update({
1398
1596
  where: { id: issue.id },
@@ -1436,14 +1634,13 @@ export function registerSeoRoutes(router) {
1436
1634
  });
1437
1635
  }
1438
1636
  const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
1439
- const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1440
1637
  const deterministic = suggestDeterministicFix(issueInput, fixCtx);
1441
1638
  let suggestion = deterministic;
1442
1639
  if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
1443
1640
  const inlinePlanErr = assertPlanFeature('seo.inlineApply');
1444
1641
  if (inlinePlanErr)
1445
1642
  return planRequiredResponse(inlinePlanErr);
1446
- const cached = pendingFixFromMetadata(issue.metadata);
1643
+ const cached = cachedPending;
1447
1644
  const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1448
1645
  if (!cached || !cached.autoFixable) {
1449
1646
  return errorResponse('This issue does not support an automatic fix.', 422);
@@ -1481,6 +1678,50 @@ export function registerSeoRoutes(router) {
1481
1678
  return errorResponse('Document not found', 404);
1482
1679
  throw err;
1483
1680
  }
1681
+ const afterDoc = await db().document.findFirst({
1682
+ where: { id: loaded.entity.entityId, deletedAt: null },
1683
+ select: {
1684
+ id: true,
1685
+ collection: true,
1686
+ title: true,
1687
+ slug: true,
1688
+ data: true,
1689
+ structuredData: true,
1690
+ plainText: true,
1691
+ publishedAt: true,
1692
+ status: true,
1693
+ },
1694
+ });
1695
+ const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
1696
+ const liveEntity = afterDoc
1697
+ ? documentToAuditEntity(afterDoc, frontEndContentCollectionTypes(getPrentaConfig()), loaded.siteSeo ?? getPrentaConfig()?.seo)
1698
+ : loaded.entity;
1699
+ // Prefer the rendered/effective meta title so verify matches changes.after
1700
+ // (page-title portion in the patch ≠ rendered title when a template applies).
1701
+ const metaTitleLive = suggestion.changes.some((c) => c.field === 'metaTitle')
1702
+ ? (liveEntity.metaTitle ??
1703
+ (typeof suggestion.patch.metaTitle === 'string' ? suggestion.patch.metaTitle : null))
1704
+ : (liveEntity.metaTitle ?? null);
1705
+ const verified = await verifySeoIssueFix({
1706
+ issueType: issue.type,
1707
+ suggestion,
1708
+ entityId: loaded.entity.entityId,
1709
+ live: {
1710
+ data: asRecord(afterDoc?.data),
1711
+ metaTitle: metaTitleLive,
1712
+ metaDescription: liveEntity.metaDescription ?? null,
1713
+ canonical: liveEntity.canonicalUrl ?? null,
1714
+ noindex: liveEntity.noindex ?? null,
1715
+ structuredDataType: liveEntity.structuredDataType ?? null,
1716
+ status: afterDoc ? String(afterDoc.status) : undefined,
1717
+ },
1718
+ db: db(),
1719
+ siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1720
+ navLinks: await gatherNavigationLinkTargets(db()),
1721
+ });
1722
+ if (!verified.ok) {
1723
+ return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1724
+ }
1484
1725
  const existingMeta = asRecord(issue.metadata);
1485
1726
  const resolved = await db().seoIssue.update({
1486
1727
  where: { id: issue.id },
@@ -1545,8 +1786,14 @@ export function registerSeoRoutes(router) {
1545
1786
  });
1546
1787
  openIssues = sortIssuesBySeverity(rawIssues);
1547
1788
  }
1548
- const records = await buildSeoContentRecords().catch(() => []);
1549
- const indexable = records.filter((r) => !r.noindex).length;
1789
+ const { frontEndContentCollectionTypes, countAuditEntities } = await import('../../seo/audit-runner.js');
1790
+ const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
1791
+ const total = await countAuditEntities(db(), { collectionTypes }).catch(() => 0);
1792
+ const meta = (lastRun?.metadata ?? {});
1793
+ const indexable = typeof meta.indexableCount === 'number' && Number.isFinite(meta.indexableCount)
1794
+ ? meta.indexableCount
1795
+ : total;
1796
+ const auditTruncated = meta.truncated === true;
1550
1797
  const perf = await loadSearchPerformance(10);
1551
1798
  const summary = {
1552
1799
  total: openIssues.length,
@@ -1565,7 +1812,7 @@ export function registerSeoRoutes(router) {
1565
1812
  {
1566
1813
  label: 'Pages Indexed',
1567
1814
  value: String(indexable),
1568
- helper: `${records.length} total pages`,
1815
+ helper: auditTruncated ? `${total} published (audit capped)` : `${total} published pages`,
1569
1816
  trend: 'flat',
1570
1817
  },
1571
1818
  {
@@ -1626,6 +1873,7 @@ export function registerSeoRoutes(router) {
1626
1873
  searchPerformance: perf,
1627
1874
  searchConsoleConnected: moduleSettings.searchConsoleConnected,
1628
1875
  lastAuditRunId: lastRun?.id ?? null,
1876
+ auditTruncated,
1629
1877
  },
1630
1878
  });
1631
1879
  }
@@ -1657,8 +1905,22 @@ export function registerSeoRoutes(router) {
1657
1905
  // metaTitle/description/canonical/OG for every collection, the exact
1658
1906
  // bypass the sibling document-level SEO endpoints already prevent.
1659
1907
  const allowed = await readableSeoCollections(auth.session);
1660
- const records = await buildSeoContentRecords(allowed);
1661
- return json({ data: { records } });
1908
+ const url = new URL(request.url);
1909
+ const page = clampPage(url.searchParams.get('page'));
1910
+ const pageSize = clampPageSize(url.searchParams.get('pageSize'), 100, 50);
1911
+ const result = await buildSeoContentRecords(allowed, undefined, {
1912
+ skip: (page - 1) * pageSize,
1913
+ take: pageSize,
1914
+ });
1915
+ return json({
1916
+ data: {
1917
+ records: result.records,
1918
+ total: result.total,
1919
+ page,
1920
+ pageSize,
1921
+ truncated: result.truncated,
1922
+ },
1923
+ });
1662
1924
  }
1663
1925
  catch (err) {
1664
1926
  return internalError(err, 'seo/content');
@@ -1685,7 +1947,7 @@ export function registerSeoRoutes(router) {
1685
1947
  // draft has SEO worth editing, and the matching PUT accepts one. Resolving
1686
1948
  // through the published-only gather made drafts 404 here while remaining
1687
1949
  // writable — so an editor could save SEO and never read it back.
1688
- const records = await buildSeoContentRecords(null, [params.entityId]);
1950
+ const { records } = await buildSeoContentRecords(null, [params.entityId]);
1689
1951
  const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
1690
1952
  if (!record)
1691
1953
  return errorResponse('Content record not found', 404);
@@ -1846,7 +2108,7 @@ export function registerSeoRoutes(router) {
1846
2108
  catch {
1847
2109
  /* noop */
1848
2110
  }
1849
- const records = await buildSeoContentRecords();
2111
+ const { records } = await buildSeoContentRecords(null, [doc.id]);
1850
2112
  const record = records.find((r) => r.entityId === doc.id);
1851
2113
  return json({ data: record ?? { ok: true } });
1852
2114
  }