feat(campaigns): add portable campaign transfers
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user