@actuate-media/cms-core 0.78.0 → 0.79.1
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 +12 -0
- package/dist/__tests__/actions/document-crud.test.js +50 -0
- package/dist/__tests__/actions/document-crud.test.js.map +1 -1
- package/dist/__tests__/api/collection-read-access.test.d.ts +2 -0
- package/dist/__tests__/api/collection-read-access.test.d.ts.map +1 -0
- package/dist/__tests__/api/collection-read-access.test.js +140 -0
- package/dist/__tests__/api/collection-read-access.test.js.map +1 -0
- package/dist/__tests__/api/route-contracts.test.js +9 -0
- package/dist/__tests__/api/route-contracts.test.js.map +1 -1
- package/dist/__tests__/seo/fix-brief-export.test.d.ts +2 -0
- package/dist/__tests__/seo/fix-brief-export.test.d.ts.map +1 -0
- package/dist/__tests__/seo/fix-brief-export.test.js +153 -0
- package/dist/__tests__/seo/fix-brief-export.test.js.map +1 -0
- package/dist/__tests__/seo/issue-batch-briefs.test.d.ts +2 -0
- package/dist/__tests__/seo/issue-batch-briefs.test.d.ts.map +1 -0
- package/dist/__tests__/seo/issue-batch-briefs.test.js +64 -0
- package/dist/__tests__/seo/issue-batch-briefs.test.js.map +1 -0
- package/dist/__tests__/seo/issue-content-brief.test.d.ts +2 -0
- package/dist/__tests__/seo/issue-content-brief.test.d.ts.map +1 -0
- package/dist/__tests__/seo/issue-content-brief.test.js +107 -0
- package/dist/__tests__/seo/issue-content-brief.test.js.map +1 -0
- package/dist/__tests__/seo/issue-content-research.test.d.ts +2 -0
- package/dist/__tests__/seo/issue-content-research.test.d.ts.map +1 -0
- package/dist/__tests__/seo/issue-content-research.test.js +114 -0
- package/dist/__tests__/seo/issue-content-research.test.js.map +1 -0
- package/dist/__tests__/seo/issue-fix-ai.test.d.ts +2 -0
- package/dist/__tests__/seo/issue-fix-ai.test.d.ts.map +1 -0
- package/dist/__tests__/seo/issue-fix-ai.test.js +96 -0
- package/dist/__tests__/seo/issue-fix-ai.test.js.map +1 -0
- package/dist/__tests__/seo/issue-fix-orchestrator.test.d.ts +2 -0
- package/dist/__tests__/seo/issue-fix-orchestrator.test.d.ts.map +1 -0
- package/dist/__tests__/seo/issue-fix-orchestrator.test.js +48 -0
- package/dist/__tests__/seo/issue-fix-orchestrator.test.js.map +1 -0
- package/dist/__tests__/seo/issue-fix.test.d.ts +2 -0
- package/dist/__tests__/seo/issue-fix.test.d.ts.map +1 -0
- package/dist/__tests__/seo/issue-fix.test.js +98 -0
- package/dist/__tests__/seo/issue-fix.test.js.map +1 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +5 -0
- package/dist/actions.js.map +1 -1
- package/dist/api/routes/documents.js +4 -4
- package/dist/api/routes/documents.js.map +1 -1
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +677 -1
- package/dist/api/routes/seo.js.map +1 -1
- package/dist/seo/fix-brief-export.d.ts +47 -0
- package/dist/seo/fix-brief-export.d.ts.map +1 -0
- package/dist/seo/fix-brief-export.js +224 -0
- package/dist/seo/fix-brief-export.js.map +1 -0
- package/dist/seo/index.d.ts +12 -0
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +6 -0
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/issue-batch-briefs.d.ts +37 -0
- package/dist/seo/issue-batch-briefs.d.ts.map +1 -0
- package/dist/seo/issue-batch-briefs.js +85 -0
- package/dist/seo/issue-batch-briefs.js.map +1 -0
- package/dist/seo/issue-content-brief.d.ts +51 -0
- package/dist/seo/issue-content-brief.d.ts.map +1 -0
- package/dist/seo/issue-content-brief.js +265 -0
- package/dist/seo/issue-content-brief.js.map +1 -0
- package/dist/seo/issue-content-research.d.ts +51 -0
- package/dist/seo/issue-content-research.d.ts.map +1 -0
- package/dist/seo/issue-content-research.js +301 -0
- package/dist/seo/issue-content-research.js.map +1 -0
- package/dist/seo/issue-fix-ai.d.ts +39 -0
- package/dist/seo/issue-fix-ai.d.ts.map +1 -0
- package/dist/seo/issue-fix-ai.js +298 -0
- package/dist/seo/issue-fix-ai.js.map +1 -0
- package/dist/seo/issue-fix.d.ts +68 -0
- package/dist/seo/issue-fix.d.ts.map +1 -0
- package/dist/seo/issue-fix.js +244 -0
- package/dist/seo/issue-fix.js.map +1 -0
- package/package.json +1 -1
package/dist/api/routes/seo.js
CHANGED
|
@@ -8,7 +8,7 @@ import { syncSchemaTypeFields } from '../../seo/schema-sync.js';
|
|
|
8
8
|
import { guardedDb } from '../guarded-db.js';
|
|
9
9
|
import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
|
|
10
10
|
import { detectAiBot } from '../../seo/ai-bots.js';
|
|
11
|
-
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, } from '../route-helpers.js';
|
|
11
|
+
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, } from '../route-helpers.js';
|
|
12
12
|
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
|
|
13
13
|
// alphabetically (critical, info, warning) which wrongly ranks `info` above
|
|
14
14
|
// `warning`. Unknown severities sort last.
|
|
@@ -391,6 +391,480 @@ export function registerSeoRoutes(router) {
|
|
|
391
391
|
return internalError(err, 'seo/issues');
|
|
392
392
|
}
|
|
393
393
|
});
|
|
394
|
+
router.get('/seo/issues/export-brief', async (request) => {
|
|
395
|
+
try {
|
|
396
|
+
const auth = await requireAuth(request);
|
|
397
|
+
if (auth.error)
|
|
398
|
+
return auth.error;
|
|
399
|
+
if (!hasModel(db(), 'seoIssue')) {
|
|
400
|
+
const { buildSeoFixBriefMarkdown, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
|
|
401
|
+
return json({
|
|
402
|
+
data: {
|
|
403
|
+
markdown: buildSeoFixBriefMarkdown({ issues: [], manualOnly: true }),
|
|
404
|
+
filename: fixBriefFilename(),
|
|
405
|
+
issueCount: 0,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const url = new URL(request.url);
|
|
410
|
+
const severity = url.searchParams.get('severity');
|
|
411
|
+
const status = url.searchParams.get('status');
|
|
412
|
+
const auditRunId = url.searchParams.get('auditRunId');
|
|
413
|
+
const manualOnly = url.searchParams.get('manualOnly') !== 'false';
|
|
414
|
+
const includeContentBriefs = url.searchParams.get('includeContentBriefs') !== 'false';
|
|
415
|
+
const includeContentResearch = url.searchParams.get('includeContentResearch') !== 'false';
|
|
416
|
+
const where = {};
|
|
417
|
+
if (severity)
|
|
418
|
+
where.severity = severity;
|
|
419
|
+
if (status)
|
|
420
|
+
where.status = status;
|
|
421
|
+
else
|
|
422
|
+
where.status = { in: ['open', 'recurring'] };
|
|
423
|
+
if (auditRunId)
|
|
424
|
+
where.auditRunId = auditRunId;
|
|
425
|
+
const issues = sortIssuesBySeverity(await db().seoIssue.findMany({
|
|
426
|
+
where,
|
|
427
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
428
|
+
take: 500,
|
|
429
|
+
}));
|
|
430
|
+
let runAuditDate = null;
|
|
431
|
+
let runId = auditRunId;
|
|
432
|
+
if (auditRunId && hasModel(db(), 'seoAuditRun')) {
|
|
433
|
+
const run = await db().seoAuditRun.findUnique({ where: { id: auditRunId } });
|
|
434
|
+
runAuditDate = run?.finishedAt ?? run?.startedAt ?? null;
|
|
435
|
+
}
|
|
436
|
+
else if (hasModel(db(), 'seoAuditRun')) {
|
|
437
|
+
const lastRun = await db().seoAuditRun.findFirst({
|
|
438
|
+
where: { status: 'COMPLETED' },
|
|
439
|
+
orderBy: { startedAt: 'desc' },
|
|
440
|
+
});
|
|
441
|
+
runId = lastRun?.id ?? null;
|
|
442
|
+
runAuditDate = lastRun?.finishedAt ?? lastRun?.startedAt ?? null;
|
|
443
|
+
}
|
|
444
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
445
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
446
|
+
const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
|
|
447
|
+
const siteSeo = mergedConfig?.seo;
|
|
448
|
+
const { buildSeoFixBriefMarkdown, filterFixBriefIssues, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
|
|
449
|
+
const entityIds = [
|
|
450
|
+
...new Set(issues
|
|
451
|
+
.map((i) => i.entityId)
|
|
452
|
+
.filter((id) => typeof id === 'string' && id.length > 0)),
|
|
453
|
+
];
|
|
454
|
+
const updatedAtByEntity = new Map();
|
|
455
|
+
if ((includeContentBriefs || includeContentResearch) && entityIds.length > 0) {
|
|
456
|
+
const docs = await db().document.findMany({
|
|
457
|
+
where: { id: { in: entityIds }, deletedAt: null },
|
|
458
|
+
select: { id: true, updatedAt: true },
|
|
459
|
+
});
|
|
460
|
+
for (const doc of docs) {
|
|
461
|
+
updatedAtByEntity.set(doc.id, doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const { contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
|
|
465
|
+
const { contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
|
|
466
|
+
const briefIssues = issues.map((i) => {
|
|
467
|
+
let contentBrief = null;
|
|
468
|
+
let contentResearch = null;
|
|
469
|
+
if (i.entityId) {
|
|
470
|
+
const updatedAt = updatedAtByEntity.get(i.entityId);
|
|
471
|
+
if (updatedAt) {
|
|
472
|
+
if (includeContentBriefs) {
|
|
473
|
+
contentBrief = contentBriefFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
|
|
474
|
+
}
|
|
475
|
+
if (includeContentResearch) {
|
|
476
|
+
contentResearch = contentResearchFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
id: i.id,
|
|
482
|
+
type: i.type,
|
|
483
|
+
title: i.title,
|
|
484
|
+
description: i.description ?? null,
|
|
485
|
+
impact: i.impact ?? null,
|
|
486
|
+
recommendation: i.recommendation ?? null,
|
|
487
|
+
severity: i.severity,
|
|
488
|
+
category: i.category,
|
|
489
|
+
status: i.status,
|
|
490
|
+
entityType: i.entityType ?? null,
|
|
491
|
+
entityId: i.entityId ?? null,
|
|
492
|
+
entityTitle: i.entityTitle ?? null,
|
|
493
|
+
url: i.url ?? null,
|
|
494
|
+
fixActionType: i.fixActionType ?? null,
|
|
495
|
+
fixable: i.fixable ?? false,
|
|
496
|
+
contentBrief,
|
|
497
|
+
contentResearch,
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
const markdown = buildSeoFixBriefMarkdown({
|
|
501
|
+
siteName: siteSeo?.siteName ?? null,
|
|
502
|
+
siteUrl: siteSeo?.siteUrl ?? null,
|
|
503
|
+
auditRunId: runId,
|
|
504
|
+
auditDate: runAuditDate,
|
|
505
|
+
issues: briefIssues,
|
|
506
|
+
manualOnly,
|
|
507
|
+
});
|
|
508
|
+
const issueCount = filterFixBriefIssues(briefIssues, manualOnly).length;
|
|
509
|
+
return json({
|
|
510
|
+
data: {
|
|
511
|
+
markdown,
|
|
512
|
+
filename: fixBriefFilename(siteSeo?.siteName, runAuditDate ?? undefined),
|
|
513
|
+
issueCount,
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
return internalError(err, 'seo/issues/export-brief');
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
router.post('/seo/issues/batch-content-briefs', async (request) => {
|
|
522
|
+
try {
|
|
523
|
+
const auth = await requireAuth(request);
|
|
524
|
+
if (auth.error)
|
|
525
|
+
return auth.error;
|
|
526
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
527
|
+
if (roleErr)
|
|
528
|
+
return roleErr;
|
|
529
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
530
|
+
return modelNotAvailable('SeoIssue');
|
|
531
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-batch-briefs:${auth.session.userId}`))) {
|
|
532
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
533
|
+
}
|
|
534
|
+
const body = (await request.json().catch(() => ({})));
|
|
535
|
+
const severity = typeof body?.severity === 'string' ? body.severity : null;
|
|
536
|
+
const status = typeof body?.status === 'string' ? body.status : null;
|
|
537
|
+
const skipExisting = body?.skipExisting !== false;
|
|
538
|
+
const includeResearch = body?.includeResearch === true;
|
|
539
|
+
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
540
|
+
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
541
|
+
: undefined;
|
|
542
|
+
const issueIds = Array.isArray(body?.issueIds)
|
|
543
|
+
? body.issueIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
544
|
+
: null;
|
|
545
|
+
const where = {};
|
|
546
|
+
if (severity)
|
|
547
|
+
where.severity = severity;
|
|
548
|
+
if (status)
|
|
549
|
+
where.status = status;
|
|
550
|
+
else
|
|
551
|
+
where.status = { in: ['open', 'recurring'] };
|
|
552
|
+
let issues = await db().seoIssue.findMany({
|
|
553
|
+
where,
|
|
554
|
+
orderBy: { lastDetectedAt: 'desc' },
|
|
555
|
+
take: 500,
|
|
556
|
+
});
|
|
557
|
+
if (issueIds?.length) {
|
|
558
|
+
const idSet = new Set(issueIds);
|
|
559
|
+
issues = issues.filter((i) => idSet.has(i.id));
|
|
560
|
+
}
|
|
561
|
+
const { supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
562
|
+
const eligible = issues.filter((i) => supportsContentBrief(i.type));
|
|
563
|
+
const entityIds = [
|
|
564
|
+
...new Set(eligible
|
|
565
|
+
.map((i) => i.entityId)
|
|
566
|
+
.filter((id) => typeof id === 'string' && id.length > 0)),
|
|
567
|
+
];
|
|
568
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
569
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
570
|
+
const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
|
|
571
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
572
|
+
const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
|
|
573
|
+
const contexts = new Map();
|
|
574
|
+
if (entityIds.length > 0) {
|
|
575
|
+
const docs = await db().document.findMany({
|
|
576
|
+
where: { id: { in: entityIds }, deletedAt: null },
|
|
577
|
+
select: {
|
|
578
|
+
id: true,
|
|
579
|
+
collection: true,
|
|
580
|
+
title: true,
|
|
581
|
+
slug: true,
|
|
582
|
+
data: true,
|
|
583
|
+
structuredData: true,
|
|
584
|
+
plainText: true,
|
|
585
|
+
publishedAt: true,
|
|
586
|
+
updatedAt: true,
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
for (const doc of docs) {
|
|
590
|
+
contexts.set(doc.id, {
|
|
591
|
+
collection: doc.collection,
|
|
592
|
+
entity: documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo),
|
|
593
|
+
updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const { batchGenerateContentBriefs } = await import('../../seo/issue-batch-briefs.js');
|
|
598
|
+
const result = await batchGenerateContentBriefs(eligible.map((i) => ({
|
|
599
|
+
id: i.id,
|
|
600
|
+
type: i.type,
|
|
601
|
+
title: i.title,
|
|
602
|
+
description: i.description,
|
|
603
|
+
recommendation: i.recommendation,
|
|
604
|
+
entityId: i.entityId,
|
|
605
|
+
entityType: i.entityType,
|
|
606
|
+
metadata: i.metadata,
|
|
607
|
+
})), contexts, { db: db(), userId: auth.session.userId, collection: 'pages' }, { skipExisting, includeResearch, competitorUrls });
|
|
608
|
+
return json({ data: result });
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
return internalError(err, 'seo/issues/batch-content-briefs');
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
router.get('/seo/issues/:id/content-brief', async (request, params) => {
|
|
615
|
+
try {
|
|
616
|
+
const auth = await requireAuth(request);
|
|
617
|
+
if (auth.error)
|
|
618
|
+
return auth.error;
|
|
619
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
620
|
+
return modelNotAvailable('SeoIssue');
|
|
621
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
622
|
+
if (!issue)
|
|
623
|
+
return errorResponse('Issue not found', 404);
|
|
624
|
+
const { supportsContentBrief, contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
|
|
625
|
+
if (!supportsContentBrief(issue.type)) {
|
|
626
|
+
return json({ data: { supported: false, brief: null, stale: false } });
|
|
627
|
+
}
|
|
628
|
+
if (!issue.entityId) {
|
|
629
|
+
return json({ data: { supported: true, brief: null, stale: false } });
|
|
630
|
+
}
|
|
631
|
+
const loaded = await loadIssueFixContext(issue);
|
|
632
|
+
if ('error' in loaded)
|
|
633
|
+
return loaded.error;
|
|
634
|
+
const brief = contentBriefFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
|
|
635
|
+
const raw = asRecord(issue.metadata)?.contentBrief;
|
|
636
|
+
const stale = !!raw &&
|
|
637
|
+
typeof raw === 'object' &&
|
|
638
|
+
!Array.isArray(raw) &&
|
|
639
|
+
typeof raw.fingerprint === 'string' &&
|
|
640
|
+
!brief;
|
|
641
|
+
return json({ data: { supported: true, brief, stale } });
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
return internalError(err, 'seo/issues/:id/content-brief');
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
router.post('/seo/issues/:id/content-brief', async (request, params) => {
|
|
648
|
+
try {
|
|
649
|
+
const auth = await requireAuth(request);
|
|
650
|
+
if (auth.error)
|
|
651
|
+
return auth.error;
|
|
652
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
653
|
+
if (roleErr)
|
|
654
|
+
return roleErr;
|
|
655
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
656
|
+
return modelNotAvailable('SeoIssue');
|
|
657
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-brief:${auth.session.userId}`))) {
|
|
658
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
659
|
+
}
|
|
660
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
661
|
+
if (!issue)
|
|
662
|
+
return errorResponse('Issue not found', 404);
|
|
663
|
+
const loaded = await loadIssueFixContext(issue);
|
|
664
|
+
if ('error' in loaded)
|
|
665
|
+
return loaded.error;
|
|
666
|
+
const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
667
|
+
if (!supportsContentBrief(issue.type)) {
|
|
668
|
+
return errorResponse('This issue type does not support content briefs.', 422);
|
|
669
|
+
}
|
|
670
|
+
const result = await generateContentBrief({
|
|
671
|
+
id: issue.id,
|
|
672
|
+
type: issue.type,
|
|
673
|
+
title: issue.title,
|
|
674
|
+
description: issue.description,
|
|
675
|
+
recommendation: issue.recommendation,
|
|
676
|
+
entityId: issue.entityId,
|
|
677
|
+
}, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection });
|
|
678
|
+
if (!result.ok) {
|
|
679
|
+
if (result.code === 'unavailable' || result.code === 'feature_disabled') {
|
|
680
|
+
return errorResponse(result.message, 503);
|
|
681
|
+
}
|
|
682
|
+
if (result.code === 'rate_limited') {
|
|
683
|
+
return errorResponse(result.message, 429);
|
|
684
|
+
}
|
|
685
|
+
return errorResponse(result.message, 422);
|
|
686
|
+
}
|
|
687
|
+
const existingMeta = asRecord(issue.metadata);
|
|
688
|
+
await db().seoIssue.update({
|
|
689
|
+
where: { id: issue.id },
|
|
690
|
+
data: { metadata: { ...existingMeta, ...contentBriefToMetadata(result.brief) } },
|
|
691
|
+
});
|
|
692
|
+
return json({ data: { brief: result.brief } });
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
return internalError(err, 'seo/issues/:id/content-brief');
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
router.get('/seo/issues/:id/content-research', async (request, params) => {
|
|
699
|
+
try {
|
|
700
|
+
const auth = await requireAuth(request);
|
|
701
|
+
if (auth.error)
|
|
702
|
+
return auth.error;
|
|
703
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
704
|
+
return modelNotAvailable('SeoIssue');
|
|
705
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
706
|
+
if (!issue)
|
|
707
|
+
return errorResponse('Issue not found', 404);
|
|
708
|
+
const { supportsContentResearch, contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
|
|
709
|
+
if (!supportsContentResearch(issue.type)) {
|
|
710
|
+
return json({ data: { supported: false, research: null, stale: false } });
|
|
711
|
+
}
|
|
712
|
+
if (!issue.entityId) {
|
|
713
|
+
return json({ data: { supported: true, research: null, stale: false } });
|
|
714
|
+
}
|
|
715
|
+
const loaded = await loadIssueFixContext(issue);
|
|
716
|
+
if ('error' in loaded)
|
|
717
|
+
return loaded.error;
|
|
718
|
+
const research = contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
|
|
719
|
+
const raw = asRecord(issue.metadata)?.contentResearch;
|
|
720
|
+
const stale = !!raw &&
|
|
721
|
+
typeof raw === 'object' &&
|
|
722
|
+
!Array.isArray(raw) &&
|
|
723
|
+
typeof raw.fingerprint === 'string' &&
|
|
724
|
+
!research;
|
|
725
|
+
return json({ data: { supported: true, research, stale } });
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
return internalError(err, 'seo/issues/:id/content-research');
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
router.post('/seo/issues/:id/content-research', async (request, params) => {
|
|
732
|
+
try {
|
|
733
|
+
const auth = await requireAuth(request);
|
|
734
|
+
if (auth.error)
|
|
735
|
+
return auth.error;
|
|
736
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
737
|
+
if (roleErr)
|
|
738
|
+
return roleErr;
|
|
739
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
740
|
+
return modelNotAvailable('SeoIssue');
|
|
741
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-research:${auth.session.userId}`))) {
|
|
742
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
743
|
+
}
|
|
744
|
+
const body = (await request.json().catch(() => ({})));
|
|
745
|
+
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
746
|
+
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
747
|
+
: undefined;
|
|
748
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
749
|
+
if (!issue)
|
|
750
|
+
return errorResponse('Issue not found', 404);
|
|
751
|
+
const loaded = await loadIssueFixContext(issue);
|
|
752
|
+
if ('error' in loaded)
|
|
753
|
+
return loaded.error;
|
|
754
|
+
const { generateContentResearch, contentResearchToMetadata, supportsContentResearch } = await import('../../seo/issue-content-research.js');
|
|
755
|
+
if (!supportsContentResearch(issue.type)) {
|
|
756
|
+
return errorResponse('This issue type does not support topic research.', 422);
|
|
757
|
+
}
|
|
758
|
+
const result = await generateContentResearch({
|
|
759
|
+
id: issue.id,
|
|
760
|
+
type: issue.type,
|
|
761
|
+
title: issue.title,
|
|
762
|
+
description: issue.description,
|
|
763
|
+
recommendation: issue.recommendation,
|
|
764
|
+
entityId: issue.entityId,
|
|
765
|
+
}, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection }, { competitorUrls });
|
|
766
|
+
if (!result.ok) {
|
|
767
|
+
if (result.code === 'unavailable' || result.code === 'feature_disabled') {
|
|
768
|
+
return errorResponse(result.message, 503);
|
|
769
|
+
}
|
|
770
|
+
if (result.code === 'rate_limited') {
|
|
771
|
+
return errorResponse(result.message, 429);
|
|
772
|
+
}
|
|
773
|
+
return errorResponse(result.message, 422);
|
|
774
|
+
}
|
|
775
|
+
const existingMeta = asRecord(issue.metadata);
|
|
776
|
+
await db().seoIssue.update({
|
|
777
|
+
where: { id: issue.id },
|
|
778
|
+
data: { metadata: { ...existingMeta, ...contentResearchToMetadata(result.research) } },
|
|
779
|
+
});
|
|
780
|
+
return json({ data: { research: result.research } });
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
return internalError(err, 'seo/issues/:id/content-research');
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
router.post('/seo/issues/:id/research-and-brief', async (request, params) => {
|
|
787
|
+
try {
|
|
788
|
+
const auth = await requireAuth(request);
|
|
789
|
+
if (auth.error)
|
|
790
|
+
return auth.error;
|
|
791
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
792
|
+
if (roleErr)
|
|
793
|
+
return roleErr;
|
|
794
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
795
|
+
return modelNotAvailable('SeoIssue');
|
|
796
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-research-brief:${auth.session.userId}`))) {
|
|
797
|
+
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
798
|
+
}
|
|
799
|
+
const body = (await request.json().catch(() => ({})));
|
|
800
|
+
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
801
|
+
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
802
|
+
: undefined;
|
|
803
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
804
|
+
if (!issue)
|
|
805
|
+
return errorResponse('Issue not found', 404);
|
|
806
|
+
const loaded = await loadIssueFixContext(issue);
|
|
807
|
+
if ('error' in loaded)
|
|
808
|
+
return loaded.error;
|
|
809
|
+
const { generateContentResearch, contentResearchToMetadata, supportsContentResearch, contentResearchFromMetadata, } = await import('../../seo/issue-content-research.js');
|
|
810
|
+
const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
811
|
+
if (!supportsContentBrief(issue.type)) {
|
|
812
|
+
return errorResponse('This issue type does not support content briefs.', 422);
|
|
813
|
+
}
|
|
814
|
+
const issueInput = {
|
|
815
|
+
id: issue.id,
|
|
816
|
+
type: issue.type,
|
|
817
|
+
title: issue.title,
|
|
818
|
+
description: issue.description,
|
|
819
|
+
recommendation: issue.recommendation,
|
|
820
|
+
entityId: issue.entityId,
|
|
821
|
+
};
|
|
822
|
+
const briefCtx = { entity: loaded.entity, updatedAt: loaded.updatedAt };
|
|
823
|
+
const deps = { db: db(), userId: auth.session.userId, collection: loaded.collection };
|
|
824
|
+
let research = supportsContentResearch(issue.type)
|
|
825
|
+
? contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt)
|
|
826
|
+
: null;
|
|
827
|
+
if (supportsContentResearch(issue.type) && !research) {
|
|
828
|
+
const researchResult = await generateContentResearch(issueInput, briefCtx, deps, {
|
|
829
|
+
competitorUrls,
|
|
830
|
+
});
|
|
831
|
+
if (!researchResult.ok) {
|
|
832
|
+
if (researchResult.code === 'unavailable' || researchResult.code === 'feature_disabled') {
|
|
833
|
+
return errorResponse(researchResult.message, 503);
|
|
834
|
+
}
|
|
835
|
+
if (researchResult.code === 'rate_limited') {
|
|
836
|
+
return errorResponse(researchResult.message, 429);
|
|
837
|
+
}
|
|
838
|
+
return errorResponse(researchResult.message, 422);
|
|
839
|
+
}
|
|
840
|
+
research = researchResult.research;
|
|
841
|
+
}
|
|
842
|
+
const briefResult = await generateContentBrief(issueInput, briefCtx, deps, { research });
|
|
843
|
+
if (!briefResult.ok) {
|
|
844
|
+
if (briefResult.code === 'unavailable' || briefResult.code === 'feature_disabled') {
|
|
845
|
+
return errorResponse(briefResult.message, 503);
|
|
846
|
+
}
|
|
847
|
+
if (briefResult.code === 'rate_limited') {
|
|
848
|
+
return errorResponse(briefResult.message, 429);
|
|
849
|
+
}
|
|
850
|
+
return errorResponse(briefResult.message, 422);
|
|
851
|
+
}
|
|
852
|
+
const existingMeta = asRecord(issue.metadata);
|
|
853
|
+
const metadata = {
|
|
854
|
+
...existingMeta,
|
|
855
|
+
...contentBriefToMetadata(briefResult.brief),
|
|
856
|
+
...(research ? contentResearchToMetadata(research) : {}),
|
|
857
|
+
};
|
|
858
|
+
await db().seoIssue.update({
|
|
859
|
+
where: { id: issue.id },
|
|
860
|
+
data: { metadata },
|
|
861
|
+
});
|
|
862
|
+
return json({ data: { research, brief: briefResult.brief } });
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
return internalError(err, 'seo/issues/:id/research-and-brief');
|
|
866
|
+
}
|
|
867
|
+
});
|
|
394
868
|
router.get('/seo/issues/:id', async (request, params) => {
|
|
395
869
|
try {
|
|
396
870
|
const auth = await requireAuth(request);
|
|
@@ -442,6 +916,208 @@ export function registerSeoRoutes(router) {
|
|
|
442
916
|
router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
|
|
443
917
|
router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
|
|
444
918
|
router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
|
|
919
|
+
/** Load the audit entity + collection for an issue's linked document. */
|
|
920
|
+
async function loadIssueFixContext(issue) {
|
|
921
|
+
if (!issue.entityId) {
|
|
922
|
+
return { error: errorResponse('This issue is not linked to a content item.', 422) };
|
|
923
|
+
}
|
|
924
|
+
const doc = await db().document.findFirst({
|
|
925
|
+
where: { id: issue.entityId, deletedAt: null },
|
|
926
|
+
select: {
|
|
927
|
+
id: true,
|
|
928
|
+
collection: true,
|
|
929
|
+
title: true,
|
|
930
|
+
slug: true,
|
|
931
|
+
data: true,
|
|
932
|
+
structuredData: true,
|
|
933
|
+
plainText: true,
|
|
934
|
+
publishedAt: true,
|
|
935
|
+
updatedAt: true,
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
if (!doc)
|
|
939
|
+
return { error: errorResponse('Content not found', 404) };
|
|
940
|
+
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
941
|
+
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
942
|
+
const collectionTypes = frontEndContentCollectionTypes(getActuateConfig());
|
|
943
|
+
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
944
|
+
const mergedConfig = applySeoOverrides(getActuateConfig(), overrides);
|
|
945
|
+
const entity = documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo);
|
|
946
|
+
if (issue.entityType && entity.entityType !== issue.entityType) {
|
|
947
|
+
return { error: errorResponse('Issue entity type does not match the linked document.', 409) };
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
collection: doc.collection,
|
|
951
|
+
entity,
|
|
952
|
+
updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
|
|
953
|
+
siteSeo: mergedConfig?.seo,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
router.post('/seo/issues/:id/suggest-fix', async (request, params) => {
|
|
957
|
+
try {
|
|
958
|
+
const auth = await requireAuth(request);
|
|
959
|
+
if (auth.error)
|
|
960
|
+
return auth.error;
|
|
961
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
962
|
+
return modelNotAvailable('SeoIssue');
|
|
963
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
964
|
+
if (!issue)
|
|
965
|
+
return errorResponse('Issue not found', 404);
|
|
966
|
+
const loaded = await loadIssueFixContext(issue);
|
|
967
|
+
if ('error' in loaded)
|
|
968
|
+
return loaded.error;
|
|
969
|
+
const issueInput = {
|
|
970
|
+
type: issue.type,
|
|
971
|
+
title: issue.title,
|
|
972
|
+
recommendation: issue.recommendation,
|
|
973
|
+
entityType: issue.entityType,
|
|
974
|
+
entityId: issue.entityId,
|
|
975
|
+
};
|
|
976
|
+
const fixCtx = {
|
|
977
|
+
entity: loaded.entity,
|
|
978
|
+
siteSeo: loaded.siteSeo,
|
|
979
|
+
updatedAt: loaded.updatedAt,
|
|
980
|
+
};
|
|
981
|
+
let aiDeps;
|
|
982
|
+
const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
983
|
+
if (!adminErr &&
|
|
984
|
+
(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
|
|
985
|
+
aiDeps = {
|
|
986
|
+
db: db(),
|
|
987
|
+
userId: auth.session.userId,
|
|
988
|
+
collection: loaded.collection,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const { suggestIssueFix } = await import('../../seo/issue-fix.js');
|
|
992
|
+
const { pendingFixToMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
993
|
+
const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
|
|
994
|
+
const existingMeta = asRecord(issue.metadata);
|
|
995
|
+
const nextMetadata = result.cacheForApply
|
|
996
|
+
? { ...existingMeta, ...pendingFixToMetadata(result.suggestion) }
|
|
997
|
+
: { ...existingMeta, pendingFix: null };
|
|
998
|
+
await db().seoIssue.update({
|
|
999
|
+
where: { id: issue.id },
|
|
1000
|
+
data: { metadata: nextMetadata },
|
|
1001
|
+
});
|
|
1002
|
+
return json({ data: { suggestion: result.suggestion } });
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
return internalError(err, 'seo/issues/:id/suggest-fix');
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
router.post('/seo/issues/:id/apply-fix', async (request, params) => {
|
|
1009
|
+
try {
|
|
1010
|
+
const auth = await requireAuth(request);
|
|
1011
|
+
if (auth.error)
|
|
1012
|
+
return auth.error;
|
|
1013
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1014
|
+
if (roleErr)
|
|
1015
|
+
return roleErr;
|
|
1016
|
+
if (!hasModel(db(), 'seoIssue'))
|
|
1017
|
+
return modelNotAvailable('SeoIssue');
|
|
1018
|
+
const body = (await request.json().catch(() => ({})));
|
|
1019
|
+
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1020
|
+
if (!issue)
|
|
1021
|
+
return errorResponse('Issue not found', 404);
|
|
1022
|
+
if (issue.status === 'ignored') {
|
|
1023
|
+
return errorResponse('Cannot apply a fix to an ignored issue. Reopen it first.', 409);
|
|
1024
|
+
}
|
|
1025
|
+
const loaded = await loadIssueFixContext(issue);
|
|
1026
|
+
if ('error' in loaded)
|
|
1027
|
+
return loaded.error;
|
|
1028
|
+
const issueInput = {
|
|
1029
|
+
type: issue.type,
|
|
1030
|
+
title: issue.title,
|
|
1031
|
+
recommendation: issue.recommendation,
|
|
1032
|
+
entityType: issue.entityType,
|
|
1033
|
+
entityId: issue.entityId,
|
|
1034
|
+
};
|
|
1035
|
+
const fixCtx = {
|
|
1036
|
+
entity: loaded.entity,
|
|
1037
|
+
siteSeo: loaded.siteSeo,
|
|
1038
|
+
updatedAt: loaded.updatedAt,
|
|
1039
|
+
};
|
|
1040
|
+
const { suggestDeterministicFix, buildFixFingerprint } = await import('../../seo/issue-fix.js');
|
|
1041
|
+
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1042
|
+
const deterministic = suggestDeterministicFix(issueInput, fixCtx);
|
|
1043
|
+
let suggestion = deterministic;
|
|
1044
|
+
if (!deterministic.autoFixable || Object.keys(deterministic.patch).length === 0) {
|
|
1045
|
+
const cached = pendingFixFromMetadata(issue.metadata);
|
|
1046
|
+
const liveFingerprint = buildFixFingerprint(loaded.entity.entityId, issue.type, loaded.updatedAt);
|
|
1047
|
+
if (!cached || !cached.autoFixable) {
|
|
1048
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1049
|
+
}
|
|
1050
|
+
if (body?.fingerprint && body.fingerprint !== cached.fingerprint) {
|
|
1051
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1052
|
+
}
|
|
1053
|
+
if (cached.fingerprint !== liveFingerprint) {
|
|
1054
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1055
|
+
}
|
|
1056
|
+
suggestion = {
|
|
1057
|
+
autoFixable: true,
|
|
1058
|
+
source: 'ai',
|
|
1059
|
+
fingerprint: cached.fingerprint,
|
|
1060
|
+
patch: cached.patch,
|
|
1061
|
+
changes: cached.changes,
|
|
1062
|
+
justification: cached.justification,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
else if (body?.fingerprint && body.fingerprint !== suggestion.fingerprint) {
|
|
1066
|
+
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
1067
|
+
}
|
|
1068
|
+
if (!suggestion.autoFixable || Object.keys(suggestion.patch).length === 0) {
|
|
1069
|
+
return errorResponse('This issue does not support an automatic fix.', 422);
|
|
1070
|
+
}
|
|
1071
|
+
const ctx = buildActionContext(auth.session, db());
|
|
1072
|
+
try {
|
|
1073
|
+
await updateDocumentSeoFields(loaded.collection, loaded.entity.entityId, suggestion.patch, ctx);
|
|
1074
|
+
}
|
|
1075
|
+
catch (err) {
|
|
1076
|
+
const msg = err instanceof Error ? err.message : '';
|
|
1077
|
+
if (msg.includes('Access denied'))
|
|
1078
|
+
return errorResponse('Insufficient permissions', 403);
|
|
1079
|
+
if (msg.includes('not found'))
|
|
1080
|
+
return errorResponse('Document not found', 404);
|
|
1081
|
+
throw err;
|
|
1082
|
+
}
|
|
1083
|
+
const existingMeta = asRecord(issue.metadata);
|
|
1084
|
+
const resolved = await db().seoIssue.update({
|
|
1085
|
+
where: { id: issue.id },
|
|
1086
|
+
data: {
|
|
1087
|
+
status: 'resolved',
|
|
1088
|
+
resolvedAt: new Date(),
|
|
1089
|
+
resolvedById: auth.session.userId,
|
|
1090
|
+
metadata: { ...existingMeta, pendingFix: null },
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
try {
|
|
1094
|
+
await logEvent({
|
|
1095
|
+
event: 'document_updated',
|
|
1096
|
+
userId: auth.session.userId,
|
|
1097
|
+
details: {
|
|
1098
|
+
action: 'seo_issue_fix_applied',
|
|
1099
|
+
issueId: issue.id,
|
|
1100
|
+
issueType: issue.type,
|
|
1101
|
+
entityId: loaded.entity.entityId,
|
|
1102
|
+
fields: Object.keys(suggestion.patch),
|
|
1103
|
+
source: suggestion.source ?? 'deterministic',
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
catch {
|
|
1108
|
+
/* noop */
|
|
1109
|
+
}
|
|
1110
|
+
return json({
|
|
1111
|
+
data: {
|
|
1112
|
+
issue: seoIssueToApi(resolved),
|
|
1113
|
+
applied: suggestion.changes,
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
catch (err) {
|
|
1118
|
+
return internalError(err, 'seo/issues/:id/apply-fix');
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
445
1121
|
router.get('/seo/overview', async (request) => {
|
|
446
1122
|
try {
|
|
447
1123
|
const auth = await requireAuth(request);
|