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,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"