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; 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) => Record; transformDraftBeforeSave?: (draft: Record) => Record; extraPayload?: () => Partial; onLoaded?: (version: CampaignVersionDetail, draft: Record) => 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 | null>(null); const baseRevisionRef = useRef(null); const baseEtagRef = useRef(null); const loadedVersionIdRef = useRef(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 | null>(null); const dirtyRef = useRef(false); const editSequenceRef = useRef(0); const saveInFlightRef = useRef | 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 | 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 | 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) => { 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 => { 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, overrides: Partial = {} ): 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 ) }; }