@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.
- package/CHANGELOG.md +31 -0
- package/dist/__tests__/lib/api.test.d.ts +2 -0
- package/dist/__tests__/lib/api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/api.test.js +58 -0
- package/dist/__tests__/lib/api.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +99 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +431 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +41 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/seo/ChangePreview.d.ts +6 -0
- package/dist/components/seo/ChangePreview.d.ts.map +1 -0
- package/dist/components/seo/ChangePreview.js +18 -0
- package/dist/components/seo/ChangePreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +1 -14
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/api.d.ts +13 -2
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +1 -2
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/seo-service.d.ts +293 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +170 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +28 -9
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
- package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
- package/dist/views/seo/AutopilotAppliedSection.js +295 -0
- package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
- package/dist/views/seo/ProposalsTab.d.ts +7 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
- package/dist/views/seo/ProposalsTab.js +334 -0
- package/dist/views/seo/ProposalsTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +3 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +14 -19
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/relative-age.d.ts +7 -0
- package/dist/views/seo/relative-age.d.ts.map +1 -0
- package/dist/views/seo/relative-age.js +20 -0
- package/dist/views/seo/relative-age.js.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.js +375 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/components.d.ts +3 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +2 -2
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/api.test.ts +76 -0
- package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +108 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
- package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
- package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +531 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
- package/src/components/seo/ChangePreview.tsx +47 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
- package/src/lib/api.ts +19 -3
- package/src/lib/seo-service.ts +455 -2
- package/src/views/SEO.tsx +41 -8
- package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
- package/src/views/seo/ProposalsTab.tsx +761 -0
- package/src/views/seo/RedirectsTab.tsx +39 -60
- package/src/views/seo/relative-age.ts +15 -0
- package/src/views/settings/SeoAutopilotCard.tsx +826 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
- 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<
|
|
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
|
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -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
|
-
*
|
|
1083
|
-
*
|
|
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
|
+
}
|