@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/actions/document-crud.test.js +50 -0
  3. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  4. package/dist/__tests__/api/collection-read-access.test.d.ts +2 -0
  5. package/dist/__tests__/api/collection-read-access.test.d.ts.map +1 -0
  6. package/dist/__tests__/api/collection-read-access.test.js +140 -0
  7. package/dist/__tests__/api/collection-read-access.test.js.map +1 -0
  8. package/dist/__tests__/api/route-contracts.test.js +9 -0
  9. package/dist/__tests__/api/route-contracts.test.js.map +1 -1
  10. package/dist/__tests__/seo/fix-brief-export.test.d.ts +2 -0
  11. package/dist/__tests__/seo/fix-brief-export.test.d.ts.map +1 -0
  12. package/dist/__tests__/seo/fix-brief-export.test.js +153 -0
  13. package/dist/__tests__/seo/fix-brief-export.test.js.map +1 -0
  14. package/dist/__tests__/seo/issue-batch-briefs.test.d.ts +2 -0
  15. package/dist/__tests__/seo/issue-batch-briefs.test.d.ts.map +1 -0
  16. package/dist/__tests__/seo/issue-batch-briefs.test.js +64 -0
  17. package/dist/__tests__/seo/issue-batch-briefs.test.js.map +1 -0
  18. package/dist/__tests__/seo/issue-content-brief.test.d.ts +2 -0
  19. package/dist/__tests__/seo/issue-content-brief.test.d.ts.map +1 -0
  20. package/dist/__tests__/seo/issue-content-brief.test.js +107 -0
  21. package/dist/__tests__/seo/issue-content-brief.test.js.map +1 -0
  22. package/dist/__tests__/seo/issue-content-research.test.d.ts +2 -0
  23. package/dist/__tests__/seo/issue-content-research.test.d.ts.map +1 -0
  24. package/dist/__tests__/seo/issue-content-research.test.js +114 -0
  25. package/dist/__tests__/seo/issue-content-research.test.js.map +1 -0
  26. package/dist/__tests__/seo/issue-fix-ai.test.d.ts +2 -0
  27. package/dist/__tests__/seo/issue-fix-ai.test.d.ts.map +1 -0
  28. package/dist/__tests__/seo/issue-fix-ai.test.js +96 -0
  29. package/dist/__tests__/seo/issue-fix-ai.test.js.map +1 -0
  30. package/dist/__tests__/seo/issue-fix-orchestrator.test.d.ts +2 -0
  31. package/dist/__tests__/seo/issue-fix-orchestrator.test.d.ts.map +1 -0
  32. package/dist/__tests__/seo/issue-fix-orchestrator.test.js +48 -0
  33. package/dist/__tests__/seo/issue-fix-orchestrator.test.js.map +1 -0
  34. package/dist/__tests__/seo/issue-fix.test.d.ts +2 -0
  35. package/dist/__tests__/seo/issue-fix.test.d.ts.map +1 -0
  36. package/dist/__tests__/seo/issue-fix.test.js +98 -0
  37. package/dist/__tests__/seo/issue-fix.test.js.map +1 -0
  38. package/dist/actions.d.ts.map +1 -1
  39. package/dist/actions.js +5 -0
  40. package/dist/actions.js.map +1 -1
  41. package/dist/api/routes/documents.js +4 -4
  42. package/dist/api/routes/documents.js.map +1 -1
  43. package/dist/api/routes/seo.d.ts.map +1 -1
  44. package/dist/api/routes/seo.js +677 -1
  45. package/dist/api/routes/seo.js.map +1 -1
  46. package/dist/seo/fix-brief-export.d.ts +47 -0
  47. package/dist/seo/fix-brief-export.d.ts.map +1 -0
  48. package/dist/seo/fix-brief-export.js +224 -0
  49. package/dist/seo/fix-brief-export.js.map +1 -0
  50. package/dist/seo/index.d.ts +12 -0
  51. package/dist/seo/index.d.ts.map +1 -1
  52. package/dist/seo/index.js +6 -0
  53. package/dist/seo/index.js.map +1 -1
  54. package/dist/seo/issue-batch-briefs.d.ts +37 -0
  55. package/dist/seo/issue-batch-briefs.d.ts.map +1 -0
  56. package/dist/seo/issue-batch-briefs.js +85 -0
  57. package/dist/seo/issue-batch-briefs.js.map +1 -0
  58. package/dist/seo/issue-content-brief.d.ts +51 -0
  59. package/dist/seo/issue-content-brief.d.ts.map +1 -0
  60. package/dist/seo/issue-content-brief.js +265 -0
  61. package/dist/seo/issue-content-brief.js.map +1 -0
  62. package/dist/seo/issue-content-research.d.ts +51 -0
  63. package/dist/seo/issue-content-research.d.ts.map +1 -0
  64. package/dist/seo/issue-content-research.js +301 -0
  65. package/dist/seo/issue-content-research.js.map +1 -0
  66. package/dist/seo/issue-fix-ai.d.ts +39 -0
  67. package/dist/seo/issue-fix-ai.d.ts.map +1 -0
  68. package/dist/seo/issue-fix-ai.js +298 -0
  69. package/dist/seo/issue-fix-ai.js.map +1 -0
  70. package/dist/seo/issue-fix.d.ts +68 -0
  71. package/dist/seo/issue-fix.d.ts.map +1 -0
  72. package/dist/seo/issue-fix.js +244 -0
  73. package/dist/seo/issue-fix.js.map +1 -0
  74. package/package.json +1 -1
@@ -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);