All files / src/hooks/transactions useAsyncTransactionExport.ts

76.74% Statements 66/86
64.7% Branches 33/51
86.66% Functions 13/15
77.38% Lines 65/84

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305                                        539x           539x         539x 356x 356x   356x   3x 3x         3x   3x                                               539x       356x 356x   356x     3x     3x                           356x 73x   3x 3x     3x                                             3x 3x 3x 3x 3x             3x         3x 3x 3x     3x 3x           3x                                                           3x           356x             539x                             356x 356x         356x 64x 64x 64x           356x     356x 356x     356x   70x                     70x       356x 70x 3x   67x     356x   356x     356x 70x             3x 3x       356x 3x 3x 3x 3x     356x         356x                                
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
  initiateTransactionExport,
  getBackgroundTaskStatus,
} from "@services/api/transactions";
import type {
  TransactionExportTask,
  BackgroundTaskStatus,
} from "@customTypes/backgroundTasks";
import { useGetCurrentMerchantId } from "@hooks/common";
import { showMessage } from "@common/Toast";
import { useEffect, useRef, useState } from "react";
import {
  activeExports,
  cleanupAllAsyncExports,
  handleExportDownload,
  startGlobalPolling,
} from "@hooks/exports/asyncExportCore";
import { getTaskErrorMessage } from "@hooks/exports/asyncExport.helpers";
 
const QUERY_KEY_PREFIX = "transaction-export-task";
 
/**
 * Export cleanup function for use in logout or account switching.
 * Re-exported here for backward compatibility with existing import sites.
 */
export const cleanupAsyncExports = cleanupAllAsyncExports;
 
/**
 * Hook to initiate an async transaction export
 */
export const useInitiateTransactionExport = () => {
  const { merchantId } = useGetCurrentMerchantId();
  const queryClient = useQueryClient();
 
  return useMutation(
    async ({ filters, sorting }: { filters?: string; sorting?: string }) => {
      Iif (!merchantId) throw new Error("Merchant ID is required");
      return await initiateTransactionExport(merchantId, filters, sorting);
    },
    {
      onSuccess: () => {
        // Invalidate any existing export tasks queries
        queryClient.invalidateQueries(["transaction-export-task"]);
 
        showMessage(
          "Info",
          "Your export is being prepared. This may take a few minutes.",
          true,
          "Export Started",
          3000,
        );
      },
      onError: (error: Error) => {
        showMessage(
          "Error",
          error?.message || "Failed to initiate export. Please try again.",
          true,
          "Export Failed",
        );
      },
    },
  );
};
 
/**
 * Hook to poll the status of a transaction export task
 * Uses global polling that continues regardless of component lifecycle
 */
export const useTransactionExportTaskStatus = (
  taskId: number | null,
  exportType = "default",
) => {
  const { merchantId } = useGetCurrentMerchantId();
  const queryClient = useQueryClient();
 
  const query = useQuery<TransactionExportTask>(
    [QUERY_KEY_PREFIX, merchantId, taskId],
    async (): Promise<TransactionExportTask> => {
      Iif (!merchantId || !taskId) {
        throw new Error("Merchant ID and Task ID are required");
      }
      return (await getBackgroundTaskStatus(
        merchantId,
        taskId,
      )) as TransactionExportTask;
    },
    {
      enabled: Boolean(merchantId && taskId),
      refetchOnWindowFocus: true,
      staleTime: 0,
      cacheTime: Infinity,
    },
  );
 
  // Start global polling when task is active, or handle immediate completion
  useEffect(() => {
    if (!merchantId || !taskId || !query.data) return;
 
    const storageKey = `transaction-export-task-${merchantId}-${exportType}`;
    const taskData = query.data;
 
    // If task is running, start global polling
    Iif (taskData.status === "running") {
      startGlobalPolling(
        merchantId,
        taskId,
        queryClient,
        storageKey,
        QUERY_KEY_PREFIX,
      );
    }
 
    // If task completed before polling started (fast export), handle it immediately.
    //
    // activeExports normally gets populated by startGlobalPolling when status is
    // "running". When the BE worker is faster than our polling interval and the
    // first /background-tasks/:id poll already returns "completed",
    // startGlobalPolling never runs for this task, so activeExports has no entry
    // and the previous `if (existingExport && !existingExport.downloadAttempted)`
    // guard silently rejected the case the comment said it was handling — the
    // user saw the "Export started" toast and nothing else.
    //
    // Register an entry on first observation so the downloadAttempted flag still
    // dedupes a re-entrant render of the same completed task, then trigger the
    // download and clean up.
    Eif (taskData.status === "completed") {
      const key = `${merchantId}-${taskId}`;
      let entry = activeExports.get(key);
      Eif (!entry) {
        entry = {
          merchantId,
          taskId,
          downloadAttempted: false,
          storageKey,
          queryKeyPrefix: QUERY_KEY_PREFIX,
        };
        activeExports.set(key, entry);
      }
 
      // Flip the flag before downloading so a re-entrant render can't trigger a
      // second download while handleExportDownload is awaiting.
      Eif (!entry.downloadAttempted) {
        entry.downloadAttempted = true;
        handleExportDownload(taskData, merchantId);
 
        // Clean up — the task is done as far as the FE is concerned.
        activeExports.delete(key);
        sessionStorage.removeItem(storageKey);
      }
    }
 
    // Same shape as the completed branch: register-then-act so a fast-failing
    // task (status "failed" on the first poll) still surfaces the error toast.
    Iif (taskData.status === "failed") {
      const key = `${merchantId}-${taskId}`;
      let entry = activeExports.get(key);
      if (!entry) {
        entry = {
          merchantId,
          taskId,
          downloadAttempted: false,
          storageKey,
          queryKeyPrefix: QUERY_KEY_PREFIX,
        };
        activeExports.set(key, entry);
      }
 
      if (!entry.downloadAttempted) {
        entry.downloadAttempted = true;
        showMessage(
          "Error",
          getTaskErrorMessage(taskData.error),
          true,
          "Export Failed",
        );
 
        // Clean up
        activeExports.delete(key);
        sessionStorage.removeItem(storageKey);
      }
    }
 
    // Cleanup on unmount - but don't stop polling, just let it continue globally
    return () => {
      // Note: We intentionally don't stop polling here
      // The global polling will continue and stop automatically when task completes
    };
  }, [merchantId, taskId, query.data, queryClient]);
 
  return query;
};
 
/**
 * Hook to manage the full async export workflow
 * Returns state and functions to handle export, polling, and download
 */
export const useAsyncTransactionExport = (
  exportType = "default",
): {
  startExport: (
    filters?: string,
    sorting?: string,
  ) => Promise<TransactionExportTask>;
  cancelExport: () => void;
  isExporting: boolean;
  isInitiating: boolean;
  isPolling: boolean;
  taskData: TransactionExportTask | undefined;
  activeTaskId: number | null;
  progress: BackgroundTaskStatus | "processing" | undefined;
} => {
  const { merchantId } = useGetCurrentMerchantId();
  const storageKey = `transaction-export-task-${merchantId}-${exportType}`;
 
  // SessionStorage persists the active task ID across page refreshes.
  // The global polling uses an in-memory Map that gets cleared on refresh,
  // so we need sessionStorage to restore the task ID and resume polling.
  const getStoredTaskId = () => {
    try {
      const stored = sessionStorage.getItem(storageKey);
      return stored ? Number(stored) : null;
    } catch {
      return null;
    }
  };
 
  const [activeTaskId, setActiveTaskId] = useState<number | null>(
    getStoredTaskId,
  );
  const downloadAttemptedRef = useRef(false);
  const previousMerchantIdRef = useRef<number | undefined>(merchantId);
 
  // Clear exports when merchant changes (account selector) or on unmount (logout)
  useEffect(() => {
    // Check if merchant changed
    Iif (
      previousMerchantIdRef.current !== undefined &&
      previousMerchantIdRef.current !== merchantId
    ) {
      // Merchant changed - clear all exports
      cleanupAllAsyncExports();
      setActiveTaskId(null);
      downloadAttemptedRef.current = false;
    }
 
    // Update the ref
    previousMerchantIdRef.current = merchantId;
  }, [merchantId, activeTaskId]);
 
  // Persist task ID to storage whenever it changes
  useEffect(() => {
    if (activeTaskId) {
      sessionStorage.setItem(storageKey, String(activeTaskId));
    } else {
      sessionStorage.removeItem(storageKey);
    }
  }, [activeTaskId, storageKey]);
  const initiateMutation = useInitiateTransactionExport();
  const { data: taskData, isLoading: isPolling } =
    useTransactionExportTaskStatus(activeTaskId, exportType);
 
  // Sync local state with global polling results
  useEffect(() => {
    if (
      taskData?.status === "completed" ||
      taskData?.status === "failed" ||
      taskData?.status === "cancelled"
    ) {
      // Global polling already handled download/notifications
      // Just clean up local state
      setActiveTaskId(null);
      downloadAttemptedRef.current = false;
    }
  }, [taskData]);
 
  const startExport = async (filters?: string, sorting?: string) => {
    downloadAttemptedRef.current = false;
    const result = await initiateMutation.mutateAsync({ filters, sorting });
    setActiveTaskId(result.id);
    return result;
  };
 
  const cancelExport = () => {
    setActiveTaskId(null);
    downloadAttemptedRef.current = false;
  };
 
  return {
    startExport,
    cancelExport,
    isExporting:
      initiateMutation.isLoading ||
      (Boolean(activeTaskId) &&
        taskData?.status !== "completed" &&
        taskData?.status !== "failed" &&
        taskData?.status !== "cancelled"),
    isInitiating: initiateMutation.isLoading,
    isPolling,
    taskData,
    activeTaskId,
    progress: taskData?.status === "running" ? "processing" : taskData?.status,
  };
};