All files / src/hooks useFlyerUploader.ts

95.31% Statements 61/64
87.87% Branches 58/66
100% Functions 10/10
96.66% Lines 58/60

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                                          4x                     2x 53x 53x 53x     53x   16x 16x 15x       12x           53x     16x 16x           121x   121x 6x     115x 10x 10x     105x               53x     16x 16x 16x 16x         53x 1x 1x 1x 1x         53x     53x 53x 44x 41x 7x 34x 3x 2x   31x     53x 53x   53x 10x 4x 4x 3x   3x 2x   1x 1x       6x 5x 1x   1x 1x       53x                 53x                            
// src/hooks/useFlyerUploader.ts
// src/hooks/useFlyerUploader.ts
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { uploadAndProcessFlyer, getJobStatus, type JobStatus } from '../services/aiApiClient';
import { logger } from '../services/logger.client';
import { generateFileChecksum } from '../utils/checksum';
 
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
 
// Define a type for the structured error thrown by the API client
interface ApiError {
  status: number;
  body: {
    message: string;
    flyerId?: number;
  };
}
 
// Type guard to check if an error is a structured API error
function isApiError(error: unknown): error is ApiError {
  return (
    typeof error === 'object' &&
    error !== null &&
    'status' in error &&
    typeof (error as { status: unknown }).status === 'number' &&
    'body' in error &&
    typeof (error as { body: unknown }).body === 'object' &&
    (error as { body: unknown }).body !== null &&
    'message' in ((error as { body: unknown }).body as object)
  );
}
export const useFlyerUploader = () => {
  const queryClient = useQueryClient();
  const [jobId, setJobId] = useState<string | null>(null);
  const [currentFile, setCurrentFile] = useState<string | null>(null);
 
  // Mutation for the initial file upload
  const uploadMutation = useMutation({
    mutationFn: async (file: File) => {
      setCurrentFile(file.name);
      const checksum = await generateFileChecksum(file);
      return uploadAndProcessFlyer(file, checksum);
    },
    onSuccess: (data) => {
      // When upload is successful, we get a jobId and can start polling.
      setJobId(data.jobId);
    },
    // onError is handled automatically by react-query and exposed in `uploadMutation.error`
  });
 
  // Query for polling the job status
  const { data: jobStatus, error: pollError } = useQuery({
    queryKey: ['jobStatus', jobId],
    queryFn: () => {
      Iif (!jobId) throw new Error('No job ID to poll');
      return getJobStatus(jobId);
    },
    // Only run this query if there is a jobId
    enabled: !!jobId,
    // Polling logic: react-query handles the interval
    refetchInterval: (query) => {
      const data = query.state.data as JobStatus | undefined;
      // Stop polling if the job is completed or has failed
      if (data?.state === 'completed' || data?.state === 'failed') {
        return false;
      }
      // Also stop polling if the query itself has errored (e.g. network error, or JobFailedError thrown from getJobStatus)
      if (query.state.status === 'error') {
        logger.warn('[useFlyerUploader] Polling stopped due to query error state.');
        return false;
      }
      // Otherwise, poll every 3 seconds
      return 3000;
    },
    refetchOnWindowFocus: false, // No need to refetch on focus, interval is enough
    // If a poll fails (e.g., network error), don't retry automatically.
    // The user can see the error and choose to retry manually if we build that feature.
    retry: false,
  });
 
  const upload = useCallback(
    (file: File) => {
      // Reset previous state before a new upload
      setJobId(null);
      setCurrentFile(null);
      queryClient.removeQueries({ queryKey: ['jobStatus'] });
      uploadMutation.mutate(file);
    },
    [uploadMutation, queryClient],
  );
 
  const resetUploaderState = useCallback(() => {
    setJobId(null);
    setCurrentFile(null);
    uploadMutation.reset();
    queryClient.removeQueries({ queryKey: ['jobStatus'] });
  }, [uploadMutation, queryClient]);
 
  // Consolidate state derivation for the UI from the react-query hooks using useMemo.
  // This improves performance by memoizing the derived state and makes the logic easier to follow.
  const { processingState, errorMessage, duplicateFlyerId, flyerId } = useMemo(() => {
    // The order of these checks is critical. Errors must be checked first to override
    // any stale `jobStatus` from a previous successful poll.
    const state: ProcessingState = (() => {
      if (uploadMutation.isError || pollError) return 'error';
      if (uploadMutation.isPending) return 'uploading';
      if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
        return 'polling';
      if (jobStatus?.state === 'completed') {
        if (!jobStatus.returnValue?.flyerId) return 'error';
        return 'completed';
      }
      return 'idle';
    })();
 
    let msg: string | null = null;
    let dupId: number | null = null;
 
    if (state === 'error') {
      if (uploadMutation.isError) {
        const uploadError = uploadMutation.error;
        if (isApiError(uploadError)) {
          msg = uploadError.body.message;
          // Specifically handle 409 Conflict for duplicate flyers
          if (uploadError.status === 409) {
            dupId = uploadError.body.flyerId ?? null;
          }
        } else if (uploadError instanceof Error) {
          msg = uploadError.message;
        } else E{
          msg = 'An unknown upload error occurred.';
        }
      } else if (pollError) {
        msg = `Polling failed: ${pollError.message}`;
      I} else if (jobStatus?.state === 'failed') {
        msg = `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason || 'Unknown reason'}`;
      E} else if (jobStatus?.state === 'completed' && !jobStatus.returnValue?.flyerId) {
        msg = 'Job completed but did not return a flyer ID.';
      }
    }
 
    return {
      processingState: state,
      errorMessage: msg,
      duplicateFlyerId: dupId,
      flyerId: jobStatus?.state === 'completed' ? (jobStatus.returnValue?.flyerId ?? null) : null,
      statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
    };
  }, [uploadMutation, jobStatus, pollError]);
 
  return {
    processingState,
    statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
    errorMessage,
    duplicateFlyerId,
    processingStages: jobStatus?.progress?.stages || [],
    estimatedTime: jobStatus?.progress?.estimatedTimeRemaining || 0,
    currentFile,
    flyerId,
    upload,
    resetUploaderState,
    jobId,
  };
};