@prenta/admin 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/__tests__/lib/api.test.d.ts +2 -0
  3. package/dist/__tests__/lib/api.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/api.test.js +58 -0
  5. package/dist/__tests__/lib/api.test.js.map +1 -0
  6. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
  9. package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
  10. package/dist/__tests__/lib/seo-service-proposals.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-proposals.test.js +99 -0
  13. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
  14. package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
  15. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  16. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  17. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  21. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  25. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  26. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts +2 -0
  27. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
  28. package/dist/__tests__/views/seo-proposals-tab.render.test.js +431 -0
  29. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
  30. package/dist/__tests__/views/seo-settings.render.test.js +41 -4
  31. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  32. package/dist/components/seo/ChangePreview.d.ts +6 -0
  33. package/dist/components/seo/ChangePreview.d.ts.map +1 -0
  34. package/dist/components/seo/ChangePreview.js +18 -0
  35. package/dist/components/seo/ChangePreview.js.map +1 -0
  36. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  37. package/dist/components/seo/SeoIssueFixPanel.js +1 -14
  38. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  39. package/dist/lib/api.d.ts +13 -2
  40. package/dist/lib/api.d.ts.map +1 -1
  41. package/dist/lib/api.js +1 -2
  42. package/dist/lib/api.js.map +1 -1
  43. package/dist/lib/seo-service.d.ts +293 -2
  44. package/dist/lib/seo-service.d.ts.map +1 -1
  45. package/dist/lib/seo-service.js +170 -0
  46. package/dist/lib/seo-service.js.map +1 -1
  47. package/dist/prenta-admin.css +1 -1
  48. package/dist/views/SEO.d.ts +5 -1
  49. package/dist/views/SEO.d.ts.map +1 -1
  50. package/dist/views/SEO.js +28 -9
  51. package/dist/views/SEO.js.map +1 -1
  52. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  53. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  54. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  55. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  56. package/dist/views/seo/ProposalsTab.d.ts +7 -0
  57. package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
  58. package/dist/views/seo/ProposalsTab.js +334 -0
  59. package/dist/views/seo/ProposalsTab.js.map +1 -0
  60. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  61. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  62. package/dist/views/seo/RedirectsTab.js +14 -19
  63. package/dist/views/seo/RedirectsTab.js.map +1 -1
  64. package/dist/views/seo/relative-age.d.ts +7 -0
  65. package/dist/views/seo/relative-age.d.ts.map +1 -0
  66. package/dist/views/seo/relative-age.js +20 -0
  67. package/dist/views/seo/relative-age.js.map +1 -0
  68. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  69. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  70. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  71. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  72. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  73. package/dist/views/settings/SeoSettingsTab.js +2 -1
  74. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  75. package/dist/views/settings/components.d.ts +3 -1
  76. package/dist/views/settings/components.d.ts.map +1 -1
  77. package/dist/views/settings/components.js +2 -2
  78. package/dist/views/settings/components.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/__tests__/lib/api.test.ts +76 -0
  81. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  82. package/src/__tests__/lib/seo-service-proposals.test.ts +108 -0
  83. package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
  84. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  85. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  86. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  87. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +531 -0
  88. package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
  89. package/src/components/seo/ChangePreview.tsx +47 -0
  90. package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
  91. package/src/lib/api.ts +19 -3
  92. package/src/lib/seo-service.ts +455 -2
  93. package/src/views/SEO.tsx +41 -8
  94. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  95. package/src/views/seo/ProposalsTab.tsx +761 -0
  96. package/src/views/seo/RedirectsTab.tsx +39 -60
  97. package/src/views/seo/relative-age.ts +15 -0
  98. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  99. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  100. package/src/views/settings/components.tsx +10 -1
@@ -16,12 +16,12 @@ import {
16
16
  supportsSeoContentResearch,
17
17
  type SeoContentBrief,
18
18
  type SeoContentResearch,
19
- type SeoFixFieldChange,
20
19
  type SeoIssue,
21
20
  type SeoIssueFixSuggestion,
22
21
  } from '../../lib/seo-service.js'
23
22
  import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
24
23
  import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
24
+ import { ChangePreview } from './ChangePreview.js'
25
25
  import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
26
26
 
27
27
  function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
@@ -56,49 +56,6 @@ function approveSuccessToast(strategy: SeoIssueFixSuggestion['fixStrategy']): st
56
56
  }
57
57
  }
58
58
 
59
- function formatValue(value: string | boolean | null): string {
60
- if (value === null || value === '') return '—'
61
- if (typeof value === 'boolean') return value ? 'Yes' : 'No'
62
- if (value.length > 280) return `${value.slice(0, 277)}…`
63
- return value
64
- }
65
-
66
- function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
67
- if (changes.length === 0) return null
68
- return (
69
- <div className="border-border overflow-x-auto rounded-md border">
70
- <table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
71
- <thead>
72
- <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
73
- <th scope="col" className="py-2 pr-3 pl-3 font-medium">
74
- Field
75
- </th>
76
- <th scope="col" className="py-2 pr-3 font-medium">
77
- Current
78
- </th>
79
- <th scope="col" className="py-2 pr-3 font-medium">
80
- Proposed
81
- </th>
82
- </tr>
83
- </thead>
84
- <tbody className="divide-border divide-y">
85
- {changes.map((c) => (
86
- <tr key={c.field}>
87
- <td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
88
- <td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
89
- {formatValue(c.before)}
90
- </td>
91
- <td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
92
- {formatValue(c.after)}
93
- </td>
94
- </tr>
95
- ))}
96
- </tbody>
97
- </table>
98
- </div>
99
- )
100
- }
101
-
102
59
  function ContentResearchCard({ research }: { research: SeoContentResearch }) {
103
60
  return (
104
61
  <div className="border-border bg-card space-y-3 rounded-md border p-4">
package/src/lib/api.ts CHANGED
@@ -62,10 +62,27 @@ function appendLocaleParam(endpoint: string): string {
62
62
  return `${endpoint}${separator}locale=${encodeURIComponent(locale)}`
63
63
  }
64
64
 
65
+ export interface CmsApiResult<T> {
66
+ data?: T
67
+ error?: string
68
+ /**
69
+ * Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK, conflict) set
70
+ * by guard responses so callers can branch without parsing the message.
71
+ */
72
+ code?: string
73
+ /**
74
+ * The parsed non-2xx body, for the few endpoints that return structured
75
+ * detail beyond `error` / `code` (e.g. the 409 `fields` list from
76
+ * `/seo/autopilot/applied/:id/revert`). Callers must narrow it themselves.
77
+ */
78
+ errorBody?: unknown
79
+ status: number
80
+ }
81
+
65
82
  export async function cmsApi<T = unknown>(
66
83
  endpoint: string,
67
84
  options: RequestInit = {},
68
- ): Promise<{ data?: T; error?: string; code?: string; status: number }> {
85
+ ): Promise<CmsApiResult<T>> {
69
86
  const url = `${basePath}${appendLocaleParam(endpoint)}`
70
87
  const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
71
88
 
@@ -98,9 +115,8 @@ export async function cmsApi<T = unknown>(
98
115
  if (!res.ok) {
99
116
  return {
100
117
  error: json.error || `Request failed (${res.status})`,
101
- // Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK) set by
102
- // guard responses so callers can branch without parsing the message.
103
118
  code: typeof json.code === 'string' ? json.code : undefined,
119
+ errorBody: json,
104
120
  status: res.status,
105
121
  }
106
122
  }
@@ -113,6 +113,8 @@ export interface SeoOverview {
113
113
  lastAuditRunId: string | null
114
114
  /** True when the last audit hit AUDIT_MAX_ENTITIES. */
115
115
  auditTruncated?: boolean
116
+ /** Cached AI issue fixes + open redirect suggestions awaiting review. */
117
+ pendingProposals?: { issueFixes: number; redirects: number }
116
118
  }
117
119
 
118
120
  const EMPTY_OVERVIEW: SeoOverview = {
@@ -826,6 +828,295 @@ export async function sendTestDigest(): Promise<{ ok: boolean; to?: string; erro
826
828
  return { ok: true, to: res.data?.to }
827
829
  }
828
830
 
831
+ // ─── SEO Autopilot ───────────────────────────────────────────────────
832
+ // Mirrors `packages/cms-core/src/seo/autopilot-policy.ts` (`SeoAutopilotPolicy`,
833
+ // `AutopilotRunSummary`) and the `/seo/autopilot/*` routes. The server is the
834
+ // contract; keep these shapes in lock-step with it.
835
+
836
+ export type SeoAutopilotIssueType =
837
+ | 'missing-meta-title'
838
+ | 'meta-title-length'
839
+ | 'duplicate-meta-title'
840
+ | 'missing-meta-description'
841
+ | 'meta-description-length'
842
+ | 'duplicate-meta-description'
843
+ | 'missing-canonical'
844
+ | 'accidental-noindex'
845
+ | 'missing-structured-data'
846
+ | 'redirect'
847
+
848
+ export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
849
+ 'missing-meta-title',
850
+ 'meta-title-length',
851
+ 'duplicate-meta-title',
852
+ 'missing-meta-description',
853
+ 'meta-description-length',
854
+ 'duplicate-meta-description',
855
+ 'missing-canonical',
856
+ 'accidental-noindex',
857
+ 'missing-structured-data',
858
+ 'redirect',
859
+ ]
860
+
861
+ export type SeoAutopilotPauseReason = 'outcome_regression' | 'owner_unavailable' | 'manual'
862
+
863
+ /**
864
+ * Run-level stop reasons the engine reports. `already_running` is
865
+ * informational (another tick held the run lease; nothing was written); the
866
+ * rest are warnings. The engine never stores raw error messages; a throwing
867
+ * phase is reported as the coded `apply_failed` / `run_failed`.
868
+ */
869
+ export type SeoAutopilotHaltReason =
870
+ | 'disabled'
871
+ | 'paused'
872
+ | 'owner_unavailable'
873
+ | 'already_running'
874
+ | 'verify_failures'
875
+ | 'budget_exhausted'
876
+ | 'ledger_write_failed'
877
+ | 'apply_failed'
878
+ | 'run_failed'
879
+
880
+ export interface SeoAutopilotRunSummary {
881
+ startedAt: string
882
+ durationMs: number
883
+ applied: number
884
+ verifyFailed: number
885
+ /** Outcome-watch reverts this tick. */
886
+ reverted: number
887
+ /** Gate reason → count (`policy_disabled`, `cap_per_run`, `brand_below_threshold`, …). */
888
+ gated: Record<string, number>
889
+ /**
890
+ * Non-applied apply statuses plus run-level counters: `error`,
891
+ * `gate_mismatch`, `after_unreadable`, `outcome_watch_error`,
892
+ * `outcome_revert_error`, `budget_exhausted`.
893
+ */
894
+ applyOutcomes: Record<string, number>
895
+ generated: { generated: number; skipped: number; failed: number; haltedReason: string | null }
896
+ halted: boolean
897
+ /** A {@link SeoAutopilotHaltReason}; unknown strings come from a newer core and are shown verbatim. */
898
+ haltedReason: string | null
899
+ }
900
+
901
+ export interface SeoAutopilotOutcomeWatch {
902
+ enabled: boolean
903
+ days: number
904
+ dropRatio: number
905
+ minPriorClicks: number
906
+ }
907
+
908
+ export interface SeoAutopilotGovernance {
909
+ /** Settings → AI "Requires approval" on the issue-fix feature vetoes every metadata type. */
910
+ issueFixApprovalRequired: boolean
911
+ /** Same veto for 404-recovery redirects. */
912
+ redirectApprovalRequired: boolean
913
+ }
914
+
915
+ export interface SeoAutopilotSettings {
916
+ enabled: boolean
917
+ enabledById: string | null
918
+ pausedAt: string | null
919
+ pausedReason: SeoAutopilotPauseReason | null
920
+ types: Partial<Record<SeoAutopilotIssueType, boolean>>
921
+ pausedTypes: Partial<Record<SeoAutopilotIssueType, string>>
922
+ minBrandAlignment: number
923
+ redirectMinConfidence: number
924
+ maxPerRun: number
925
+ maxPerDay: number
926
+ verifyFailuresPerRun: number
927
+ outcomeWatch: SeoAutopilotOutcomeWatch
928
+ lastRunAt: string | null
929
+ lastRun: SeoAutopilotRunSummary | null
930
+ governance: SeoAutopilotGovernance
931
+ }
932
+
933
+ /** Server-side ranges (`AUTOPILOT_LIMITS`) — mirrored so the card clamps before it PUTs. */
934
+ export const SEO_AUTOPILOT_LIMITS = {
935
+ maxPerRun: { min: 1, max: 25 },
936
+ maxPerDay: { min: 1, max: 100 },
937
+ verifyFailuresPerRun: { min: 1, max: 10 },
938
+ minBrandAlignment: { min: 0, max: 100 },
939
+ redirectMinConfidence: { min: 50, max: 100 },
940
+ outcomeDays: { min: 7, max: 28 },
941
+ /** Whole percentages; the API takes a 0.2–0.8 ratio. */
942
+ dropPercent: { min: 20, max: 80 },
943
+ minPriorClicks: { min: 0, max: 100_000 },
944
+ } as const
945
+
946
+ /**
947
+ * A sparse PUT body: only the keys sent are written. `outcomeWatch` is itself
948
+ * partial. `resume` clears the engine-level pause; `resumeTypes` clears
949
+ * per-type pauses. Owner and pause state are never accepted from the client.
950
+ */
951
+ export type SeoAutopilotSettingsPatch = Partial<
952
+ Pick<
953
+ SeoAutopilotSettings,
954
+ | 'enabled'
955
+ | 'types'
956
+ | 'minBrandAlignment'
957
+ | 'redirectMinConfidence'
958
+ | 'maxPerRun'
959
+ | 'maxPerDay'
960
+ | 'verifyFailuresPerRun'
961
+ >
962
+ > & {
963
+ outcomeWatch?: Partial<SeoAutopilotOutcomeWatch>
964
+ resume?: boolean
965
+ resumeTypes?: SeoAutopilotIssueType[]
966
+ }
967
+
968
+ function isRecord(v: unknown): v is Record<string, unknown> {
969
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
970
+ }
971
+
972
+ /**
973
+ * Validate the settings payload before handing it to the card: an unexpected
974
+ * body must render the error state, not crash on `settings.types[...]`.
975
+ * `governance` is normalised defensively (missing → no veto).
976
+ */
977
+ function normalizeAutopilotSettings(raw: unknown): SeoAutopilotSettings | null {
978
+ if (!isRecord(raw)) return null
979
+ if (typeof raw.enabled !== 'boolean' || !isRecord(raw.types) || !isRecord(raw.outcomeWatch)) {
980
+ return null
981
+ }
982
+ const d = raw as unknown as SeoAutopilotSettings
983
+ const gov = isRecord(raw.governance) ? raw.governance : {}
984
+ return {
985
+ ...d,
986
+ pausedTypes: isRecord(raw.pausedTypes) ? d.pausedTypes : {},
987
+ lastRunAt: typeof raw.lastRunAt === 'string' ? raw.lastRunAt : null,
988
+ lastRun: isRecord(raw.lastRun) ? d.lastRun : null,
989
+ governance: {
990
+ issueFixApprovalRequired: gov.issueFixApprovalRequired === true,
991
+ redirectApprovalRequired: gov.redirectApprovalRequired === true,
992
+ },
993
+ }
994
+ }
995
+
996
+ export async function fetchAutopilotSettings(): Promise<
997
+ { settings: SeoAutopilotSettings } | { settings: null; error: string }
998
+ > {
999
+ const res = await cmsApi<unknown>('/seo/autopilot/settings')
1000
+ if (res.error) return { settings: null, error: res.error }
1001
+ const settings = normalizeAutopilotSettings(res.data)
1002
+ if (!settings) return { settings: null, error: 'Unexpected autopilot settings response' }
1003
+ return { settings }
1004
+ }
1005
+
1006
+ export async function updateAutopilotSettings(
1007
+ patch: SeoAutopilotSettingsPatch,
1008
+ ): Promise<{ ok: boolean; error?: string; settings?: SeoAutopilotSettings }> {
1009
+ const res = await cmsApi<unknown>('/seo/autopilot/settings', {
1010
+ method: 'PUT',
1011
+ body: JSON.stringify(patch),
1012
+ })
1013
+ if (res.error) return { ok: false, error: res.error }
1014
+ return { ok: true, settings: normalizeAutopilotSettings(res.data) ?? undefined }
1015
+ }
1016
+
1017
+ export async function runAutopilotNow(): Promise<{
1018
+ ok: boolean
1019
+ error?: string
1020
+ summary?: SeoAutopilotRunSummary
1021
+ }> {
1022
+ const res = await cmsApi<SeoAutopilotRunSummary>('/seo/autopilot/run', {
1023
+ method: 'POST',
1024
+ body: '{}',
1025
+ })
1026
+ if (res.error) return { ok: false, error: res.error }
1027
+ return { ok: true, summary: res.data }
1028
+ }
1029
+
1030
+ export type SeoAutoApplyStatus = 'applied' | 'verify_failed' | 'reverted' | 'outcome_reverted'
1031
+ export type SeoAutoApplyOutcome = 'pending' | 'held' | 'regressed' | 'insufficient_data' | 'skipped'
1032
+
1033
+ export interface SeoAutoApplyRow {
1034
+ id: string
1035
+ kind: 'issue-fix' | 'redirect'
1036
+ issueType: string
1037
+ entityId: string | null
1038
+ /** Collection slug of the touched document; `null` for redirects. */
1039
+ collection: string | null
1040
+ entityTitle: string | null
1041
+ entityUrl: string | null
1042
+ path: string | null
1043
+ before: Record<string, unknown> | null
1044
+ after: Record<string, unknown>
1045
+ gate: {
1046
+ source: string
1047
+ brandAlignment: number | null
1048
+ confidence: number | null
1049
+ checks: Record<string, boolean>
1050
+ }
1051
+ status: SeoAutoApplyStatus
1052
+ outcome: SeoAutoApplyOutcome
1053
+ outcomeMetrics: Record<string, unknown> | null
1054
+ appliedAt: string
1055
+ revertedAt: string | null
1056
+ revertReason: string | null
1057
+ }
1058
+
1059
+ export interface SeoAutoApplyList {
1060
+ docs: SeoAutoApplyRow[]
1061
+ total: number
1062
+ page: number
1063
+ pageSize: number
1064
+ /** True for ADMIN callers — the only role the revert route accepts. */
1065
+ canRevert: boolean
1066
+ }
1067
+
1068
+ export async function fetchAutopilotApplied(params: {
1069
+ page: number
1070
+ pageSize: number
1071
+ status?: SeoAutoApplyStatus
1072
+ outcome?: SeoAutoApplyOutcome
1073
+ }): Promise<SeoAutoApplyList> {
1074
+ const search = new URLSearchParams({
1075
+ page: String(params.page),
1076
+ pageSize: String(params.pageSize),
1077
+ })
1078
+ if (params.status) search.set('status', params.status)
1079
+ if (params.outcome) search.set('outcome', params.outcome)
1080
+ const res = await cmsApi<SeoAutoApplyList>(`/seo/autopilot/applied?${search.toString()}`)
1081
+ throwIfError(res)
1082
+ return (
1083
+ res.data ?? {
1084
+ docs: [],
1085
+ total: 0,
1086
+ page: params.page,
1087
+ pageSize: params.pageSize,
1088
+ canRevert: false,
1089
+ }
1090
+ )
1091
+ }
1092
+
1093
+ /**
1094
+ * Revert one ledgered change. A 409 (`code: 'conflict'`) means a human edited
1095
+ * the same fields since autopilot wrote them; the server lists them in
1096
+ * `fields` and `force: true` overrides.
1097
+ */
1098
+ export async function revertAutopilotChange(
1099
+ id: string,
1100
+ force = false,
1101
+ ): Promise<{ ok: true } | { ok: false; error: string; conflictFields?: string[] }> {
1102
+ const res = await cmsApi<unknown>(`/seo/autopilot/applied/${encodeURIComponent(id)}/revert`, {
1103
+ method: 'POST',
1104
+ body: JSON.stringify({ force }),
1105
+ })
1106
+ if (res.error) {
1107
+ if (res.code !== 'conflict') return { ok: false, error: res.error }
1108
+ const fields = isRecord(res.errorBody) ? res.errorBody.fields : undefined
1109
+ return {
1110
+ ok: false,
1111
+ error: res.error,
1112
+ conflictFields: Array.isArray(fields)
1113
+ ? fields.filter((f): f is string => typeof f === 'string')
1114
+ : [],
1115
+ }
1116
+ }
1117
+ return { ok: true }
1118
+ }
1119
+
829
1120
  // ─── AI crawler activity ─────────────────────────────────────────────
830
1121
 
831
1122
  export interface AiCrawlerRow {
@@ -1055,7 +1346,17 @@ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]>
1055
1346
 
1056
1347
  export interface RedirectRecoverySettings {
1057
1348
  aiRerank: boolean
1349
+ /**
1350
+ * @deprecated Auto-apply moved to the SEO Autopilot engine (`types.redirect`
1351
+ * on `SeoAutopilotSettings`, managed from the Settings → SEO "SEO Autopilot"
1352
+ * card). The server still emits this for one release; nothing in the admin
1353
+ * reads it. Remove together with the server field.
1354
+ */
1058
1355
  autoApply: boolean
1356
+ /**
1357
+ * @deprecated Superseded by `SeoAutopilotSettings.redirectMinConfidence`,
1358
+ * managed from the "SEO Autopilot" card. Server-emitted for one release only.
1359
+ */
1059
1360
  autoApplyMinConfidence: number
1060
1361
  }
1061
1362
 
@@ -1077,10 +1378,16 @@ export async function saveRedirectRecoverySettings(
1077
1378
 
1078
1379
  export async function generate404RecoverySuggestions(): Promise<{
1079
1380
  error?: string
1381
+ /**
1382
+ * @deprecated Always `0`: generation no longer applies redirects. Auto-apply
1383
+ * is the SEO Autopilot engine's job (Settings → SEO "SEO Autopilot" card);
1384
+ * the server keeps the field for one release. Drop with the server field.
1385
+ */
1080
1386
  autoApplied?: number
1081
1387
  /**
1082
- * Set when AI governance vetoed a requested auto-apply (eligible plans were
1083
- * stored as pending suggestions instead). Human-readable reason to surface.
1388
+ * @deprecated Always `undefined` (server sends `null`): the governance veto
1389
+ * now happens inside the autopilot gate, not at generation time. Kept for
1390
+ * one release alongside `autoApplied`.
1084
1391
  */
1085
1392
  governanceBlocked?: string
1086
1393
  }> {
@@ -1333,3 +1640,149 @@ export async function fetchLinkHealthIssues(): Promise<{
1333
1640
  },
1334
1641
  }
1335
1642
  }
1643
+
1644
+ // ─── AI proposals inbox ─────────────────────────────────────────────────
1645
+
1646
+ export interface SeoProposal {
1647
+ /** `issue:<issueId>` | `redirect:<suggestionId>` */
1648
+ id: string
1649
+ kind: 'issue-fix' | 'redirect'
1650
+ title: string
1651
+ entity: { type: string | null; id: string | null; title: string | null; url: string | null }
1652
+ changes: SeoFixFieldChange[]
1653
+ justification: string
1654
+ source: 'ai' | 'deterministic' | 'ai-404-recovery'
1655
+ /** 0–1, redirects only. */
1656
+ confidence: number | null
1657
+ /** 0–100, issue fixes only. */
1658
+ brandAlignment: number | null
1659
+ generatedAt: string | null
1660
+ /** Issue fixes only; redirects are keyed by id. */
1661
+ fingerprint: string | null
1662
+ /** Issue fixes: cached fingerprint ≠ live fingerprint. Redirects: always false. */
1663
+ stale: boolean
1664
+ issueType: string | null
1665
+ severity: SeoSeverity | null
1666
+ issueId: string | null
1667
+ suggestionId: string | null
1668
+ }
1669
+
1670
+ export interface SeoProposalsPayload {
1671
+ proposals: SeoProposal[]
1672
+ counts: { issueFixes: number; redirects: number; stale: number; eligibleForGeneration: number }
1673
+ /** `allowBulkApply` per feature, so the UI can disable batch approve up front. */
1674
+ governance: { issueFixBulk: boolean; redirectBulk: boolean }
1675
+ /** Autopilot status strip for the proposals inbox (mirror of core's payload). */
1676
+ autopilot: {
1677
+ enabled: boolean
1678
+ /** Set only while the engine is actually paused. */
1679
+ pausedReason: string | null
1680
+ lastRunAt: string | null
1681
+ /** Live autopilot changes (`status = applied`) from the last 7 days. */
1682
+ appliedLast7d: number
1683
+ }
1684
+ }
1685
+
1686
+ export interface SeoProposalGenerateResult {
1687
+ generated: number
1688
+ skipped: number
1689
+ failed: number
1690
+ remaining: number
1691
+ haltedReason: string | null
1692
+ }
1693
+
1694
+ export interface SeoProposalBulkApplyItem {
1695
+ kind: 'issue-fix' | 'redirect'
1696
+ id: string
1697
+ fingerprint?: string
1698
+ }
1699
+
1700
+ /** Mirrors core's `BulkApplyItemStatus` (`seo/proposals-bulk-apply.ts`). */
1701
+ export type SeoProposalBulkApplyItemStatus =
1702
+ | 'applied'
1703
+ | 'stale'
1704
+ | 'verify-failed'
1705
+ | 'plan-required'
1706
+ | 'not-found'
1707
+ | 'not-allowed'
1708
+ | 'not-fixable'
1709
+ | 'invalid'
1710
+ | 'still-live'
1711
+ | 'error'
1712
+ | 'pending'
1713
+
1714
+ export interface SeoProposalBulkApplyResult {
1715
+ results: Array<{
1716
+ kind: 'issue-fix' | 'redirect'
1717
+ id: string
1718
+ status: SeoProposalBulkApplyItemStatus
1719
+ reason?: string
1720
+ }>
1721
+ applied: number
1722
+ failed: number
1723
+ /** Items the server did not reach within its time budget — resubmit exactly these. */
1724
+ pending: number
1725
+ }
1726
+
1727
+ /** What the chip shows when the server says nothing about autopilot. */
1728
+ const AUTOPILOT_OFF: SeoProposalsPayload['autopilot'] = {
1729
+ enabled: false,
1730
+ pausedReason: null,
1731
+ lastRunAt: null,
1732
+ appliedLast7d: 0,
1733
+ }
1734
+
1735
+ export async function fetchSeoProposals(): Promise<SeoProposalsPayload> {
1736
+ // `autopilot` is optional on the wire: a `@prenta/core` deployed before
1737
+ // autopilot shipped omits it, and the tab must still render.
1738
+ const res = await cmsApi<Omit<SeoProposalsPayload, 'autopilot'> & Partial<SeoProposalsPayload>>(
1739
+ '/seo/proposals',
1740
+ )
1741
+ throwIfError(res)
1742
+ if (!res.data) {
1743
+ return {
1744
+ proposals: [],
1745
+ counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
1746
+ governance: { issueFixBulk: false, redirectBulk: false },
1747
+ autopilot: AUTOPILOT_OFF,
1748
+ }
1749
+ }
1750
+ return { ...res.data, autopilot: res.data.autopilot ?? AUTOPILOT_OFF }
1751
+ }
1752
+
1753
+ export async function generateSeoProposals(): Promise<{
1754
+ result?: SeoProposalGenerateResult
1755
+ error?: string
1756
+ }> {
1757
+ const res = await cmsApi<SeoProposalGenerateResult>('/seo/proposals/generate', {
1758
+ method: 'POST',
1759
+ body: JSON.stringify({ confirm: true }),
1760
+ })
1761
+ if (res.error) return { error: res.error }
1762
+ return { result: res.data }
1763
+ }
1764
+
1765
+ export async function bulkApplySeoProposals(items: SeoProposalBulkApplyItem[]): Promise<{
1766
+ result?: SeoProposalBulkApplyResult
1767
+ error?: string
1768
+ /** True when the server refused because `allowBulkApply` is off for a represented feature. */
1769
+ governanceBlocked?: boolean
1770
+ }> {
1771
+ const res = await cmsApi<SeoProposalBulkApplyResult>('/seo/proposals/bulk-apply', {
1772
+ method: 'POST',
1773
+ body: JSON.stringify({ items }),
1774
+ })
1775
+ if (res.error) {
1776
+ return res.code === 'governance_blocked'
1777
+ ? { error: res.error, governanceBlocked: true }
1778
+ : { error: res.error }
1779
+ }
1780
+ return { result: res.data }
1781
+ }
1782
+
1783
+ export async function dismissSeoIssueFix(issueId: string): Promise<{ error?: string }> {
1784
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/dismiss-fix`, {
1785
+ method: 'POST',
1786
+ })
1787
+ return res.error ? { error: res.error } : {}
1788
+ }