@prenta/core 5.5.0 → 5.6.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 (44) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/ai/governance.d.ts +6 -0
  3. package/dist/ai/governance.d.ts.map +1 -1
  4. package/dist/ai/governance.js +19 -0
  5. package/dist/ai/governance.js.map +1 -1
  6. package/dist/api/routes/redirects.d.ts.map +1 -1
  7. package/dist/api/routes/redirects.js +17 -65
  8. package/dist/api/routes/redirects.js.map +1 -1
  9. package/dist/api/routes/seo.d.ts.map +1 -1
  10. package/dist/api/routes/seo.js +195 -360
  11. package/dist/api/routes/seo.js.map +1 -1
  12. package/dist/redirects/suggestion-accept.d.ts +28 -0
  13. package/dist/redirects/suggestion-accept.d.ts.map +1 -0
  14. package/dist/redirects/suggestion-accept.js +98 -0
  15. package/dist/redirects/suggestion-accept.js.map +1 -0
  16. package/dist/seo/index.d.ts +6 -2
  17. package/dist/seo/index.d.ts.map +1 -1
  18. package/dist/seo/index.js +5 -1
  19. package/dist/seo/index.js.map +1 -1
  20. package/dist/seo/issue-fix-ai.d.ts +40 -1
  21. package/dist/seo/issue-fix-ai.d.ts.map +1 -1
  22. package/dist/seo/issue-fix-ai.js +53 -1
  23. package/dist/seo/issue-fix-ai.js.map +1 -1
  24. package/dist/seo/issue-fix-apply.d.ts +85 -0
  25. package/dist/seo/issue-fix-apply.d.ts.map +1 -0
  26. package/dist/seo/issue-fix-apply.js +400 -0
  27. package/dist/seo/issue-fix-apply.js.map +1 -0
  28. package/dist/seo/issue-fix.d.ts +9 -0
  29. package/dist/seo/issue-fix.d.ts.map +1 -1
  30. package/dist/seo/issue-fix.js +1 -0
  31. package/dist/seo/issue-fix.js.map +1 -1
  32. package/dist/seo/proposals-bulk-apply.d.ts +52 -0
  33. package/dist/seo/proposals-bulk-apply.d.ts.map +1 -0
  34. package/dist/seo/proposals-bulk-apply.js +183 -0
  35. package/dist/seo/proposals-bulk-apply.js.map +1 -0
  36. package/dist/seo/proposals-generate.d.ts +43 -0
  37. package/dist/seo/proposals-generate.d.ts.map +1 -0
  38. package/dist/seo/proposals-generate.js +138 -0
  39. package/dist/seo/proposals-generate.js.map +1 -0
  40. package/dist/seo/proposals.d.ts +116 -0
  41. package/dist/seo/proposals.d.ts.map +1 -0
  42. package/dist/seo/proposals.js +236 -0
  43. package/dist/seo/proposals.js.map +1 -0
  44. package/package.json +1 -1
@@ -1396,40 +1396,11 @@ export function registerSeoRoutes(router) {
1396
1396
  router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
1397
1397
  /** Load the audit entity + collection for an issue's linked document. */
1398
1398
  async function loadIssueFixContext(issue) {
1399
- if (!issue.entityId) {
1400
- return { error: errorResponse('This issue is not linked to a content item.', 422) };
1401
- }
1402
- const doc = await db().document.findFirst({
1403
- where: { id: issue.entityId, deletedAt: null },
1404
- select: {
1405
- id: true,
1406
- collection: true,
1407
- title: true,
1408
- slug: true,
1409
- data: true,
1410
- structuredData: true,
1411
- plainText: true,
1412
- publishedAt: true,
1413
- updatedAt: true,
1414
- },
1415
- });
1416
- if (!doc)
1417
- return { error: errorResponse('Content not found', 404) };
1418
- const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
1419
- const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
1420
- const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
1421
- const overrides = await getSeoOverrides(db()).catch(() => null);
1422
- const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
1423
- const entity = documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo);
1424
- if (issue.entityType && entity.entityType !== issue.entityType) {
1425
- return { error: errorResponse('Issue entity type does not match the linked document.', 409) };
1426
- }
1427
- return {
1428
- collection: doc.collection,
1429
- entity,
1430
- updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
1431
- siteSeo: mergedConfig?.seo,
1432
- };
1399
+ const { loadIssueFixContext: load } = await import('../../seo/issue-fix-apply.js');
1400
+ const loaded = await load(db(), issue);
1401
+ if (!loaded.ok)
1402
+ return { error: errorResponse(loaded.message, loaded.status) };
1403
+ return loaded.ctx;
1433
1404
  }
1434
1405
  router.post('/seo/issues/:id/suggest-fix', async (request, params) => {
1435
1406
  try {
@@ -1480,12 +1451,31 @@ export function registerSeoRoutes(router) {
1480
1451
  }
1481
1452
  }
1482
1453
  const { suggestIssueFix } = await import('../../seo/issue-fix.js');
1483
- const { pendingFixToMetadata } = await import('../../seo/issue-fix-ai.js');
1454
+ const { pendingFixToMetadata, scoreFixBrandAlignment } = await import('../../seo/issue-fix-ai.js');
1484
1455
  const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
1485
1456
  const existingMeta = asRecord(issue.metadata);
1486
- const nextMetadata = result.cacheForApply
1487
- ? { ...existingMeta, ...pendingFixToMetadata(result.suggestion) }
1488
- : { ...existingMeta, pendingFix: null };
1457
+ let nextMetadata;
1458
+ if (result.cacheForApply) {
1459
+ // Deterministic brand-voice score stamped at generation (no LLM call)
1460
+ // so the proposals inbox can badge it without re-scoring.
1461
+ const { getAIConfig } = await import('../../ai/config-store.js');
1462
+ const brandVoice = await getAIConfig(db())
1463
+ .then((c) => c.settings.brandVoice)
1464
+ .catch((err) => {
1465
+ // Fail open: an unreadable AI config must not cost a working suggestion.
1466
+ console.warn('[seo/suggest-fix] AI config unavailable for brand alignment', err);
1467
+ return undefined;
1468
+ });
1469
+ nextMetadata = {
1470
+ ...existingMeta,
1471
+ ...pendingFixToMetadata(result.suggestion, {
1472
+ brandAlignment: scoreFixBrandAlignment(result.suggestion.changes, brandVoice),
1473
+ }),
1474
+ };
1475
+ }
1476
+ else {
1477
+ nextMetadata = { ...existingMeta, pendingFix: null };
1478
+ }
1489
1479
  await db().seoIssue.update({
1490
1480
  where: { id: issue.id },
1491
1481
  data: { metadata: nextMetadata },
@@ -1507,353 +1497,192 @@ export function registerSeoRoutes(router) {
1507
1497
  if (!hasModel(db(), 'seoIssue'))
1508
1498
  return modelNotAvailable('SeoIssue');
1509
1499
  const body = (await request.json().catch(() => ({})));
1500
+ const { applyIssueFix, VERIFY_FAILED_MESSAGE } = await import('../../seo/issue-fix-apply.js');
1501
+ const result = await applyIssueFix(db(), {
1502
+ issueId: params.id,
1503
+ fingerprint: body?.fingerprint,
1504
+ actor: { userId: auth.session.userId, role: auth.session.role },
1505
+ });
1506
+ switch (result.status) {
1507
+ case 'applied':
1508
+ return json({ data: { issue: seoIssueToApi(result.issue), applied: result.applied } });
1509
+ case 'stale':
1510
+ return errorResponse(result.reason, 409);
1511
+ case 'verify-failed':
1512
+ return json({ error: VERIFY_FAILED_MESSAGE, reason: result.reason }, 409);
1513
+ case 'plan-required':
1514
+ return planRequiredResponse(result.denial);
1515
+ case 'not-found':
1516
+ return errorResponse(result.reason, 404);
1517
+ case 'not-allowed':
1518
+ return errorResponse(result.reason, result.httpStatus);
1519
+ case 'not-fixable':
1520
+ return errorResponse(result.reason, result.httpStatus);
1521
+ default: {
1522
+ const _exhaustive = result;
1523
+ return _exhaustive;
1524
+ }
1525
+ }
1526
+ }
1527
+ catch (err) {
1528
+ return internalError(err, 'seo/issues/:id/apply-fix');
1529
+ }
1530
+ });
1531
+ router.post('/seo/issues/:id/dismiss-fix', async (request, params) => {
1532
+ try {
1533
+ const auth = await requireAuth(request);
1534
+ if (auth.error)
1535
+ return auth.error;
1536
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1537
+ if (roleErr)
1538
+ return roleErr;
1539
+ if (!hasModel(db(), 'seoIssue'))
1540
+ return modelNotAvailable('SeoIssue');
1510
1541
  const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
1511
1542
  if (!issue)
1512
1543
  return errorResponse('Issue not found', 404);
1513
- if (issue.status === 'ignored') {
1514
- return errorResponse('Cannot apply a fix to an ignored issue. Reopen it first.', 409);
1515
- }
1516
- const loaded = await loadIssueFixContext(issue);
1517
- if ('error' in loaded)
1518
- return loaded.error;
1519
- const issueInput = {
1520
- type: issue.type,
1521
- title: issue.title,
1522
- recommendation: issue.recommendation,
1523
- entityType: issue.entityType,
1524
- entityId: issue.entityId,
1525
- };
1526
- const fixCtx = {
1527
- entity: loaded.entity,
1528
- siteSeo: loaded.siteSeo,
1529
- updatedAt: loaded.updatedAt,
1530
- metadata: asRecord(issue.metadata),
1531
- };
1532
- const { REDIRECT_FIX_TYPES, applyBrokenLinkRedirectFix, applyBrokenLinkContentFix } = await import('../../seo/issue-fix-redirect.js');
1533
1544
  const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
1534
- const { verifySeoIssueFix } = await import('../../seo/issue-fix-verify.js');
1535
- const { INSERT_LINK_FIX_TYPES, applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint, } = await import('../../seo/issue-fix-link-insert.js');
1536
- const cachedPending = pendingFixFromMetadata(issue.metadata);
1537
- const isInsertLinkFix = INSERT_LINK_FIX_TYPES.has(issue.type) || cachedPending?.patch?.fixStrategy === 'insert-link';
1538
- if (isInsertLinkFix) {
1539
- const inlinePlanErr = assertPlanFeature('seo.inlineApply');
1540
- if (inlinePlanErr)
1541
- return planRequiredResponse(inlinePlanErr);
1542
- if (!cachedPending?.autoFixable ||
1543
- (cachedPending.patch.fixStrategy !== 'insert-link' &&
1544
- !INSERT_LINK_FIX_TYPES.has(issue.type))) {
1545
- return errorResponse('This issue does not support an automatic fix.', 422);
1546
- }
1547
- if (INSERT_LINK_FIX_TYPES.has(issue.type) &&
1548
- cachedPending.patch.fixStrategy &&
1549
- cachedPending.patch.fixStrategy !== 'insert-link') {
1550
- return errorResponse('This issue does not support an automatic fix.', 422);
1551
- }
1552
- const sourceEntityId = typeof cachedPending.patch.sourceEntityId === 'string'
1553
- ? cachedPending.patch.sourceEntityId
1554
- : '';
1555
- const targetEntityId = typeof cachedPending.patch.targetEntityId === 'string'
1556
- ? cachedPending.patch.targetEntityId
1557
- : (issue.entityId ?? '');
1558
- const targetPath = typeof cachedPending.patch.targetPath === 'string'
1559
- ? cachedPending.patch.targetPath
1560
- : loaded.entity.url;
1561
- const anchorText = typeof cachedPending.patch.anchorText === 'string' ? cachedPending.patch.anchorText : '';
1562
- if (!sourceEntityId || !targetEntityId || !anchorText) {
1563
- return errorResponse('This issue does not support an automatic fix.', 422);
1564
- }
1565
- if (body?.fingerprint && body.fingerprint !== cachedPending.fingerprint) {
1566
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1567
- }
1568
- const source = await db().document.findFirst({
1569
- where: { id: sourceEntityId, deletedAt: null },
1570
- select: { id: true, updatedAt: true },
1571
- });
1572
- if (!source)
1573
- return errorResponse('Source document not found', 404);
1574
- const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
1575
- if (cachedPending.fingerprint !== liveFingerprint) {
1576
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1577
- }
1578
- const ctx = buildActionContext(auth.session, db());
1579
- let verified;
1580
- try {
1581
- const result = await applyAndVerifyInternalLinkInsert({
1582
- db: db(),
1583
- ctx,
1584
- sourceEntityId,
1585
- targetEntityId,
1586
- anchorText,
1587
- targetPath,
1588
- siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1589
- navLinks: await gatherNavigationLinkTargets(db()),
1590
- });
1591
- verified = result.verify;
1592
- }
1593
- catch (err) {
1594
- const mapped = mapInternalLinkInsertError(err);
1595
- if (mapped)
1596
- return mapped;
1597
- throw err;
1598
- }
1599
- if (!verified.ok) {
1600
- return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1601
- }
1602
- const existingMeta = asRecord(issue.metadata);
1603
- const resolved = await db().seoIssue.update({
1604
- where: { id: issue.id },
1605
- data: {
1606
- status: 'resolved',
1607
- resolvedAt: new Date(),
1608
- resolvedById: auth.session.userId,
1609
- relatedEntityIds: [sourceEntityId],
1610
- metadata: { ...existingMeta, pendingFix: null },
1611
- },
1612
- });
1613
- try {
1614
- await logEvent({
1615
- event: 'document_updated',
1616
- userId: auth.session.userId,
1617
- details: {
1618
- action: 'seo_issue_fix_applied',
1619
- seoFix: 'insert-link',
1620
- issueId: issue.id,
1621
- issueType: issue.type,
1622
- sourceEntityId,
1623
- targetEntityId,
1624
- targetPath,
1625
- },
1626
- });
1627
- }
1628
- catch {
1629
- /* noop */
1630
- }
1631
- return json({
1632
- data: {
1633
- issue: seoIssueToApi(resolved),
1634
- applied: cachedPending.changes,
1635
- },
1636
- });
1637
- }
1638
- if (REDIRECT_FIX_TYPES.has(issue.type)) {
1639
- const { buildFixFingerprint } = await import('../../seo/issue-fix.js');
1640
- const cached = cachedPending;
1641
- const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1642
- if (!cached?.autoFixable) {
1643
- return errorResponse('This issue does not support an automatic fix.', 422);
1644
- }
1645
- if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
1646
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1647
- }
1648
- if (cached.fingerprint !== liveFingerprint) {
1649
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1650
- }
1651
- const fixStrategy = cached.patch.fixStrategy === 'fix-link' ? 'fix-link' : 'redirect';
1652
- try {
1653
- if (fixStrategy === 'fix-link') {
1654
- const ctx = buildActionContext(auth.session, db());
1655
- await applyBrokenLinkContentFix(loaded.collection, loaded.entity.entityId, cached.patch, ctx);
1656
- }
1657
- else {
1658
- await applyBrokenLinkRedirectFix(db(), cached.patch, auth.session.userId);
1659
- }
1660
- }
1661
- catch (err) {
1662
- const msg = err instanceof Error ? err.message : 'Fix application failed';
1663
- return errorResponse(msg, 400);
1664
- }
1665
- const afterDoc = await db().document.findFirst({
1666
- where: { id: loaded.entity.entityId, deletedAt: null },
1667
- select: { data: true },
1668
- });
1669
- const redirectSuggestion = {
1670
- autoFixable: true,
1671
- fingerprint: cached.fingerprint,
1672
- fixStrategy: fixStrategy,
1673
- patch: cached.patch,
1674
- changes: cached.changes,
1675
- justification: cached.justification,
1676
- source: cached.source,
1677
- };
1678
- const verified = await verifySeoIssueFix({
1679
- issueType: issue.type,
1680
- suggestion: redirectSuggestion,
1681
- entityId: loaded.entity.entityId,
1682
- live: { data: asRecord(afterDoc?.data) },
1683
- db: db(),
1684
- siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1685
- navLinks: await gatherNavigationLinkTargets(db()),
1686
- });
1687
- if (!verified.ok) {
1688
- return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1689
- }
1690
- const existingMeta = asRecord(issue.metadata);
1691
- const resolved = await db().seoIssue.update({
1692
- where: { id: issue.id },
1693
- data: {
1694
- status: 'resolved',
1695
- resolvedAt: new Date(),
1696
- resolvedById: auth.session.userId,
1697
- metadata: { ...existingMeta, pendingFix: null },
1698
- },
1699
- });
1700
- try {
1701
- await logEvent({
1702
- event: 'document_updated',
1703
- userId: auth.session.userId,
1704
- details: {
1705
- action: fixStrategy === 'fix-link'
1706
- ? 'seo_issue_link_fix_applied'
1707
- : 'seo_issue_redirect_applied',
1708
- issueId: issue.id,
1709
- issueType: issue.type,
1710
- ...(fixStrategy === 'fix-link'
1711
- ? {
1712
- fixLinkFrom: cached.patch.fixLinkFrom,
1713
- fixLinkTo: cached.patch.fixLinkTo,
1714
- }
1715
- : {
1716
- redirectFrom: cached.patch.redirectFrom,
1717
- redirectTo: cached.patch.redirectTo,
1718
- }),
1719
- },
1720
- });
1721
- }
1722
- catch {
1723
- /* noop */
1724
- }
1725
- return json({
1726
- data: {
1727
- issue: seoIssueToApi(resolved),
1728
- applied: cached.changes,
1729
- },
1730
- });
1731
- }
1732
- const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
1733
- const deterministic = suggestDeterministicFix(issueInput, fixCtx);
1734
- let suggestion = deterministic;
1735
- if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
1736
- const inlinePlanErr = assertPlanFeature('seo.inlineApply');
1737
- if (inlinePlanErr)
1738
- return planRequiredResponse(inlinePlanErr);
1739
- const cached = cachedPending;
1740
- const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
1741
- if (!cached || !cached.autoFixable) {
1742
- return errorResponse('This issue does not support an automatic fix.', 422);
1743
- }
1744
- if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
1745
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1746
- }
1747
- if (cached.fingerprint !== liveFingerprint) {
1748
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1749
- }
1750
- suggestion = {
1751
- autoFixable: true,
1752
- source: 'ai',
1753
- fingerprint: cached.fingerprint,
1754
- patch: cached.patch,
1755
- changes: cached.changes,
1756
- justification: cached.justification,
1757
- };
1758
- }
1759
- else if (body?.fingerprint && body.fingerprint !== suggestion.fingerprint) {
1760
- return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
1761
- }
1762
- if (!suggestion.autoFixable || Object.keys(suggestion.patch).length === 0) {
1763
- return errorResponse('This issue does not support an automatic fix.', 422);
1764
- }
1765
- const ctx = buildActionContext(auth.session, db());
1766
- try {
1767
- await updateDocumentSeoFields(loaded.collection, loaded.entity.entityId, suggestion.patch, ctx);
1768
- }
1769
- catch (err) {
1770
- const msg = err instanceof Error ? err.message : '';
1771
- if (msg.includes('Access denied'))
1772
- return errorResponse('Insufficient permissions', 403);
1773
- if (msg.includes('not found'))
1774
- return errorResponse('Document not found', 404);
1775
- throw err;
1776
- }
1777
- const afterDoc = await db().document.findFirst({
1778
- where: { id: loaded.entity.entityId, deletedAt: null },
1779
- select: {
1780
- id: true,
1781
- collection: true,
1782
- title: true,
1783
- slug: true,
1784
- data: true,
1785
- structuredData: true,
1786
- plainText: true,
1787
- publishedAt: true,
1788
- status: true,
1789
- },
1790
- });
1791
- const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
1792
- const liveEntity = afterDoc
1793
- ? documentToAuditEntity(afterDoc, frontEndContentCollectionTypes(getPrentaConfig()), loaded.siteSeo ?? getPrentaConfig()?.seo)
1794
- : loaded.entity;
1795
- // Prefer the rendered/effective meta title so verify matches changes.after
1796
- // (page-title portion in the patch ≠ rendered title when a template applies).
1797
- const metaTitleLive = suggestion.changes.some((c) => c.field === 'metaTitle')
1798
- ? (liveEntity.metaTitle ??
1799
- (typeof suggestion.patch.metaTitle === 'string' ? suggestion.patch.metaTitle : null))
1800
- : (liveEntity.metaTitle ?? null);
1801
- const verified = await verifySeoIssueFix({
1802
- issueType: issue.type,
1803
- suggestion,
1804
- entityId: loaded.entity.entityId,
1805
- live: {
1806
- data: asRecord(afterDoc?.data),
1807
- metaTitle: metaTitleLive,
1808
- metaDescription: liveEntity.metaDescription ?? null,
1809
- canonical: liveEntity.canonicalUrl ?? null,
1810
- noindex: liveEntity.noindex ?? null,
1811
- structuredDataType: liveEntity.structuredDataType ?? null,
1812
- status: afterDoc ? String(afterDoc.status) : undefined,
1813
- },
1814
- db: db(),
1815
- siteUrl: getPrentaConfig()?.seo?.siteUrl ?? loaded.siteSeo?.siteUrl ?? '',
1816
- navLinks: await gatherNavigationLinkTargets(db()),
1817
- });
1818
- if (!verified.ok) {
1819
- return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
1820
- }
1821
- const existingMeta = asRecord(issue.metadata);
1822
- const resolved = await db().seoIssue.update({
1545
+ const cached = pendingFixFromMetadata(issue.metadata);
1546
+ if (!cached)
1547
+ return json({ data: { dismissed: false } });
1548
+ // Sticky: remember which document state was rejected so batch
1549
+ // generation does not recreate it until the document changes. The
1550
+ // marker must match the entity-based live fingerprint the no-cache
1551
+ // path computes, which differs from the cached one for insert-link.
1552
+ const { dismissalFingerprintFor } = await import('../../seo/proposals.js');
1553
+ const entityDoc = typeof issue.entityId === 'string'
1554
+ ? await db()
1555
+ .document.findUnique({ where: { id: issue.entityId }, select: { updatedAt: true } })
1556
+ .catch(() => null)
1557
+ : null;
1558
+ const dismissedFingerprint = dismissalFingerprintFor(issue, cached, entityDoc?.updatedAt ?? null);
1559
+ await db().seoIssue.update({
1823
1560
  where: { id: issue.id },
1824
1561
  data: {
1825
- status: 'resolved',
1826
- resolvedAt: new Date(),
1827
- resolvedById: auth.session.userId,
1828
- metadata: { ...existingMeta, pendingFix: null },
1562
+ metadata: {
1563
+ ...asRecord(issue.metadata),
1564
+ pendingFix: null,
1565
+ dismissedFingerprint,
1566
+ },
1829
1567
  },
1830
1568
  });
1831
1569
  try {
1832
1570
  await logEvent({
1833
- event: 'document_updated',
1571
+ event: 'seo_fix_dismissed',
1834
1572
  userId: auth.session.userId,
1835
1573
  details: {
1836
- action: 'seo_issue_fix_applied',
1837
1574
  issueId: issue.id,
1838
1575
  issueType: issue.type,
1839
- entityId: loaded.entity.entityId,
1840
- fields: Object.keys(suggestion.patch),
1841
- source: suggestion.source ?? 'deterministic',
1576
+ entityId: issue.entityId ?? null,
1577
+ fingerprint: dismissedFingerprint,
1578
+ source: cached.source,
1842
1579
  },
1843
1580
  });
1844
1581
  }
1845
1582
  catch {
1846
- /* noop */
1583
+ /* audit-log failures never block */
1584
+ }
1585
+ return json({ data: { dismissed: true } });
1586
+ }
1587
+ catch (err) {
1588
+ return internalError(err, 'seo/issues/:id/dismiss-fix');
1589
+ }
1590
+ });
1591
+ // Any authenticated user, like GET /seo/issues and GET /seo/overview: editors
1592
+ // read the inbox and act through the WRITE_ROLES mutation routes.
1593
+ router.get('/seo/proposals', async (request) => {
1594
+ try {
1595
+ const auth = await requireAuth(request);
1596
+ if (auth.error)
1597
+ return auth.error;
1598
+ const { listSeoProposals } = await import('../../seo/proposals.js');
1599
+ return json({ data: await listSeoProposals(db()) });
1600
+ }
1601
+ catch (err) {
1602
+ return internalError(err, 'seo/proposals');
1603
+ }
1604
+ });
1605
+ router.post('/seo/proposals/generate', async (request) => {
1606
+ try {
1607
+ const auth = await requireAuth(request);
1608
+ if (auth.error)
1609
+ return auth.error;
1610
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
1611
+ if (roleErr)
1612
+ return roleErr;
1613
+ const planErr = assertPlanFeature('seo.inlineApply');
1614
+ if (planErr)
1615
+ return planRequiredResponse(planErr);
1616
+ if (!hasModel(db(), 'seoIssue'))
1617
+ return modelNotAvailable('SeoIssue');
1618
+ const body = (await request.json().catch(() => ({})));
1619
+ if (body?.confirm !== true) {
1620
+ return errorResponse('Pass { "confirm": true } to generate proposals.', 400);
1621
+ }
1622
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-proposals-generate:${auth.session.userId}`))) {
1623
+ return errorResponse('AI rate limit reached. Try again in an hour.', 429);
1624
+ }
1625
+ const { generateSeoProposals } = await import('../../seo/proposals-generate.js');
1626
+ const result = await generateSeoProposals(db(), { userId: auth.session.userId });
1627
+ return json({ data: result });
1628
+ }
1629
+ catch (err) {
1630
+ return internalError(err, 'seo/proposals/generate');
1631
+ }
1632
+ });
1633
+ // WRITE_ROLES like the per-item apply-fix route; redirect items are
1634
+ // additionally admin-gated per item inside the service (spec §4).
1635
+ router.post('/seo/proposals/bulk-apply', async (request) => {
1636
+ try {
1637
+ const auth = await requireAuth(request);
1638
+ if (auth.error)
1639
+ return auth.error;
1640
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1641
+ if (roleErr)
1642
+ return roleErr;
1643
+ if (!hasModel(db(), 'seoIssue'))
1644
+ return modelNotAvailable('SeoIssue');
1645
+ const body = (await request.json().catch(() => ({})));
1646
+ const rawItems = Array.isArray(body?.items) ? body.items : null;
1647
+ if (!rawItems || rawItems.length === 0) {
1648
+ return errorResponse('items must be a non-empty array.', 400);
1649
+ }
1650
+ const { PROPOSAL_BULK_APPLY_MAX, bulkApplySeoProposals } = await import('../../seo/proposals-bulk-apply.js');
1651
+ if (rawItems.length > PROPOSAL_BULK_APPLY_MAX) {
1652
+ return errorResponse(`At most ${PROPOSAL_BULK_APPLY_MAX} items per bulk apply.`, 400);
1653
+ }
1654
+ const items = [];
1655
+ for (const raw of rawItems) {
1656
+ const r = asRecord(raw);
1657
+ if ((r.kind !== 'issue-fix' && r.kind !== 'redirect') ||
1658
+ typeof r.id !== 'string' ||
1659
+ !r.id) {
1660
+ return errorResponse('Each item needs kind ("issue-fix" | "redirect") and id.', 400);
1661
+ }
1662
+ items.push({
1663
+ kind: r.kind,
1664
+ id: r.id,
1665
+ fingerprint: typeof r.fingerprint === 'string' ? r.fingerprint : undefined,
1666
+ });
1667
+ }
1668
+ const result = await bulkApplySeoProposals(db(), {
1669
+ items,
1670
+ actor: { userId: auth.session.userId, role: auth.session.role },
1671
+ });
1672
+ if (result.blocked) {
1673
+ return json({ error: result.reason, code: 'governance_blocked' }, 403);
1847
1674
  }
1848
1675
  return json({
1849
1676
  data: {
1850
- issue: seoIssueToApi(resolved),
1851
- applied: suggestion.changes,
1677
+ results: result.results,
1678
+ applied: result.applied,
1679
+ failed: result.failed,
1680
+ pending: result.pending,
1852
1681
  },
1853
1682
  });
1854
1683
  }
1855
1684
  catch (err) {
1856
- return internalError(err, 'seo/issues/:id/apply-fix');
1685
+ return internalError(err, 'seo/proposals/bulk-apply');
1857
1686
  }
1858
1687
  });
1859
1688
  router.get('/seo/overview', async (request) => {
@@ -1900,6 +1729,11 @@ export function registerSeoRoutes(router) {
1900
1729
  warning: openIssues.filter((i) => i.severity === 'warning').length,
1901
1730
  info: openIssues.filter((i) => i.severity === 'info').length,
1902
1731
  };
1732
+ const { countPendingProposals } = await import('../../seo/proposals.js');
1733
+ const pendingProposals = await countPendingProposals(db(), openIssues).catch(() => ({
1734
+ issueFixes: 0,
1735
+ redirects: 0,
1736
+ }));
1903
1737
  const scoreAfter = lastRun?.siteScoreAfter ?? null;
1904
1738
  const scoreBefore = lastRun?.siteScoreBefore ?? null;
1905
1739
  const breakdown = lastRun?.metadata?.breakdown ?? [];
@@ -2019,6 +1853,7 @@ export function registerSeoRoutes(router) {
2019
1853
  searchConsoleConnected: moduleSettings.searchConsoleConnected,
2020
1854
  lastAuditRunId: lastRun?.id ?? null,
2021
1855
  auditTruncated,
1856
+ pendingProposals,
2022
1857
  },
2023
1858
  });
2024
1859
  }