All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m52s
172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
// 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,
|
|
JobFailedError,
|
|
} from '../services/aiApiClient';
|
|
import { logger } from '../services/logger.client';
|
|
import { generateFileChecksum } from '../utils/checksum';
|
|
import type { ProcessingStage } from '../types';
|
|
|
|
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: () => {
|
|
if (!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, statusMessage } = 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 {
|
|
msg = 'An unknown upload error occurred.';
|
|
}
|
|
} else if (pollError) {
|
|
msg = `Polling failed: ${pollError.message}`;
|
|
} else if (jobStatus?.state === 'failed') {
|
|
msg = `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason || 'Unknown reason'}`;
|
|
} 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,
|
|
};
|
|
};
|