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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -234,6 +234,52 @@ export type CampaignCopyOptions = {
|
||||
include_mail_profile: boolean;
|
||||
};
|
||||
|
||||
export type CampaignTransferScope =
|
||||
| "metadata"
|
||||
| "template_config"
|
||||
| "recipients"
|
||||
| "attachments"
|
||||
| "review_state"
|
||||
| "delivery_history";
|
||||
|
||||
export type CampaignPortablePackage = {
|
||||
format: "govoplan.campaign-portable";
|
||||
format_version: string;
|
||||
package_id: string;
|
||||
exported_at: string;
|
||||
source: Record<string, unknown>;
|
||||
scopes: CampaignTransferScope[];
|
||||
manifest: Record<string, unknown>;
|
||||
payload: Record<string, unknown>;
|
||||
integrity: { algorithm: string; package_sha256: string };
|
||||
};
|
||||
|
||||
export type CampaignTransferPlanItem = {
|
||||
scope: CampaignTransferScope;
|
||||
code: string;
|
||||
summary: string;
|
||||
item_count?: number | null;
|
||||
};
|
||||
|
||||
export type CampaignImportPreview = {
|
||||
compatible: boolean;
|
||||
package_id?: string | null;
|
||||
package_sha256?: string | null;
|
||||
format_version?: string | null;
|
||||
source: Record<string, unknown>;
|
||||
available_scopes: CampaignTransferScope[];
|
||||
selected_scopes: CampaignTransferScope[];
|
||||
destination: { external_id?: string; name?: string; status?: string };
|
||||
will_create: CampaignTransferPlanItem[];
|
||||
will_skip: CampaignTransferPlanItem[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type CampaignImportApplyResponse = CampaignCreateResponse & {
|
||||
receipt: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignScheduleOccurrence = {
|
||||
id: string;
|
||||
schedule_id: string;
|
||||
@@ -1339,6 +1385,49 @@ options: CampaignCopyOptions)
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportCampaignPackage(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
scopes: CampaignTransferScope[])
|
||||
: Promise<CampaignPortablePackage> {
|
||||
return apiFetch<CampaignPortablePackage>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/exports`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ scopes })
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewCampaignImport(
|
||||
settings: ApiSettings,
|
||||
payload: {
|
||||
package: Record<string, unknown>;
|
||||
selected_scopes?: CampaignTransferScope[] | null;
|
||||
external_id?: string;
|
||||
name?: string;
|
||||
})
|
||||
: Promise<CampaignImportPreview> {
|
||||
return apiFetch<CampaignImportPreview>(settings, "/api/v1/campaign-transfers/imports/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function importCampaignPackage(
|
||||
settings: ApiSettings,
|
||||
payload: {
|
||||
package: Record<string, unknown>;
|
||||
selected_scopes: CampaignTransferScope[];
|
||||
external_id?: string;
|
||||
name?: string;
|
||||
expected_package_sha256: string;
|
||||
})
|
||||
: Promise<CampaignImportApplyResponse> {
|
||||
return apiFetch<CampaignImportApplyResponse>(settings, "/api/v1/campaign-transfers/imports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCampaignSchedules(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function CampaignModulePage({
|
||||
? <OperatorQueuePage settings={settings} auth={auth} />
|
||||
: active === "reports"
|
||||
? <AggregateReportsPage settings={settings} />
|
||||
: <CampaignListPage settings={settings} />}
|
||||
: <CampaignListPage settings={settings} auth={auth} />}
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { Archive, CalendarClock, Copy, Download, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { FormGrid, Button } from "@govoplan/core-webui";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
copyCampaign,
|
||||
createCampaignSchedule,
|
||||
deleteCampaign,
|
||||
exportCampaignPackage,
|
||||
getCampaignLifecyclePolicy,
|
||||
lockCampaignVersionPermanently,
|
||||
lockCampaignVersionTemporarily,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type CampaignScheduleCreate,
|
||||
type CampaignLifecyclePolicy,
|
||||
type CampaignCopyOptions,
|
||||
type CampaignTransferScope,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
summaryValue } from
|
||||
"./utils/campaignView";
|
||||
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
type LockAction = "temporary" | "unlock" | "permanent";
|
||||
@@ -68,6 +71,7 @@ const defaultCopyOptions: CampaignCopyOptions = {
|
||||
include_policies: true,
|
||||
include_mail_profile: true
|
||||
};
|
||||
const defaultExportScopes: CampaignTransferScope[] = ["metadata", "template_config"];
|
||||
|
||||
function defaultScheduleDraft(): CampaignScheduleCreate {
|
||||
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||
@@ -104,6 +108,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [exportScopes, setExportScopes] = useState<CampaignTransferScope[]>(defaultExportScopes);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||
@@ -113,11 +120,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
||||
const canExport = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:export");
|
||||
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
||||
const canAutonomousSchedule = canSchedule
|
||||
&& hasScope(auth, "campaigns:campaign:queue")
|
||||
&& hasScope(auth, "campaigns:campaign:send")
|
||||
&& hasScope(auth, "mail:profile:use");
|
||||
const canExportRecipients = hasScope(auth, "campaigns:recipient:read") && hasScope(auth, "campaigns:recipient:export");
|
||||
const canExportReview = hasScope(auth, "campaigns:report:read");
|
||||
const canExportDelivery = canExportRecipients && hasScope(auth, "campaigns:report:export");
|
||||
|
||||
function openSection(section: string, fragment = "") {
|
||||
const params = new URLSearchParams();
|
||||
@@ -352,6 +363,36 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportScope(scope: CampaignTransferScope, checked: boolean) {
|
||||
setExportScopes((current) => checked
|
||||
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
|
||||
: current.filter((item) => item !== scope));
|
||||
}
|
||||
|
||||
async function exportPortablePackage() {
|
||||
if (!campaign || !data.currentVersion || exportBusy || exportScopes.length === 0) return;
|
||||
setExportBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const portablePackage = await exportCampaignPackage(
|
||||
settings,
|
||||
campaign.id,
|
||||
data.currentVersion.id,
|
||||
exportScopes
|
||||
);
|
||||
downloadJson(
|
||||
`${safeFileStem(campaign.external_id || campaign.name)}-v${data.currentVersion.version_number ?? 1}.govoplan-campaign.json`,
|
||||
portablePackage
|
||||
);
|
||||
setExportDialogOpen(false);
|
||||
setMessage("Portable Campaign package downloaded. Keep recipient or delivery packages in an approved location.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setExportBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
archetype="editor"
|
||||
@@ -364,7 +405,16 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
actions={<PageActionBar
|
||||
variant="editor"
|
||||
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reload(), loading }}
|
||||
primaryActions={<>
|
||||
{canExport && <Button
|
||||
onClick={() => setExportDialogOpen(true)}
|
||||
disabled={loading || savingIdentity || identityDirty || exportBusy}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before exporting." : undefined}>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
Export package
|
||||
</Button>}
|
||||
{canCopy && data.currentVersion && <Button
|
||||
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
@@ -580,6 +630,34 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={exportDialogOpen}
|
||||
title="Export portable Campaign package"
|
||||
className="campaign-copy-dialog"
|
||||
helpContextId="campaigns.action.export-package"
|
||||
closeDisabled={exportBusy}
|
||||
onClose={() => setExportDialogOpen(false)}
|
||||
footer={<>
|
||||
<Button onClick={() => setExportDialogOpen(false)} disabled={exportBusy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void exportPortablePackage()} disabled={exportBusy || exportScopes.length === 0}>
|
||||
{exportBusy ? "Preparing package..." : "Download package"}
|
||||
</Button>
|
||||
</>}>
|
||||
<div className="campaign-copy-form">
|
||||
<p className="muted small-note">
|
||||
Configuration-only is the privacy-safe default. The package contains JSON and attachment references, never file content, credentials, or transport secrets.
|
||||
</p>
|
||||
<div className="campaign-copy-options">
|
||||
<CopyOption label="Metadata" detail="Campaign identity, description, and source status." checked={exportScopes.includes("metadata")} onChange={(checked) => toggleExportScope("metadata", checked)} />
|
||||
<CopyOption label="Template and configuration" detail="Fields, template, validation and delivery settings. Deployment-bound Mail credentials are excluded." checked={exportScopes.includes("template_config")} onChange={(checked) => toggleExportScope("template_config", checked)} />
|
||||
<CopyOption label="Recipients" detail="Recipient rows and import provenance. This can contain personal data and needs recipient-export authority." checked={exportScopes.includes("recipients")} disabled={!canExportRecipients} onChange={(checked) => toggleExportScope("recipients", checked)} />
|
||||
<CopyOption label="Attachments" detail="Global and per-recipient attachment rules. File bytes are not embedded." checked={exportScopes.includes("attachments")} onChange={(checked) => toggleExportScope("attachments", checked)} />
|
||||
<CopyOption label="Review state" detail="Aggregate validation, build, issue, and review evidence. Imports retain provenance but never replay approval state." checked={exportScopes.includes("review_state")} disabled={!canExportReview} onChange={(checked) => toggleExportScope("review_state", checked)} />
|
||||
<CopyOption label="Delivery history" detail="Recipient-level delivery outcomes and safe provenance. Imports never recreate sent state." checked={exportScopes.includes("delivery_history")} disabled={!canExportDelivery} onChange={(checked) => toggleExportScope("delivery_history", checked)} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={scheduleDialogOpen}
|
||||
title="Schedule campaign"
|
||||
|
||||
@@ -121,7 +121,7 @@ export function getText(record: Record<string, unknown>, key: string, fallback =
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function downloadJson(filename: string, data: Record<string, unknown>) {
|
||||
export function downloadJson(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
@@ -2726,10 +2726,15 @@
|
||||
.campaign-copy-option strong, .campaign-copy-option small { display: block; }
|
||||
.campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; }
|
||||
.campaign-copy-option.is-disabled { opacity: .62; }
|
||||
.campaign-import-plan { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.campaign-import-plan h3 { margin: 0 0 8px; font-size: 1rem; }
|
||||
.campaign-import-plan ul { margin: 0; padding-left: 20px; }
|
||||
.campaign-import-plan li + li { margin-top: 6px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.campaign-copy-identity { grid-template-columns: 1fr; }
|
||||
.campaign-copy-option { grid-template-columns: 1fr; }
|
||||
.campaign-import-plan { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
||||
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
|
||||
const campaignList = readFileSync("src/features/campaigns/CampaignListPage.tsx", "utf8");
|
||||
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
|
||||
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
|
||||
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
|
||||
@@ -29,10 +30,20 @@ assert.match(overview, /schedule\.last_outcome/);
|
||||
assert.match(overview, /schedule\.last_recovery_state/);
|
||||
assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/);
|
||||
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
||||
assert.match(overview, /defaultExportScopes: CampaignTransferScope\[\] = \["metadata", "template_config"\]/);
|
||||
assert.match(overview, /hasScope\(auth, "campaigns:campaign:export"\)/);
|
||||
assert.match(overview, /await exportCampaignPackage/);
|
||||
assert.match(overview, /credentials, or transport secrets/);
|
||||
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
|
||||
assert.match(api, /\/api\/v1\/campaign-transfers\/imports\/preview/);
|
||||
assert.match(api, /expected_package_sha256/);
|
||||
assert.match(campaignList, /hasScope\(auth, "campaigns:campaign:import"\)/);
|
||||
assert.match(campaignList, /setImportPreviewStale\(true\)/);
|
||||
assert.match(campaignList, /Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state/);
|
||||
assert.match(campaignList, /expected_package_sha256: importPreview\.package_sha256/);
|
||||
assert.match(recipients, /requestBulkActivation\(true\)/);
|
||||
assert.match(recipients, /requestBulkActivation\(false\)/);
|
||||
assert.match(recipients, /const count = inlineEntries\.filter/);
|
||||
|
||||
Reference in New Issue
Block a user