"use client"; import { useCallback, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { berlinCalendarDay } from "@/lib/dates"; import { ApiError } from "@/lib/auth/api-client"; import { useCurrentUserId } from "@/lib/auth/auth-context"; import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api"; import { deriveHiveFacts } from "@/lib/hive/facts"; import { hasQueuedHiveChange } from "@/lib/hive/pending"; import { applyHiveOps } from "@/lib/hive/ops"; import { fetchColonyTreatments } from "@/lib/treatments/api"; import { queueHiveChange } from "@/lib/offline/db"; import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { syncPendingEntries } from "@/lib/offline/sync"; import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; import type { ClientHiveChangeSource, HiveApplyResult, HiveCatalog, HiveOp, HiveSetupView, HiveState, } from "@/lib/hive/types"; import { showHiveFollowUps } from "./follow-up-toast"; /** The server refused the ops against its newer state (409 HIVE_CONFLICT). */ export interface HiveConflict { /** The state the server holds now. */ current: { version: number; state: HiveState }; /** The user's own ops. */ ops: HiveOp[]; /** The ops re-applied to `current.state`; a failure carries op index and reason. */ preview: HiveApplyResult; } export interface HiveMutation { /** Resolves with the saved setup; null when the change was queued and no setup is loaded to show it on. */ submit( ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string, ): Promise; conflict: HiveConflict | null; /** "retry" sends the ops again against the current state (new key); "discard" drops them. */ resolveConflict(action: "retry" | "discard"): Promise; isPending: boolean; } interface Attempt { ops: HiveOp[]; source: ClientHiveChangeSource; occurredOn: string; } /** Thrown for a submit while another one of this hook is still running. */ export const HIVE_BUSY_MESSAGE = "HIVE_BUSY"; /** Same ops, source and day: a manual re-press after a network error. */ function signatureOf(attempt: Attempt): string { return JSON.stringify([attempt.source, attempt.occurredOn, attempt.ops]); } /** No verdict from the server (network, timeout, 5xx): the change may or may not be saved. */ function isAmbiguousFailure(error: unknown): boolean { return !(error instanceof ApiError) || error.status >= 500; } function withState(view: HiveSetupView, state: HiveState, version: number, catalog: HiveCatalog): HiveSetupView { return { ...view, version, state, facts: deriveHiveFacts(state, catalog) }; } /** * Posts a change of the colony's hive. The cache shows the result at once * (optimistic); a failure refetches it. One submit at a time. An attempt keeps * its idempotency key across manual re-presses after a network error or 5xx; * a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only. * A card change that fails at network level goes into the offline queue * `pending-hive-changes` under the same key (spec 10); the card shows it from * there, so the cache keeps the state it had. */ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation { const queryClient = useQueryClient(); const router = useRouter(); const ownerUserId = useCurrentUserId(); const [conflict, setConflict] = useState(null); const [pendingCount, setPendingCount] = useState(0); const lastAttempt = useRef(null); const inFlight = useRef(false); // Key of an attempt that failed without a verdict (network, 5xx): a re-press reuses it. const failedKey = useRef<{ signature: string; key: string } | null>(null); const queryKey = hiveKeys.detail(colonyId); const hasOpenTreatment = useCallback(async (): Promise => { try { const treatments = await queryClient.fetchQuery({ queryKey: ["colonies", colonyId, "treatments"], queryFn: () => fetchColonyTreatments(colonyId), }); return treatments.some((treatment) => treatment.endDate == null); } catch { return false; } }, [colonyId, queryClient]); const suggestFollowUps = useCallback( async (attempt: Attempt, before: HiveState, after: HiveState): Promise => { const { ops, occurredOn } = attempt; const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment() : false; showHiveFollowUps(ops, before, after, catalog, { colonyId, occurredOn, navigate: (href) => router.push(href), hasOpenTreatment: open, }); }, [catalog, colonyId, hasOpenTreatment, router], ); const run = useCallback( async (attempt: Attempt): Promise => { if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE); inFlight.current = true; const { ops, source, occurredOn } = attempt; const signature = signatureOf(attempt); const key = failedKey.current?.signature === signature ? failedKey.current.key : newIdempotencyKey(); setPendingCount((count) => count + 1); try { // A running refetch must not overwrite the optimistic state. await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); // Behind an earlier queued change of this colony it must not overtake: queue it too, // whether or not the card has a setup loaded. if (ownerUserId && source === "CARD" && (await hasQueuedHiveChange(colonyId, ownerUserId))) { await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId); void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); // Online again, the queue goes out in order. void syncPendingEntries(ownerUserId, { afterChange: true }).catch(() => undefined); if (!previous) return null; const queued = applyHiveOps(previous.state, ops, catalog); return queued.ok ? withState(previous, queued.state, previous.version, catalog) : previous; } if (previous) { const applied = applyHiveOps(previous.state, ops, catalog); if (applied.ok) { queryClient.setQueryData(queryKey, withState(previous, applied.state, previous.version, catalog)); } } let optimistic: HiveSetupView | undefined; if (previous) optimistic = queryClient.getQueryData(queryKey); try { const view = await postHiveChange(colonyId, { occurredOn, source, ops }, key); queryClient.setQueryData(queryKey, view); setConflict(null); lastAttempt.current = null; failedKey.current = null; void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) }); if (previous) { // Follow-ups are best effort: a failing suggestion must not fail the saved change. suggestFollowUps(attempt, previous.state, view.state).catch(() => {}); } return view; } catch (error) { const body = hiveConflictOf(error); if (body) { failedKey.current = null; lastAttempt.current = attempt; if (previous) { queryClient.setQueryData( queryKey, withState(previous, body.current.state, body.current.version, catalog), ); } setConflict({ current: body.current, ops, preview: applyHiveOps(body.current.state, ops, catalog), }); } else if (previous && ownerUserId && source === "CARD" && !(error instanceof ApiError)) { // No network: queue it. The pending ops are shown on top of the loaded state. await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId); failedKey.current = null; lastAttempt.current = null; if (optimistic && queryClient.getQueryData(queryKey) === optimistic) { queryClient.setQueryData(queryKey, previous); } void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY }); return optimistic ?? previous; } else { failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null; // Restore only if nobody changed the cache since our optimistic write; then refetch. if (previous && optimistic && queryClient.getQueryData(queryKey) === optimistic) { queryClient.setQueryData(queryKey, previous); } void queryClient.invalidateQueries({ queryKey }); } throw error; } } finally { inFlight.current = false; setPendingCount((count) => count - 1); } }, [catalog, colonyId, ownerUserId, queryClient, queryKey, suggestFollowUps], ); const submit = useCallback( (ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string) => run({ ops, source, occurredOn: occurredOn ?? berlinCalendarDay(new Date().toISOString()) }), [run], ); const resolveConflict = useCallback( async (action: "retry" | "discard"): Promise => { const attempt = lastAttempt.current; if (action === "discard" || !attempt) { lastAttempt.current = null; setConflict(null); return; } if (conflict && !conflict.preview.ok) return; try { await run(attempt); } catch (error) { // A new conflict is already in `conflict`; anything else is for the caller. if (!hiveConflictOf(error)) throw error; } }, [conflict, run], ); return { submit, conflict, resolveConflict, isPending: pendingCount > 0 }; }