Add governed organization template upgrades

This commit is contained in:
2026-08-04 01:46:36 +02:00
parent 97acfcba7d
commit fcfd67b0b5
17 changed files with 2357 additions and 6 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
"types": "src/index.ts",
"scripts": {
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs",
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
"test:template-upgrades": "node scripts/test-template-upgrades.mjs"
},
"exports": {
".": {
+35
View File
@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const root = process.cwd();
const panel = fs.readFileSync(
path.join(root, "src/features/organizations/OrganizationTemplateUpgradePanel.tsx"),
"utf8"
);
const api = fs.readFileSync(path.join(root, "src/api/organizations.ts"), "utf8");
const moduleSource = fs.readFileSync(path.join(root, "src/module.ts"), "utf8");
const expectations = [
[panel, "previewOrganizationModelUpgrade", "The panel must create a persisted preview."],
[panel, "applyOrganizationModelUpgrade", "The panel must explicitly apply a reviewed preview."],
[panel, "cancelOrganizationModelUpgrade", "The panel must support cancellation without mutation."],
[panel, "<ConfirmDialog open={applyConfirmation}", "Apply must use a separate confirmation step."],
[panel, "<DataGrid id={`organization-model-upgrade-diff-", "The comparison must use the shared DataGrid."],
[panel, "<DismissibleAlert", "Errors and outcomes must use the shared alert component."],
[api, '"/api/v1/organizations/model-upgrades/preview"', "The preview API must be declared."],
[api, "/apply`", "The apply API must be declared."],
[moduleSource, 'id: "organizations.admin.template-upgrades"', "Views must be able to target the upgrade surface."]
];
for (const [source, marker, message] of expectations) {
if (!source.includes(marker)) {
throw new Error(message);
}
}
if (/useEffect\([^)]*applyOrganizationModelUpgrade/s.test(panel)) {
throw new Error("An organization template upgrade must never apply from an effect.");
}
console.log("Organization template upgrade interface contract passed.");
+110
View File
@@ -135,6 +135,81 @@ export type OrganizationModel = {
functions: OrganizationFunctionItem[];
};
export type OrganizationTemplateVersion = {
id: string;
template_id: string;
version: string;
status: string;
definition_sha256: string;
release_notes?: string | null;
published_at?: string | null;
};
export type OrganizationTemplateCatalogItem = {
id: string;
slug: string;
name: string;
description?: string | null;
versions: OrganizationTemplateVersion[];
};
export type OrganizationTemplateCatalog = {
templates: OrganizationTemplateCatalogItem[];
};
export type OrganizationModelInstantiation = {
id: string;
tenant_id: string;
template_id: string;
template_version_id: string;
source_definition_sha256: string;
status: string;
object_counts: Record<string, number>;
provenance: Record<string, unknown>;
created_at: string;
updated_at: string;
};
export type OrganizationUpgradeDecisionAction = "keep_local" | "use_target" | "map_to";
export type OrganizationUpgradeDiffEntry = {
id: string;
collection: string;
key: string;
classification: string;
requires_decision: boolean;
base?: Record<string, unknown> | null;
local?: Record<string, unknown> | null;
target?: Record<string, unknown> | null;
allowed_actions: OrganizationUpgradeDecisionAction[];
};
export type OrganizationModelUpgrade = {
id: string;
tenant_id: string;
template_id: string;
source_instantiation_id: string;
source_template_version_id: string;
target_template_version_id: string;
status: string;
revision: number;
preview: {
entries: OrganizationUpgradeDiffEntry[];
counts: Record<string, number>;
requires_decisions: number;
blocking_invalid_references: number;
silent_mutation: boolean;
};
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string | null }>;
created_at: string;
updated_at: string;
};
export type OrganizationModelUpgradeList = {
current_instantiation?: OrganizationModelInstantiation | null;
upgrades: OrganizationModelUpgrade[];
};
export type OrganizationChangeRequestPayload = {
change_request_id?: string | null;
};
@@ -190,6 +265,41 @@ export function getOrganizationModel(settings: ApiSettings): Promise<Organizatio
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
}
export function getOrganizationTemplateCatalog(settings: ApiSettings): Promise<OrganizationTemplateCatalog> {
return apiFetch<OrganizationTemplateCatalog>(settings, "/api/v1/organizations/model-templates");
}
export function getOrganizationModelUpgrades(settings: ApiSettings): Promise<OrganizationModelUpgradeList> {
return apiFetch<OrganizationModelUpgradeList>(settings, "/api/v1/organizations/model-upgrades");
}
export function previewOrganizationModelUpgrade(settings: ApiSettings, targetTemplateVersionId: string): Promise<OrganizationModelUpgrade> {
return apiPostJson(settings, "/api/v1/organizations/model-upgrades/preview", {
target_template_version_id: targetTemplateVersionId,
idempotency_key: crypto.randomUUID()
});
}
export function cancelOrganizationModelUpgrade(settings: ApiSettings, upgradeId: string, expectedRevision: number): Promise<OrganizationModelUpgrade> {
return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/cancel`, {
expected_revision: expectedRevision
});
}
export function applyOrganizationModelUpgrade(
settings: ApiSettings,
upgradeId: string,
expectedRevision: number,
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string }>,
changeRequestId?: string
): Promise<{ upgrade: OrganizationModelUpgrade; instantiation: OrganizationModelInstantiation }> {
return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/apply`, {
expected_revision: expectedRevision,
decisions,
change_request_id: changeRequestId || null
});
}
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
}
@@ -0,0 +1,252 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react";
import {
Button,
Card,
ConfirmDialog,
DataGrid,
Dialog,
DismissibleAlert,
FormField,
LoadingFrame,
MetricCard,
StatusBadge,
TableActionGroup,
adminErrorMessage,
hasAnyScope,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
applyOrganizationModelUpgrade,
cancelOrganizationModelUpgrade,
getOrganizationModelUpgrades,
getOrganizationTemplateCatalog,
previewOrganizationModelUpgrade,
type OrganizationModelInstantiation,
type OrganizationModelUpgrade,
type OrganizationTemplateCatalogItem,
type OrganizationTemplateVersion,
type OrganizationUpgradeDecisionAction,
type OrganizationUpgradeDiffEntry
} from "../../api/organizations";
type Decision = { action: OrganizationUpgradeDecisionAction; target_key?: string };
export default function OrganizationTemplateUpgradePanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const canRead = hasAnyScope(auth, ["organizations:model:read", "admin:settings:read"]);
const canWrite = hasAnyScope(auth, ["organizations:model:write"]);
const [templates, setTemplates] = useState<OrganizationTemplateCatalogItem[]>([]);
const [current, setCurrent] = useState<OrganizationModelInstantiation | null>(null);
const [upgrades, setUpgrades] = useState<OrganizationModelUpgrade[]>([]);
const [targetVersionId, setTargetVersionId] = useState("");
const [selected, setSelected] = useState<OrganizationModelUpgrade | null>(null);
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [changeRequestId, setChangeRequestId] = useState("");
const [applyConfirmation, setApplyConfirmation] = useState(false);
const [cancelTarget, setCancelTarget] = useState<OrganizationModelUpgrade | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const load = useCallback(async () => {
if (!canRead) return;
setLoading(true);
setError("");
try {
const [catalog, state] = await Promise.all([
getOrganizationTemplateCatalog(settings),
getOrganizationModelUpgrades(settings)
]);
setTemplates(catalog.templates);
setCurrent(state.current_instantiation ?? null);
setUpgrades(state.upgrades);
const available = availableVersions(catalog.templates, state.current_instantiation ?? null);
setTargetVersionId((value) => available.some((item) => item.id === value) ? value : available[0]?.id ?? "");
setSelected((value) => value ? state.upgrades.find((item) => item.id === value.id) ?? null : null);
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setLoading(false);
}
}, [canRead, settings.accessToken, settings.apiBaseUrl]);
useEffect(() => { void load(); }, [load]);
const versions = useMemo(() => versionMap(templates), [templates]);
const targets = useMemo(() => availableVersions(templates, current), [templates, current]);
const currentVersion = current ? versions.get(current.template_version_id) : undefined;
const pending = upgrades.filter((item) => item.status === "previewed");
const upgradeColumns = useMemo<DataGridColumn<OrganizationModelUpgrade>[]>(() => [
{ id: "source", header: "Source version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id, value: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id },
{ id: "target", header: "Target version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id, value: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id },
{ id: "changes", header: "Changes", width: 105, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.entries.length, value: (row) => row.preview.entries.length },
{ id: "decisions", header: "Decisions", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.requires_decisions, value: (row) => row.preview.requires_decisions },
{ id: "invalid", header: "Invalid refs", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.blocking_invalid_references, value: (row) => row.preview.blocking_invalid_references },
{ id: "status", header: "Status", width: 120, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.status} />, value: (row) => row.status },
{ id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at },
{ id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) => <TableActionGroup minimumSlots={2} actions={[
{ id: "inspect", label: "Inspect upgrade", icon: <Eye aria-hidden="true" />, onClick: () => openUpgrade(row) },
{ id: "cancel", label: "Cancel preview", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canWrite || row.status !== "previewed", onClick: () => setCancelTarget(row) }
]} /> }
], [canWrite, versions]);
const diffColumns = useMemo<DataGridColumn<OrganizationUpgradeDiffEntry>[]>(() => [
{ id: "collection", header: "Area", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.collection), value: (row) => row.collection },
{ id: "key", header: "Object", width: 210, sortable: true, filterable: true, render: (row) => row.key, value: (row) => row.key },
{ id: "classification", header: "Classification", width: 185, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.classification} />, value: (row) => row.classification },
{ id: "summary", header: "Comparison", width: 330, render: (row) => comparisonSummary(row), value: (row) => comparisonSummary(row) },
{ id: "decision", header: "Decision", width: 280, render: (row) => row.requires_decision ? <div className="organization-upgrade-decision"><select value={decisions[row.id]?.action ?? ""} disabled={!canWrite || busy || selected?.status !== "previewed"} onChange={(event) => setDecision(row, event.target.value as OrganizationUpgradeDecisionAction)}><option value="">Select decision</option>{row.allowed_actions.map((action) => <option key={action} value={action}>{decisionLabel(action)}</option>)}</select>{decisions[row.id]?.action === "map_to" && <input value={decisions[row.id]?.target_key ?? ""} placeholder="Target key" disabled={!canWrite || busy} onChange={(event) => setDecisions((value) => ({ ...value, [row.id]: { ...value[row.id], target_key: event.target.value } }))} />}</div> : "Automatic", value: (row) => decisions[row.id]?.action ?? "automatic" }
], [busy, canWrite, decisions, selected?.status]);
function setDecision(entry: OrganizationUpgradeDiffEntry, action: OrganizationUpgradeDecisionAction) {
if (!action) {
setDecisions((value) => { const next = { ...value }; delete next[entry.id]; return next; });
return;
}
setDecisions((value) => ({ ...value, [entry.id]: { action } }));
}
function openUpgrade(upgrade: OrganizationModelUpgrade) {
setSelected(upgrade);
setDecisions(upgrade.decisions ?? {});
setChangeRequestId("");
}
async function createPreview() {
if (!targetVersionId || busy) return;
setBusy(true);
setError("");
setSuccess("");
try {
const created = await previewOrganizationModelUpgrade(settings, targetVersionId);
setSuccess("The three-way comparison was recorded. No tenant model data was changed.");
await load();
openUpgrade(created);
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
async function applyUpgrade() {
if (!selected || busy) return;
setBusy(true);
setError("");
setSuccess("");
try {
await applyOrganizationModelUpgrade(settings, selected.id, selected.revision, decisions, changeRequestId.trim() || undefined);
setApplyConfirmation(false);
setSelected(null);
setSuccess("The template upgrade was applied as a new tenant-owned model instantiation.");
await load();
} catch (caught) {
setError(adminErrorMessage(caught));
setApplyConfirmation(false);
} finally {
setBusy(false);
}
}
async function cancelUpgrade() {
if (!cancelTarget || busy) return;
setBusy(true);
setError("");
try {
await cancelOrganizationModelUpgrade(settings, cancelTarget.id, cancelTarget.revision);
setCancelTarget(null);
if (selected?.id === cancelTarget.id) setSelected(null);
setSuccess("The upgrade preview was cancelled without changing the tenant model.");
await load();
} catch (caught) {
setError(adminErrorMessage(caught));
setCancelTarget(null);
} finally {
setBusy(false);
}
}
const decisionsComplete = selected ? selected.preview.entries.every((entry) => !entry.requires_decision || Boolean(decisions[entry.id]?.action) && (decisions[entry.id].action !== "map_to" || Boolean(decisions[entry.id].target_key?.trim()))) : false;
const applyDisabled = !selected || !canWrite || busy || selected.status !== "previewed" || selected.preview.blocking_invalid_references > 0 || !decisionsComplete;
if (!canRead) return null;
return <>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
<Card title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
<LoadingFrame loading={loading} label="Loading organization template upgrade state">
<div className="metric-grid compact">
<MetricCard label="Applied version" value={currentVersion?.version ?? "Custom model"} tone="info" />
<MetricCard label="Available upgrades" value={targets.length} tone={targets.length ? "info" : "good"} />
<MetricCard label="Open previews" value={pending.length} tone={pending.length ? "warning" : "good"} />
<MetricCard label="Copy semantics" value="Tenant-owned" tone="good" />
</div>
<p className="muted small-note">Template versions are immutable sources. A tenant model never live-inherits changes: every upgrade is a recorded three-way comparison, explicit decision set, and confirmed new instantiation.</p>
<div className="organization-upgrade-toolbar">
<FormField label="Published target version"><select value={targetVersionId} disabled={!canWrite || busy || !targets.length} onChange={(event) => setTargetVersionId(event.target.value)}>{targets.length ? targets.map((version) => <option key={version.id} value={version.id}>{templateVersionLabel(templates, version)}</option>) : <option value="">No newer published version</option>}</select></FormField>
<Button variant="primary" onClick={() => void createPreview()} disabled={!canWrite || busy || !targetVersionId}><GitCompareArrows aria-hidden="true" /> Create preview</Button>
</div>
<div className="organization-upgrade-table"><DataGrid id="organization-model-upgrades" rows={upgrades} columns={upgradeColumns} initialFit="container" getRowKey={(row) => row.id} emptyText={current ? "No organization template upgrades have been previewed." : "This tenant model was not instantiated from a system template."} /></div>
</LoadingFrame>
</Card>
<Dialog open={Boolean(selected)} title="Organization model upgrade preview" className="organization-upgrade-dialog" onClose={() => !busy && setSelected(null)} closeDisabled={busy} footer={<><Button onClick={() => setSelected(null)} disabled={busy}>Close</Button>{selected?.status === "previewed" && <><Button variant="danger" onClick={() => setCancelTarget(selected)} disabled={!canWrite || busy}>Cancel preview</Button><Button variant="primary" onClick={() => setApplyConfirmation(true)} disabled={applyDisabled}><Play aria-hidden="true" /> Review and apply</Button></>}</>}>
{selected && <>
<div className="metric-grid compact">
<MetricCard label="Changes" value={selected.preview.entries.length} tone="info" />
<MetricCard label="Required decisions" value={selected.preview.requires_decisions} tone={selected.preview.requires_decisions ? "warning" : "good"} />
<MetricCard label="Invalid references" value={selected.preview.blocking_invalid_references} tone={selected.preview.blocking_invalid_references ? "danger" : "good"} />
<MetricCard label="Status" value={humanize(selected.status)} tone="info" />
</div>
<p className="muted small-note">Compatible additions and non-conflicting changes apply automatically. Local-only divergence is preserved. Destructive remapping and competing edits require an explicit bounded decision.</p>
<div className="organization-upgrade-diff"><DataGrid id={`organization-model-upgrade-diff-${selected.id}`} rows={selected.preview.entries} columns={diffColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="The versions and tenant model are equivalent." /></div>
<FormField label="Approved change request (when required by tenant policy)"><input value={changeRequestId} disabled={!canWrite || busy || selected.status !== "previewed"} onChange={(event) => setChangeRequestId(event.target.value)} /></FormField>
</>}
</Dialog>
<ConfirmDialog open={applyConfirmation} title="Apply organization model upgrade" message="Apply this reviewed comparison and its explicit decisions? The current instantiation will be superseded, the resulting model remains tenant-owned, and the operation is recorded for audit and event consumers." confirmLabel="Apply upgrade" busy={busy} onConfirm={() => void applyUpgrade()} onCancel={() => !busy && setApplyConfirmation(false)} />
<ConfirmDialog open={Boolean(cancelTarget)} title="Cancel organization model upgrade" message="Cancel this preview? No tenant organization data will be changed and the cancellation remains recorded in the upgrade history." confirmLabel="Cancel preview" tone="danger" busy={busy} onConfirm={() => void cancelUpgrade()} onCancel={() => !busy && setCancelTarget(null)} />
</>;
}
function versionMap(templates: OrganizationTemplateCatalogItem[]): Map<string, OrganizationTemplateVersion> {
return new Map(templates.flatMap((template) => template.versions.map((version) => [version.id, version] as const)));
}
function availableVersions(templates: OrganizationTemplateCatalogItem[], current: OrganizationModelInstantiation | null): OrganizationTemplateVersion[] {
if (!current) return [];
const template = templates.find((item) => item.id === current.template_id);
return (template?.versions ?? []).filter((version) => version.id !== current.template_version_id && version.status === "published");
}
function templateVersionLabel(templates: OrganizationTemplateCatalogItem[], version: OrganizationTemplateVersion): string {
const template = templates.find((item) => item.id === version.template_id);
return `${template?.name ?? "Template"} · ${version.version}`;
}
function comparisonSummary(entry: OrganizationUpgradeDiffEntry): string {
if (entry.classification === "compatible_addition") return "Added by target template";
if (entry.classification === "compatible_change") return "Target changed; tenant still matches source";
if (entry.classification === "destructive_remapping") return "Removal or reference remapping";
if (entry.classification === "invalid_reference") return String(entry.local?.diagnostic ?? "Invalid tenant reference");
return entry.requires_decision ? "Both tenant and target changed" : "Tenant-only customization is preserved";
}
function decisionLabel(action: OrganizationUpgradeDecisionAction): string {
if (action === "keep_local") return "Keep tenant value";
if (action === "use_target") return "Use template value";
return "Map references to another key";
}
function humanize(value: string): string {
return value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function formatDateTime(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
@@ -26,6 +26,7 @@ import {
ORGANIZATIONS_INTERFACE_I18N,
organizationWriteReason
} from "./interfacePatterns";
import OrganizationTemplateUpgradePanel from "./OrganizationTemplateUpgradePanel";
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
tenant_id: "",
@@ -203,6 +204,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
</Card>
</div>
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
</AdminPageLayout>
);
}
+8
View File
@@ -55,6 +55,14 @@ export const organizationsModule: PlatformWebModule = {
kind: "section",
label: "i18n:govoplan-organizations.organizations_administration",
order: 85
},
{
id: "organizations.admin.template-upgrades",
moduleId: "organizations",
kind: "section",
label: "Organization template upgrades",
parentId: "organizations.admin.tenant",
order: 86
}
],
navItems: [
+37
View File
@@ -77,3 +77,40 @@
grid-template-columns: 1fr;
}
}
.organization-upgrade-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1fr) auto;
align-items: end;
gap: 0.8rem;
margin: 0.9rem 0;
}
.organization-upgrade-table {
min-height: 10rem;
max-height: 24rem;
overflow: auto;
}
.organization-upgrade-dialog {
width: min(92vw, 78rem);
height: min(88vh, 54rem);
}
.organization-upgrade-diff {
min-height: 12rem;
max-height: 25rem;
overflow: auto;
margin: 0.8rem 0;
}
.organization-upgrade-decision {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.35rem;
}
@media (max-width: 760px) {
.organization-upgrade-toolbar {
grid-template-columns: minmax(0, 1fr);
}
}