feat: harden campaign delivery and editing
This commit is contained in:
@@ -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
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user