@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.
- package/CHANGELOG.md +21 -0
- package/dist/ai/governance.d.ts +6 -0
- package/dist/ai/governance.d.ts.map +1 -1
- package/dist/ai/governance.js +19 -0
- package/dist/ai/governance.js.map +1 -1
- package/dist/api/routes/redirects.d.ts.map +1 -1
- package/dist/api/routes/redirects.js +17 -65
- package/dist/api/routes/redirects.js.map +1 -1
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +195 -360
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/redirects/suggestion-accept.d.ts +28 -0
- package/dist/redirects/suggestion-accept.d.ts.map +1 -0
- package/dist/redirects/suggestion-accept.js +98 -0
- package/dist/redirects/suggestion-accept.js.map +1 -0
- package/dist/seo/index.d.ts +6 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +5 -1
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/issue-fix-ai.d.ts +40 -1
- package/dist/seo/issue-fix-ai.d.ts.map +1 -1
- package/dist/seo/issue-fix-ai.js +53 -1
- package/dist/seo/issue-fix-ai.js.map +1 -1
- package/dist/seo/issue-fix-apply.d.ts +85 -0
- package/dist/seo/issue-fix-apply.d.ts.map +1 -0
- package/dist/seo/issue-fix-apply.js +400 -0
- package/dist/seo/issue-fix-apply.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +9 -0
- package/dist/seo/issue-fix.d.ts.map +1 -1
- package/dist/seo/issue-fix.js +1 -0
- package/dist/seo/issue-fix.js.map +1 -1
- package/dist/seo/proposals-bulk-apply.d.ts +52 -0
- package/dist/seo/proposals-bulk-apply.d.ts.map +1 -0
- package/dist/seo/proposals-bulk-apply.js +183 -0
- package/dist/seo/proposals-bulk-apply.js.map +1 -0
- package/dist/seo/proposals-generate.d.ts +43 -0
- package/dist/seo/proposals-generate.d.ts.map +1 -0
- package/dist/seo/proposals-generate.js +138 -0
- package/dist/seo/proposals-generate.js.map +1 -0
- package/dist/seo/proposals.d.ts +116 -0
- package/dist/seo/proposals.d.ts.map +1 -0
- package/dist/seo/proposals.js +236 -0
- package/dist/seo/proposals.js.map +1 -0
- package/package.json +1 -1
package/dist/api/routes/seo.js
CHANGED
|
@@ -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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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: '
|
|
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:
|
|
1840
|
-
|
|
1841
|
-
source:
|
|
1576
|
+
entityId: issue.entityId ?? null,
|
|
1577
|
+
fingerprint: dismissedFingerprint,
|
|
1578
|
+
source: cached.source,
|
|
1842
1579
|
},
|
|
1843
1580
|
});
|
|
1844
1581
|
}
|
|
1845
1582
|
catch {
|
|
1846
|
-
/*
|
|
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
|
-
|
|
1851
|
-
applied:
|
|
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/
|
|
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
|
}
|