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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.23",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "src/index.ts",
+89
View File
@@ -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");
+5
View File
@@ -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/);