@prenta/admin 1.14.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 (81) hide show
  1. package/CHANGELOG.md +10 -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.js +28 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  13. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  14. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  17. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  18. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  21. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
  23. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  24. package/dist/__tests__/views/seo-settings.render.test.js +41 -4
  25. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  26. package/dist/lib/api.d.ts +13 -2
  27. package/dist/lib/api.d.ts.map +1 -1
  28. package/dist/lib/api.js +1 -2
  29. package/dist/lib/api.js.map +1 -1
  30. package/dist/lib/seo-service.d.ts +206 -2
  31. package/dist/lib/seo-service.d.ts.map +1 -1
  32. package/dist/lib/seo-service.js +139 -5
  33. package/dist/lib/seo-service.js.map +1 -1
  34. package/dist/prenta-admin.css +1 -1
  35. package/dist/views/SEO.js +1 -1
  36. package/dist/views/SEO.js.map +1 -1
  37. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  38. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  39. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  40. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  41. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  42. package/dist/views/seo/ProposalsTab.js +53 -17
  43. package/dist/views/seo/ProposalsTab.js.map +1 -1
  44. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  45. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  46. package/dist/views/seo/RedirectsTab.js +14 -19
  47. package/dist/views/seo/RedirectsTab.js.map +1 -1
  48. package/dist/views/seo/relative-age.d.ts +7 -0
  49. package/dist/views/seo/relative-age.d.ts.map +1 -0
  50. package/dist/views/seo/relative-age.js +20 -0
  51. package/dist/views/seo/relative-age.js.map +1 -0
  52. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  53. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  54. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  55. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  56. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  57. package/dist/views/settings/SeoSettingsTab.js +2 -1
  58. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  59. package/dist/views/settings/components.d.ts +3 -1
  60. package/dist/views/settings/components.d.ts.map +1 -1
  61. package/dist/views/settings/components.js +2 -2
  62. package/dist/views/settings/components.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/__tests__/lib/api.test.ts +76 -0
  65. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  66. package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
  67. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  68. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  69. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  70. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
  71. package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
  72. package/src/lib/api.ts +19 -3
  73. package/src/lib/seo-service.ts +334 -6
  74. package/src/views/SEO.tsx +1 -1
  75. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  76. package/src/views/seo/ProposalsTab.tsx +92 -12
  77. package/src/views/seo/RedirectsTab.tsx +39 -60
  78. package/src/views/seo/relative-age.ts +15 -0
  79. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  80. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  81. package/src/views/settings/components.tsx +10 -1
@@ -828,6 +828,295 @@ export async function sendTestDigest(): Promise<{ ok: boolean; to?: string; erro
828
828
  return { ok: true, to: res.data?.to }
829
829
  }
830
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
+
831
1120
  // ─── AI crawler activity ─────────────────────────────────────────────
832
1121
 
833
1122
  export interface AiCrawlerRow {
@@ -1057,7 +1346,17 @@ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]>
1057
1346
 
1058
1347
  export interface RedirectRecoverySettings {
1059
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
+ */
1060
1355
  autoApply: boolean
1356
+ /**
1357
+ * @deprecated Superseded by `SeoAutopilotSettings.redirectMinConfidence`,
1358
+ * managed from the "SEO Autopilot" card. Server-emitted for one release only.
1359
+ */
1061
1360
  autoApplyMinConfidence: number
1062
1361
  }
1063
1362
 
@@ -1079,10 +1378,16 @@ export async function saveRedirectRecoverySettings(
1079
1378
 
1080
1379
  export async function generate404RecoverySuggestions(): Promise<{
1081
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
+ */
1082
1386
  autoApplied?: number
1083
1387
  /**
1084
- * Set when AI governance vetoed a requested auto-apply (eligible plans were
1085
- * 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`.
1086
1391
  */
1087
1392
  governanceBlocked?: string
1088
1393
  }> {
@@ -1367,6 +1672,15 @@ export interface SeoProposalsPayload {
1367
1672
  counts: { issueFixes: number; redirects: number; stale: number; eligibleForGeneration: number }
1368
1673
  /** `allowBulkApply` per feature, so the UI can disable batch approve up front. */
1369
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
+ }
1370
1684
  }
1371
1685
 
1372
1686
  export interface SeoProposalGenerateResult {
@@ -1410,16 +1724,30 @@ export interface SeoProposalBulkApplyResult {
1410
1724
  pending: number
1411
1725
  }
1412
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
+
1413
1735
  export async function fetchSeoProposals(): Promise<SeoProposalsPayload> {
1414
- const res = await cmsApi<SeoProposalsPayload>('/seo/proposals')
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
+ )
1415
1741
  throwIfError(res)
1416
- return (
1417
- res.data ?? {
1742
+ if (!res.data) {
1743
+ return {
1418
1744
  proposals: [],
1419
1745
  counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
1420
1746
  governance: { issueFixBulk: false, redirectBulk: false },
1747
+ autopilot: AUTOPILOT_OFF,
1421
1748
  }
1422
- )
1749
+ }
1750
+ return { ...res.data, autopilot: res.data.autopilot ?? AUTOPILOT_OFF }
1423
1751
  }
1424
1752
 
1425
1753
  export async function generateSeoProposals(): Promise<{
package/src/views/SEO.tsx CHANGED
@@ -284,7 +284,7 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
284
284
  {activeTab === 'technical' && <TechnicalTab />}
285
285
  </Tabs.Content>
286
286
  <Tabs.Content value="redirects" tabIndex={-1}>
287
- {activeTab === 'redirects' && <RedirectsTab />}
287
+ {activeTab === 'redirects' && <RedirectsTab onNavigate={onNavigate} />}
288
288
  </Tabs.Content>
289
289
  <Tabs.Content value="audit" tabIndex={-1}>
290
290
  {activeTab === 'audit' && (