Files
govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor.ts
T
zemion c51fc180fb
Module Package Release / publish-packages (push) Successful in 12s
Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
2026-09-08 01:32:26 +02:00

424 lines
16 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
import {
revisionConflictFromError,
threeWayMerge,
useConcurrencyConflictResolver
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import {
autosaveCampaignVersion,
getCampaignVersion,
type CampaignVersionDetail,
type CampaignVersionUpdatePayload
} from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { clientCampaignEditorState } from "../utils/editorState";
import { campaignMailReferencesUnchanged } from "../utils/mailProfileReference";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
type StepValue = string | (() => string | null | undefined);
type UseCampaignDraftEditorOptions = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | null;
locked: boolean;
reload: (options?: {force?: boolean;}) => Promise<void>;
setError: (message: string) => void;
currentStep: StepValue;
currentFlow?: string;
workflowState?: string;
isComplete?: boolean;
unsavedTitle: string;
unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
transformDraftBeforeSave?: (draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void;
};
function resolveStep(value: StepValue): string | null | undefined {
return typeof value === "function" ? value() : value;
}
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,
version,
locked,
reload,
setError,
currentStep,
currentFlow = "manual",
workflowState = "editing",
isComplete = false,
unsavedTitle,
unsavedMessage,
loadedLabel = defaultLoadedLabel,
transformLoadedDraft,
transformDraftBeforeSave,
extraPayload,
onLoaded,
onSaved
}: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel);
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 loadedVersionIdRef = useRef<string | null>(null);
const contextKey = JSON.stringify([campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const contextRef = useRef(contextKey);
const loadedContextRef = useRef(contextKey);
contextRef.current = contextKey;
const draftRef = useRef<Record<string, unknown> | null>(null);
const dirtyRef = useRef(false);
const editSequenceRef = useRef(0);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const discardGenerationRef = useRef(0);
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
transformDraftBeforeSaveRef.current = transformDraftBeforeSave;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
const [draft, setDraftState] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirtyState] = useState(false);
const [saving, setSaving] = useState(false);
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
const [localError, setLocalError] = useState("");
const setDraft = useCallback((update: SetStateAction<Record<string, unknown> | null>) => {
const next = typeof update === "function" ? update(draftRef.current) : update;
if (next === draftRef.current) return;
draftRef.current = next;
editSequenceRef.current += 1;
setDraftState(next);
}, []);
const setDirty = useCallback((update: SetStateAction<boolean>) => {
dirtyRef.current = typeof update === "function" ? update(dirtyRef.current) : update;
setDirtyState(dirtyRef.current);
}, []);
useEffect(() => {
if (!version) {
if (loadedContextRef.current !== contextKey) {
draftRef.current = null;
setDraftState(null);
setDirty(false);
loadedVersionIdRef.current = null;
}
return;
}
if (loadedContextRef.current === contextKey && loadedVersionIdRef.current === version.id && (
dirtyRef.current || saveInFlightRef.current || version.edit_revision < (baseRevisionRef.current ?? 0)
)) 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;
loadedVersionIdRef.current = version.id;
loadedContextRef.current = contextKey;
draftRef.current = loadedDraft;
setDraftState(loadedDraft);
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
}, [contextKey, version, setDirty]);
const markDirty = useCallback(() => {
editSequenceRef.current += 1;
setDirty(true);
setLocalError("");
}, [setDirty]);
const patch = useCallback((path: string[], value: unknown) => {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}, [locked, markDirty, setDraft]);
const saveDraft = useCallback((_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (saveInFlightRef.current) return saveInFlightRef.current;
const submittedDraft = draftRef.current;
if (!submittedDraft || !version || locked) return Promise.resolve(false);
discardGenerationRef.current += 1;
const submittedSequence = editSequenceRef.current;
const submittedVersionId = version.id;
const stillCurrent = () => loadedVersionIdRef.current === submittedVersionId && contextRef.current === contextKey;
setSaving(true);
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
setError("");
setLocalError("");
const operation = (async () => {
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(submittedDraft) ?? submittedDraft;
const additionalPayload = extraPayload?.() ?? {};
if (version.mail_profile_migration_required && !additionalPayload.migrate_legacy_mail_settings
&& !campaignMailReferencesUnchanged(baseDraftRef.current ?? getCampaignJson(version), draftToSave)) {
// Unchanged Mail references allow independent content/archive repairs;
// the server retains legacy transport without migrating it. Changing
// the reference still needs the explicit audited Mail settings action.
throw new Error("i18n:govoplan-campaign.legacy_mail_migration_required");
}
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
);
if (!stillCurrent()) return false;
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 (!stillCurrent()) return false;
if (resolution.action === "cancel") {
setSaveState("i18n:govoplan-campaign.save_cancelled");
return false;
}
if (resolution.action === "reload") {
draftRef.current = latestLoaded;
setDraftState(latestLoaded);
baseDraftRef.current = latestDraft;
baseRevisionRef.current = latest.edit_revision;
baseEtagRef.current = latest.strong_etag;
setDirty(false);
setSaveState(loadedLabelRef.current(latest));
onLoadedRef.current?.(latest, latestLoaded);
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."
);
}
if (!stillCurrent()) return false;
const savedInitial = ensureCampaignDraft(saved);
const savedDraft = transformLoadedDraftRef.current?.(saved, savedInitial) ?? savedInitial;
const newerEdits = editSequenceRef.current !== submittedSequence;
if (!newerEdits) {
draftRef.current = savedDraft;
setDraftState(savedDraft);
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(savedDraft) ?? savedDraft;
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false);
}
// A late local edit keeps its original merge base and revision. The next
// explicit save reconciles against the acknowledged server revision,
// rather than silently overwriting concurrent changes merged into it.
if (newerEdits) setDirty(true);
setSaveState(newerEdits ? "i18n:govoplan-campaign.saved_newer_changes_pending"
: `Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
try {
if (!newerEdits) {
onSaved?.(saved);
await reload();
}
} catch {
// The POST acknowledgement is authoritative. A failed refresh or
// observer must not report a committed mutation as a failed save.
setError("i18n:govoplan-campaign.saved_refresh_failed");
}
return !newerEdits && !dirtyRef.current;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
if (stillCurrent()) {
setLocalError(text);
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
}
return false;
}
})();
const pending = operation.finally(() => {
saveInFlightRef.current = null;
setSaving(false);
});
saveInFlightRef.current = pending;
return pending;
}, [campaignId, contextKey, currentFlow, currentStep, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setDirty, setError, settings, version, workflowState]);
const discardDraft = useCallback(async () => {
if (saveInFlightRef.current || !version) return;
const generation = ++discardGenerationRef.current;
const sequence = editSequenceRef.current;
const stillCurrent = () => generation === discardGenerationRef.current
&& loadedVersionIdRef.current === version.id && contextRef.current === contextKey;
try {
// Do not discard local work until a fresh version was actually read.
const latest = await getCampaignVersion(settings, campaignId, version.id);
if (!stillCurrent()) return;
if (sequence !== editSequenceRef.current || saveInFlightRef.current
|| latest.edit_revision < (baseRevisionRef.current ?? 0)) {
setLocalError("i18n:govoplan-campaign.discard_superseded");
return;
}
const initialDraft = ensureCampaignDraft(latest);
const loadedDraft = transformLoadedDraftRef.current?.(latest, initialDraft) ?? initialDraft;
draftRef.current = loadedDraft;
setDraftState(loadedDraft);
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft;
baseRevisionRef.current = latest.edit_revision;
baseEtagRef.current = latest.strong_etag;
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(latest));
onLoadedRef.current?.(latest, loadedDraft);
await reload({ force: true });
} catch (err) {
if (stillCurrent()) setLocalError(err instanceof Error ? err.message : String(err));
}
}, [campaignId, contextKey, reload, setDirty, settings, version]);
const unsavedRegistration = useMemo(() => dirty && !locked ? {
title: unsavedTitle,
message: unsavedMessage,
onSave: () => saveDraft("manual"),
onDiscard: () => { void discardDraft(); }
} : null, [dirty, discardDraft, locked, saveDraft, unsavedMessage, unsavedTitle]);
useRegisterCampaignUnsavedChanges(unsavedRegistration);
return {
draft,
setDraft,
displayDraft: draft ?? ensureCampaignDraft(null),
dirty,
saving,
setDirty,
saveState,
setSaveState,
localError,
setLocalError,
patch,
markDirty,
discardDraft,
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: clientCampaignEditorState(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
)
};
}