feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions
@@ -1,6 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
revisionConflictFromError,
threeWayMerge,
useConcurrencyConflictResolver
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import {
autosaveCampaignVersion,
getCampaignVersion,
type CampaignVersionDetail,
type CampaignVersionUpdatePayload
} from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
@@ -36,6 +46,26 @@ function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
}
const CAMPAIGN_PROTECTED_MERGE_PATHS = [
"/current_flow",
"/current_step",
"/workflow_state",
"/is_complete",
"/editor_state",
"/source_filename",
"/source_base_path",
"/migrate_legacy_mail_settings",
"/campaign_json/delivery",
"/campaign_json/server",
"/campaign_json/security",
"/campaign_json/signatures",
"/campaign_json/encryption",
"/campaign_json/_retention",
"/campaign_json/campaign/owner_user_id",
"/campaign_json/campaign/owner_group_id",
"/campaign_json/campaign/status"
] as const;
export function useCampaignDraftEditor({
settings,
campaignId,
@@ -60,6 +90,10 @@ export function useCampaignDraftEditor({
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded);
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
const baseRevisionRef = useRef<number | null>(null);
const baseEtagRef = useRef<string | null>(null);
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
useEffect(() => {
loadedLabelRef.current = loadedLabel;
@@ -77,6 +111,11 @@ export function useCampaignDraftEditor({
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
baseDraftRef.current = (
transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft
);
baseRevisionRef.current = version.edit_revision;
baseEtagRef.current = version.strong_etag;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
@@ -102,15 +141,100 @@ export function useCampaignDraftEditor({
setLocalError("");
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draftToSave,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
});
const additionalPayload = extraPayload?.() ?? {};
let mutation = campaignVersionMutation(
version,
draftToSave,
{
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...additionalPayload
}
);
let mergeBase = campaignVersionMutation(
version,
baseDraftRef.current ?? draftToSave
);
let expectedRevision = baseRevisionRef.current ?? version.edit_revision;
let expectedEtag = baseEtagRef.current ?? version.strong_etag;
let reconciliationKind: "none" | "auto_merge" | "manual" = "none";
let resolvedConflictPaths: string[] = [];
let saved: CampaignVersionDetail | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
...mutation,
base_revision: expectedRevision,
reconciliation_kind: reconciliationKind,
resolved_conflict_paths: resolvedConflictPaths
}, expectedEtag);
break;
} catch (error) {
const conflict = revisionConflictFromError(error);
if (!conflict) throw error;
const latest = await getCampaignVersion(
settings,
campaignId,
version.id
);
const latestLoaded = transformLoadedDraftRef.current?.(
latest,
ensureCampaignDraft(latest)
) ?? ensureCampaignDraft(latest);
const latestDraft = (
transformDraftBeforeSaveRef.current?.(latestLoaded) ?? latestLoaded
);
const latestMutation = campaignVersionMutation(
latest,
latestDraft
);
const merge = threeWayMerge(
mergeBase,
mutation,
latestMutation,
{
protectedPaths: CAMPAIGN_PROTECTED_MERGE_PATHS,
stableIdFields: ["id", "entry_id"]
}
);
if (merge.conflicts.length === 0) {
mutation = merge.value;
reconciliationKind = "auto_merge";
resolvedConflictPaths = merge.appliedPaths;
} else {
const resolution = await resolveConcurrencyConflict({
resourceLabel: `Campaign version ${latest.version_number}`,
merge
});
if (resolution.action === "cancel") return false;
if (resolution.action === "reload") {
setDirty(false);
await reload({ force: true });
return false;
}
mutation = resolution.value;
reconciliationKind = "manual";
resolvedConflictPaths = resolution.resolvedPaths;
}
mergeBase = latestMutation;
expectedRevision = latest.edit_revision;
expectedEtag = latest.strong_etag;
}
}
if (!saved) {
throw new Error(
"The campaign changed repeatedly while it was being saved. Reload and try again."
);
}
setDraft(getCampaignJson(saved));
baseDraftRef.current = getCampaignJson(saved);
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
@@ -122,7 +246,7 @@ export function useCampaignDraftEditor({
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setError, settings, version, workflowState]);
const discardDraft = useCallback(async () => {
if (version) {
@@ -162,3 +286,23 @@ export function useCampaignDraftEditor({
saveDraft
};
}
function campaignVersionMutation(
version: CampaignVersionDetail,
campaignJson: Record<string, unknown>,
overrides: Partial<CampaignVersionUpdatePayload> = {}
): CampaignVersionUpdatePayload {
return {
campaign_json: campaignJson,
current_flow: overrides.current_flow ?? version.current_flow ?? null,
current_step: overrides.current_step ?? version.current_step ?? null,
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
is_complete: overrides.is_complete ?? version.is_complete ?? false,
editor_state: overrides.editor_state ?? version.editor_state ?? {},
source_filename: overrides.source_filename ?? version.source_filename ?? null,
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
migrate_legacy_mail_settings: (
overrides.migrate_legacy_mail_settings ?? false
)
};
}