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,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"
|
||||
|
||||
Reference in New Issue
Block a user