@prenta/core 4.1.1 → 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.
- package/CHANGELOG.md +6 -0
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +330 -108
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/seo/audit-engine.d.ts +7 -0
- package/dist/seo/audit-engine.d.ts.map +1 -1
- package/dist/seo/audit-engine.js +24 -0
- package/dist/seo/audit-engine.js.map +1 -1
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js +14 -0
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/index.d.ts +7 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +4 -0
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/issue-fix-link-insert.d.ts +105 -0
- package/dist/seo/issue-fix-link-insert.d.ts.map +1 -0
- package/dist/seo/issue-fix-link-insert.js +461 -0
- package/dist/seo/issue-fix-link-insert.js.map +1 -0
- package/dist/seo/issue-fix-verify.d.ts +31 -0
- package/dist/seo/issue-fix-verify.d.ts.map +1 -0
- package/dist/seo/issue-fix-verify.js +76 -0
- package/dist/seo/issue-fix-verify.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +2 -2
- package/dist/seo/issue-fix.d.ts.map +1 -1
- package/dist/seo/issue-fix.js +9 -0
- package/dist/seo/issue-fix.js.map +1 -1
- package/dist/seo/link-suggest.d.ts +49 -0
- package/dist/seo/link-suggest.d.ts.map +1 -0
- package/dist/seo/link-suggest.js +147 -0
- package/dist/seo/link-suggest.js.map +1 -0
- package/dist/seo/nav-link-targets.d.ts +9 -0
- package/dist/seo/nav-link-targets.d.ts.map +1 -0
- package/dist/seo/nav-link-targets.js +47 -0
- package/dist/seo/nav-link-targets.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/api/routes/seo.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 },
|