feat(campaigns): add portable campaign transfers
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 04:01:39 +02:00
parent 1bd24f9b5b
commit 3934e7fedb
20 changed files with 2310 additions and 18 deletions
@@ -1,18 +1,28 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { ExternalLink, Upload } from "lucide-react";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
import { Link } from "react-router";
import type { ApiSettings } from "../../types";
import type { ApiSettings, AuthInfo } from "../../types";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageActionBar, PageLayout, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DismissibleAlert, PageActionBar, PageLayout, TableActionGroup, ToggleSwitch, hasScope, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import {
createNewCampaign,
importCampaignPackage,
listCampaignsDelta,
previewCampaignImport,
type CampaignDeltaResponse,
type CampaignImportPreview,
type CampaignTransferScope
} from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
export default function CampaignListPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const navigate = useGuardedNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>("");
@@ -20,6 +30,16 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importPackage, setImportPackage] = useState<Record<string, unknown> | null>(null);
const [importPreview, setImportPreview] = useState<CampaignImportPreview | null>(null);
const [importScopes, setImportScopes] = useState<CampaignTransferScope[]>([]);
const [importIdentity, setImportIdentity] = useState({ external_id: "", name: "" });
const [importPreviewStale, setImportPreviewStale] = useState(false);
const [importBusy, setImportBusy] = useState(false);
const [importError, setImportError] = useState("");
const canImport = hasScope(auth, "campaigns:campaign:import") && hasScope(auth, "campaigns:campaign:create");
const canImportRecipients = hasScope(auth, "campaigns:recipient:import") && hasScope(auth, "campaigns:recipient:write");
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
setLoading(true);
@@ -60,6 +80,101 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}
}
function openImport() {
setImportPackage(null);
setImportPreview(null);
setImportScopes([]);
setImportIdentity({ external_id: "", name: "" });
setImportPreviewStale(false);
setImportError("");
setImportOpen(true);
}
async function readImportFile(file: File | undefined) {
if (!file) return;
setImportBusy(true);
setImportError("");
try {
if (file.size > 25 * 1024 * 1024) throw new Error("Campaign packages larger than 25 MB must be reviewed and imported through a governed integration.");
const parsed: unknown = JSON.parse(await file.text());
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Select a JSON object exported as a portable Campaign package.");
const packageData = parsed as Record<string, unknown>;
const preview = await previewCampaignImport(settings, { package: packageData });
const allowedScopes = preview.selected_scopes.filter((scope) => scope !== "recipients" || canImportRecipients);
setImportPackage(packageData);
setImportPreview(preview);
setImportScopes(allowedScopes);
setImportIdentity({
external_id: preview.destination.external_id ?? "",
name: preview.destination.name ?? ""
});
setImportPreviewStale(allowedScopes.length !== preview.selected_scopes.length);
} catch (err) {
setImportPackage(null);
setImportPreview(null);
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setImportBusy(false);
}
}
function patchImportIdentity(key: "external_id" | "name", value: string) {
setImportIdentity((current) => ({ ...current, [key]: value }));
setImportPreviewStale(true);
}
function toggleImportScope(scope: CampaignTransferScope, checked: boolean) {
setImportScopes((current) => checked
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
: current.filter((item) => item !== scope));
setImportPreviewStale(true);
}
async function refreshImportPreview() {
if (!importPackage || importBusy || importScopes.length === 0) return;
setImportBusy(true);
setImportError("");
try {
const preview = await previewCampaignImport(settings, {
package: importPackage,
selected_scopes: importScopes,
external_id: importIdentity.external_id.trim() || undefined,
name: importIdentity.name.trim() || undefined
});
setImportPreview(preview);
setImportIdentity({
external_id: preview.destination.external_id ?? importIdentity.external_id,
name: preview.destination.name ?? importIdentity.name
});
setImportPreviewStale(false);
} catch (err) {
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setImportBusy(false);
}
}
async function applyImport() {
if (!importPackage || !importPreview?.compatible || !importPreview.package_sha256 || importPreviewStale || importBusy) return;
setImportBusy(true);
setImportError("");
try {
const created = await importCampaignPackage(settings, {
package: importPackage,
selected_scopes: importScopes,
external_id: importIdentity.external_id.trim() || undefined,
name: importIdentity.name.trim() || undefined,
expected_package_sha256: importPreview.package_sha256
});
setImportOpen(false);
navigate(`/campaigns/${created.campaign.id}`);
} catch (err) {
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setImportBusy(false);
}
}
useEffect(() => {
setCampaignDeltaWatermark(null);
load(null);
@@ -141,7 +256,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}];
return (
return (<>
<PageLayout
archetype="collection"
mode="workspace"
@@ -154,9 +269,15 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(null), loading }}
createAction={<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
createAction={<>
{canImport && <Button onClick={openImport} disabled={creating || importBusy}>
<Upload size={16} aria-hidden="true" />
Import package
</Button>}
<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
</Button>
</>}
/>}
>
@@ -185,7 +306,75 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}
</LoadingFrame>
</Card>
</PageLayout>);
</PageLayout>
<Dialog
open={importOpen}
title="Import portable Campaign package"
className="campaign-copy-dialog campaign-import-dialog"
helpContextId="campaigns.action.import-package"
closeDisabled={importBusy}
onClose={() => setImportOpen(false)}
footer={<>
<Button onClick={() => setImportOpen(false)} disabled={importBusy}>Cancel</Button>
{importPackage && <Button onClick={() => void refreshImportPreview()} disabled={importBusy || importScopes.length === 0 || !importPreviewStale}>
{importBusy ? "Checking..." : "Refresh preview"}
</Button>}
<Button
variant="primary"
onClick={() => void applyImport()}
disabled={importBusy || importPreviewStale || !importPreview?.compatible || !importPreview.package_sha256}>
{importBusy ? "Importing..." : "Create draft"}
</Button>
</>}>
<div className="campaign-copy-form">
<DismissibleAlert tone="info" resetKey="campaign-portable-import-safety">
Import always creates a new draft. Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state.
</DismissibleAlert>
<FormField label="Portable Campaign package" help="Select a .govoplan-campaign.json file. Packages are integrity-checked before any draft is created.">
<input type="file" accept="application/json,.json,.govoplan-campaign.json" disabled={importBusy} onChange={(event) => void readImportFile(event.target.files?.[0])} />
</FormField>
{importError && <div className="inline-alert is-error" role="alert">{importError}</div>}
{importPreview && <>
<div className="campaign-copy-identity">
<FormField label="Campaign name">
<input value={importIdentity.name} disabled={importBusy} onChange={(event) => patchImportIdentity("name", event.target.value)} />
</FormField>
<FormField label="Campaign ID">
<input value={importIdentity.external_id} disabled={importBusy} onChange={(event) => patchImportIdentity("external_id", event.target.value)} />
</FormField>
</div>
<div className="campaign-copy-options">
{importPreview.available_scopes.map((scope) => <div className="campaign-copy-option" key={scope}>
<div>
<strong>{transferScopeLabel(scope)}</strong>
<small>{transferScopeDescription(scope)}</small>
</div>
<ToggleSwitch
label={`Import ${transferScopeLabel(scope)}`}
checked={importScopes.includes(scope)}
disabled={importBusy || scope === "recipients" && !canImportRecipients}
onChange={(checked) => toggleImportScope(scope, checked)} />
</div>)}
</div>
{importPreviewStale && <p className="muted small-note">Identity or scope choices changed. Refresh the preview before importing.</p>}
{!importPreview.compatible && <div className="inline-alert is-error" role="alert">
<strong>This package cannot be imported.</strong>
<ul>{importPreview.errors.map((item) => <li key={item}>{item}</li>)}</ul>
</div>}
{importPreview.warnings.length > 0 && <div className="inline-alert is-warning">
<strong>Review before import</strong>
<ul>{importPreview.warnings.map((item) => <li key={item}>{item}</li>)}</ul>
</div>}
<div className="campaign-import-plan">
<ImportPlan title="Will create" items={importPreview.will_create} />
<ImportPlan title="Will skip" items={importPreview.will_skip} />
</div>
<p className="muted mono-small">Package {importPreview.package_id ?? "unknown"} · SHA-256 {importPreview.package_sha256 ?? "unavailable"}</p>
</>}
</div>
</Dialog>
</>);
}
@@ -194,6 +383,40 @@ function shortId(value: string): string {
return `${value.slice(0, 12)}${value.slice(-6)}`;
}
function ImportPlan({ title, items }: {title: string;items: CampaignImportPreview["will_create"];}) {
return <div>
<h3>{title}</h3>
{items.length === 0
? <p className="muted">Nothing.</p>
: <ul>{items.map((item) => <li key={`${item.scope}:${item.code}`}>
<strong>{transferScopeLabel(item.scope)}</strong>: {item.summary}
{typeof item.item_count === "number" ? ` (${item.item_count})` : ""}
</li>)}</ul>}
</div>;
}
function transferScopeLabel(scope: CampaignTransferScope): string {
return ({
metadata: "Metadata",
template_config: "Template and configuration",
recipients: "Recipients",
attachments: "Attachments",
review_state: "Review state",
delivery_history: "Delivery history"
} satisfies Record<CampaignTransferScope, string>)[scope];
}
function transferScopeDescription(scope: CampaignTransferScope): string {
return ({
metadata: "Identity and source description for the new draft.",
template_config: "Portable fields, templates, policies, and delivery settings.",
recipients: "Campaign-local recipient rows and import provenance.",
attachments: "Attachment rules and references; never file content.",
review_state: "Historical evidence retained in the package, never replayed.",
delivery_history: "Historical outcomes retained in the package, never replayed."
} satisfies Record<CampaignTransferScope, string>)[scope];
}
function formatDateTime(value?: string): string {
return formatPlatformDateTime(value);
}