From d71c944472517c93d4bb1d831dff172c774edd7d Mon Sep 17 00:00:00 2001 From: Bhekani Khumalo Date: Sun, 12 Oct 2025 20:31:21 +0100 Subject: [PATCH 1/6] fix: prevent interview creation with incomplete job data Add validation to interview creation endpoint to prevent crashes when candidateDetails or jobDescription are null during extraction. Update types to reflect nullable fields and add null checks in session context. --- src/app/api/interviews/route.ts | 40 +++++++++++++++++++ .../voice-provider-config.ts | 13 +++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/app/api/interviews/route.ts b/src/app/api/interviews/route.ts index 07822d56..b3938461 100644 --- a/src/app/api/interviews/route.ts +++ b/src/app/api/interviews/route.ts @@ -46,6 +46,46 @@ export const POST = withAuth( }); } + // Verify that the job has required data before creating interview + const job = await db.query.jobs.findFirst({ + where: eq(jobs.id, jobId), + with: { + candidateDetails: true, + jobDescription: true, + }, + }); + + if (!job) { + logger.warn({ jobId }, "Job not found"); + return NextResponse.json(formatErrorEntity("Job not found"), { + status: 404, + }); + } + + if (!job.candidateDetails) { + logger.warn({ jobId }, "Cannot create interview: candidate details not extracted yet"); + return NextResponse.json( + formatErrorEntity( + "Candidate details are still being extracted. Please wait a moment and try again." + ), + { + status: 400, + } + ); + } + + if (!job.jobDescription) { + logger.warn({ jobId }, "Cannot create interview: job description not extracted yet"); + return NextResponse.json( + formatErrorEntity( + "Job description is still being extracted. Please wait a moment and try again." + ), + { + status: 400, + } + ); + } + // Only check for existing interview if humeChatId is provided if (humeChatId) { const existingInterview = await db.query.interviews.findFirst({ diff --git a/src/components/interview-container/voice-provider-config.ts b/src/components/interview-container/voice-provider-config.ts index 730e803b..72a22cca 100644 --- a/src/components/interview-container/voice-provider-config.ts +++ b/src/components/interview-container/voice-provider-config.ts @@ -10,23 +10,26 @@ export interface InterviewWithJob { job: { candidateDetails: { name: string; - }; + } | null; jobDescription: { role: string | null; company: string | null; - }; + } | null; }; } export function createSessionContext(interview: Entity | undefined | null) { if (!interview?.data) return ""; + if (!interview.data.job.candidateDetails) return ""; + if (!interview.data.job.jobDescription) return ""; const { duration, actualTime, type, job, keyQuestions } = interview.data; const interviewDuration = actualTime ? duration - actualTime : duration; const interviewType = formatInterviewType(type || "behavioral"); - const candidateName = job.candidateDetails.name; - const role = job.jobDescription.role || "the specified"; - const company = job.jobDescription.company || "the company"; + // Non-null assertions safe here due to checks above + const candidateName = job.candidateDetails!.name; + const role = job.jobDescription!.role || "the specified"; + const company = job.jobDescription!.company || "the company"; const baseContext = `You are an AI interviewer called Cora, the lead interviewer at Interview Optimiser. You are conducting a ${interviewDuration} minute ${interviewType} mock interview with ${candidateName} to help them prepare for a ${role} job at ${company}. Your goal is to ask relevant, insightful questions based on the candidate data and job role information, focusing on ${interviewType} questions. From 961269bc29aa933db87b85ef30e41635e7da39ec Mon Sep 17 00:00:00 2001 From: Bhekani Khumalo Date: Sun, 12 Oct 2025 20:31:32 +0100 Subject: [PATCH 2/6] feat: add dynamic cache invalidation for incomplete job data Implement conditional staleTime in useJob and new useInterview hook to automatically refetch when candidateDetails or jobDescription are missing. Update UI to show loading states during data extraction. --- src/components/interview-placeholder.tsx | 22 ++++++++----- src/hooks/useCustomisedSystemPrompt.tsx | 24 ++------------- src/hooks/useInterview.ts | 39 ++++++++++++++++++++++++ src/hooks/useJob.ts | 16 +++++++++- 4 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useInterview.ts diff --git a/src/components/interview-placeholder.tsx b/src/components/interview-placeholder.tsx index 9e25ec74..384cfd92 100644 --- a/src/components/interview-placeholder.tsx +++ b/src/components/interview-placeholder.tsx @@ -31,6 +31,8 @@ export function InterviewPlaceholder({ accessToken, configId }: InterviewPlaceho const params = useParams(); const jobId = params.jobId as string; const queryClient = useQueryClient(); + // useJob will automatically refetch if candidateDetails or jobDescription are missing + // staleTime = 0 when incomplete, so cache is never used for partial data const { data: job, isLoading, error } = useJob(jobId); const { data: user, isLoading: isUserLoading } = useUser(); const router = useRouter(); @@ -146,6 +148,8 @@ export function InterviewPlaceholder({ accessToken, configId }: InterviewPlaceho } const hasEnoughMinutes = user?.minutes && user.minutes >= (interviewToBeCreated?.duration || 0); + const isDataBeingExtracted = !job?.data?.candidateDetails || !job?.data?.jobDescription; + const canStartInterview = hasEnoughMinutes && !isDataBeingExtracted; return (
@@ -248,21 +252,25 @@ export function InterviewPlaceholder({ accessToken, configId }: InterviewPlaceho initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.5, delay: 0.8 }} + className="flex flex-col items-center gap-2" > + {isDataBeingExtracted && ( +

+ Extracting candidate details and job information... +

+ )} + {!hasEnoughMinutes && !isDataBeingExtracted && ( +

Not enough minutes available

+ )} {/* Navigation Buttons */} diff --git a/src/hooks/useCustomisedSystemPrompt.tsx b/src/hooks/useCustomisedSystemPrompt.tsx index 5c4b9dfc..ccabdf1c 100644 --- a/src/hooks/useCustomisedSystemPrompt.tsx +++ b/src/hooks/useCustomisedSystemPrompt.tsx @@ -1,20 +1,10 @@ "use client"; -import { getRepository } from "@/lib/data/repositoryFactory"; import { createInterviewInstructions } from "@/utils/conversation_config"; -import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import type { InferResultType } from "~/db/helpers"; -import type { CandidateDetails, JobDescription } from "~/db/schema"; +import { useInterview } from "./useInterview"; import { useJob } from "./useJob"; -type InterviewWithJobDescriptionAndCandidateDetails = InferResultType<"interviews"> & { - job: { - jobDescription: JobDescription; - candidateDetails: CandidateDetails; - }; -}; - export default function useCustomisedSystemPrompt({ jobId, interviewId, @@ -23,17 +13,7 @@ export default function useCustomisedSystemPrompt({ interviewId: string; }) { const { data: job, isLoading: jobIsLoading } = useJob(jobId); - - const { data: interview, isLoading: interviewIsLoading } = useQuery({ - queryKey: ["interview", interviewId], - queryFn: async () => { - const interviewRepo = - await getRepository("interviews"); - return await interviewRepo.getById(interviewId); - }, - enabled: !!interviewId, - staleTime: 30000, // Cache valid for 30s, allows instant load from cache while background refetch happens - }); + const { data: interview, isLoading: interviewIsLoading } = useInterview(interviewId); const systemPrompt = useMemo(() => { if (!job) return ""; diff --git a/src/hooks/useInterview.ts b/src/hooks/useInterview.ts new file mode 100644 index 00000000..a1b21e81 --- /dev/null +++ b/src/hooks/useInterview.ts @@ -0,0 +1,39 @@ +import { getRepository } from "@/lib/data/repositoryFactory"; +import { useQuery } from "@tanstack/react-query"; +import type { InferResultType } from "~/db/helpers"; +import type { CandidateDetails, JobDescription } from "~/db/schema"; + +type InterviewWithJobDescriptionAndCandidateDetails = InferResultType<"interviews"> & { + job: { + jobDescription: JobDescription | null; + candidateDetails: CandidateDetails | null; + }; +}; + +export function useInterview(interviewId: string) { + return useQuery({ + queryKey: ["interview", interviewId], + queryFn: async () => { + const interviewRepo = + await getRepository("interviews"); + return await interviewRepo.getById(interviewId); + }, + enabled: !!interviewId, + // Dynamic staleTime: if job data is incomplete, mark as stale immediately + // This forces refetch on every useInterview call until extraction completes + staleTime: (query) => { + const interview = query.state.data; + + // If no interview data yet, consider immediately stale + if (!interview?.data?.job) return 0; + + // Check if candidate details and job description are extracted + const isDataComplete = + interview.data.job.candidateDetails && interview.data.job.jobDescription; + + // If incomplete: staleTime = 0 (refetch on every mount) + // If complete: staleTime = 30s (normal caching) + return isDataComplete ? 30000 : 0; + }, + }); +} diff --git a/src/hooks/useJob.ts b/src/hooks/useJob.ts index e6b4f4f7..ba647f81 100644 --- a/src/hooks/useJob.ts +++ b/src/hooks/useJob.ts @@ -18,6 +18,20 @@ export function useJob(jobId: string) { const job = await jobsRepo.getById(jobId); return job; }, - staleTime: 30000, // Cache valid for 30s, allows instant load from cache while background refetch happens + // Dynamic staleTime: if data is incomplete, mark as stale immediately + // This forces refetch on every useJob call until extraction completes + staleTime: (query) => { + const job = query.state.data; + + // If no data yet, consider immediately stale + if (!job?.data) return 0; + + // Check if candidate details and job description are extracted + const isDataComplete = job.data.candidateDetails && job.data.jobDescription; + + // If incomplete: staleTime = 0 (refetch on every mount) + // If complete: staleTime = 30s (normal caching) + return isDataComplete ? 30000 : 0; + }, }); } From 6dfff8286aae7f85ef6c9f64e29b4269931f3ef9 Mon Sep 17 00:00:00 2001 From: Bhekani Khumalo Date: Sun, 12 Oct 2025 20:44:26 +0100 Subject: [PATCH 3/6] style: apply Biome formatting to migrations and Lambda function Auto-formatting cleanup from Biome linter run during build. No logic changes - just code style consistency. --- db/migrations/meta/0009_snapshot.json | 188 +++++----------------- db/migrations/meta/_journal.json | 2 +- functions/generate-missing-audio/index.ts | 5 +- 3 files changed, 46 insertions(+), 149 deletions(-) diff --git a/db/migrations/meta/0009_snapshot.json b/db/migrations/meta/0009_snapshot.json index 3b7fb9a1..4fc21cf2 100644 --- a/db/migrations/meta/0009_snapshot.json +++ b/db/migrations/meta/0009_snapshot.json @@ -111,12 +111,8 @@ "name": "candidate_details_job_id_jobs_id_fk", "tableFrom": "candidate_details", "tableTo": "jobs", - "columnsFrom": [ - "job_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["job_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -268,9 +264,7 @@ "countries_isoCode_unique": { "name": "countries_isoCode_unique", "nullsNotDistinct": false, - "columns": [ - "iso_code" - ] + "columns": ["iso_code"] } }, "policies": {}, @@ -360,12 +354,8 @@ "name": "customisations_user_id_users_id_fk", "tableFrom": "customisations", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -524,12 +514,8 @@ "name": "feature_request_likes_user_id_users_id_fk", "tableFrom": "feature_request_likes", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -537,12 +523,8 @@ "name": "feature_request_likes_feature_request_id_feature_requests_id_fk", "tableFrom": "feature_request_likes", "tableTo": "feature_requests", - "columnsFrom": [ - "feature_request_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["feature_request_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -626,12 +608,8 @@ "name": "feature_requests_user_id_users_id_fk", "tableFrom": "feature_requests", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -763,9 +741,7 @@ "file_extraction_cache_file_hash_unique": { "name": "file_extraction_cache_file_hash_unique", "nullsNotDistinct": false, - "columns": [ - "file_hash" - ] + "columns": ["file_hash"] } }, "policies": {}, @@ -862,12 +838,8 @@ "name": "images_prompt_id_users_id_fk", "tableFrom": "images", "tableTo": "users", - "columnsFrom": [ - "prompt_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["prompt_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -877,16 +849,12 @@ "images_cloudinaryPublicId_unique": { "name": "images_cloudinaryPublicId_unique", "nullsNotDistinct": false, - "columns": [ - "cloudinary_public_id" - ] + "columns": ["cloudinary_public_id"] }, "images_url_unique": { "name": "images_url_unique", "nullsNotDistinct": false, - "columns": [ - "url" - ] + "columns": ["url"] } }, "policies": {}, @@ -1024,12 +992,8 @@ "name": "interviews_job_id_jobs_id_fk", "tableFrom": "interviews", "tableTo": "jobs", - "columnsFrom": [ - "job_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["job_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -1150,12 +1114,8 @@ "name": "invitations_organization_id_organizations_id_fk", "tableFrom": "invitations", "tableTo": "organizations", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1309,12 +1269,8 @@ "name": "job_descriptions_job_id_jobs_id_fk", "tableFrom": "job_descriptions", "tableTo": "jobs", - "columnsFrom": [ - "job_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["job_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -1421,12 +1377,8 @@ "name": "jobs_user_id_users_id_fk", "tableFrom": "jobs", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -1551,12 +1503,8 @@ "name": "organization_members_organization_id_organizations_id_fk", "tableFrom": "organization_members", "tableTo": "organizations", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -1564,12 +1512,8 @@ "name": "organization_members_user_id_users_id_fk", "tableFrom": "organization_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1766,12 +1710,8 @@ "name": "page_settings_report_id_reports_id_fk", "tableFrom": "page_settings", "tableTo": "reports", - "columnsFrom": [ - "report_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["report_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -1861,12 +1801,8 @@ "name": "question_analysis_report_id_reports_id_fk", "tableFrom": "question_analysis", "tableTo": "reports", - "columnsFrom": [ - "report_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["report_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -2081,12 +2017,8 @@ "name": "reports_interview_id_interviews_id_fk", "tableFrom": "reports", "tableTo": "interviews", - "columnsFrom": [ - "interview_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["interview_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" } @@ -2236,12 +2168,8 @@ "name": "reviews_user_id_users_id_fk", "tableFrom": "reviews", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -2483,16 +2411,12 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] }, "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -2504,13 +2428,7 @@ "public.feature_request_status": { "name": "feature_request_status", "schema": "public", - "values": [ - "submitted", - "triaged", - "in_progress", - "completed", - "declined" - ] + "values": ["submitted", "triaged", "in_progress", "completed", "declined"] }, "public.interview_type": { "name": "interview_type", @@ -2528,40 +2446,22 @@ "public.invitation_status": { "name": "invitation_status", "schema": "public", - "values": [ - "pending", - "accepted", - "rejected", - "expired", - "revoked" - ] + "values": ["pending", "accepted", "rejected", "expired", "revoked"] }, "public.margin_size": { "name": "margin_size", "schema": "public", - "values": [ - "Normal", - "Narrow", - "Wide" - ] + "values": ["Normal", "Narrow", "Wide"] }, "public.paper_size": { "name": "paper_size", "schema": "public", - "values": [ - "A4", - "Letter", - "Legal" - ] + "values": ["A4", "Letter", "Legal"] }, "public.role": { "name": "role", "schema": "public", - "values": [ - "user", - "admin", - "recruiter" - ] + "values": ["user", "admin", "recruiter"] } }, "schemas": {}, @@ -2574,4 +2474,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index c3da8277..4e01264d 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -73,4 +73,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/functions/generate-missing-audio/index.ts b/functions/generate-missing-audio/index.ts index c48d8c5c..98702deb 100644 --- a/functions/generate-missing-audio/index.ts +++ b/functions/generate-missing-audio/index.ts @@ -51,10 +51,7 @@ export const handler = Sentry.wrapHandler(async () => { for (const report of reportsToProcess) { // Skip if interview doesn't have Hume chat ID (shouldn't happen for completed interviews) if (!report.interview.humeChatId) { - logger.warn( - { reportId: report.id }, - "Skipping audio reconstruction - missing humeChatId" - ); + logger.warn({ reportId: report.id }, "Skipping audio reconstruction - missing humeChatId"); continue; } From dba21705dd6dfa0ef2717f04c3035e8bcb167578 Mon Sep 17 00:00:00 2001 From: Bhekani Khumalo Date: Sun, 12 Oct 2025 20:50:56 +0100 Subject: [PATCH 4/6] fix: add polling to detect extraction completion staleTime alone doesn't trigger refetches while component is mounted. Need refetchInterval to actively poll for extraction completion. Why both are needed: - staleTime: Prevents long-term caching of incomplete data - refetchInterval: Detects when server extraction finishes Without polling, button stays disabled until manual reload/refocus. --- src/hooks/useInterview.ts | 24 ++++++++++++++++-------- src/hooks/useJob.ts | 23 +++++++++++++++-------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/hooks/useInterview.ts b/src/hooks/useInterview.ts index a1b21e81..b79558c4 100644 --- a/src/hooks/useInterview.ts +++ b/src/hooks/useInterview.ts @@ -19,21 +19,29 @@ export function useInterview(interviewId: string) { return await interviewRepo.getById(interviewId); }, enabled: !!interviewId, - // Dynamic staleTime: if job data is incomplete, mark as stale immediately - // This forces refetch on every useInterview call until extraction completes + // Dynamic staleTime: prevents long-term caching of incomplete data staleTime: (query) => { const interview = query.state.data; - - // If no interview data yet, consider immediately stale if (!interview?.data?.job) return 0; - // Check if candidate details and job description are extracted const isDataComplete = interview.data.job.candidateDetails && interview.data.job.jobDescription; - - // If incomplete: staleTime = 0 (refetch on every mount) - // If complete: staleTime = 30s (normal caching) return isDataComplete ? 30000 : 0; }, + // Polling: actively checks for extraction completion while component is mounted + // Combined with staleTime, this ensures we detect when server-side extraction finishes + refetchInterval: (query) => { + const interview = query.state.data; + + // No data yet, keep checking + if (!interview?.data?.job) return 3000; + + // Check if extraction is complete + const isDataComplete = + interview.data.job.candidateDetails && interview.data.job.jobDescription; + + // If complete, stop polling. If incomplete, check every 3s + return isDataComplete ? false : 3000; + }, }); } diff --git a/src/hooks/useJob.ts b/src/hooks/useJob.ts index ba647f81..982f0dec 100644 --- a/src/hooks/useJob.ts +++ b/src/hooks/useJob.ts @@ -18,20 +18,27 @@ export function useJob(jobId: string) { const job = await jobsRepo.getById(jobId); return job; }, - // Dynamic staleTime: if data is incomplete, mark as stale immediately - // This forces refetch on every useJob call until extraction completes + // Dynamic staleTime: prevents long-term caching of incomplete data staleTime: (query) => { const job = query.state.data; - - // If no data yet, consider immediately stale if (!job?.data) return 0; - // Check if candidate details and job description are extracted const isDataComplete = job.data.candidateDetails && job.data.jobDescription; - - // If incomplete: staleTime = 0 (refetch on every mount) - // If complete: staleTime = 30s (normal caching) return isDataComplete ? 30000 : 0; }, + // Polling: actively checks for extraction completion while component is mounted + // Combined with staleTime, this ensures we detect when server-side extraction finishes + refetchInterval: (query) => { + const job = query.state.data; + + // No data yet, keep checking + if (!job?.data) return 3000; + + // Check if extraction is complete + const isDataComplete = job.data.candidateDetails && job.data.jobDescription; + + // If complete, stop polling. If incomplete, check every 3s + return isDataComplete ? false : 3000; + }, }); } From 45aac3ccad13bb4d97ca12f9e3708895db557a97 Mon Sep 17 00:00:00 2001 From: Bhekani Khumalo Date: Sun, 12 Oct 2025 20:54:31 +0100 Subject: [PATCH 5/6] docs: clarify staleTime vs refetchInterval behavior in useJob Update comments to accurately explain: - staleTime only triggers refetch on mount/remount/refocus - refetchInterval needed for active polling while mounted - Without polling, button stays disabled until manual reload Fixes misleading comment about staleTime forcing refetch on every call. --- src/hooks/useJob.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/useJob.ts b/src/hooks/useJob.ts index 982f0dec..d283638d 100644 --- a/src/hooks/useJob.ts +++ b/src/hooks/useJob.ts @@ -18,7 +18,9 @@ export function useJob(jobId: string) { const job = await jobsRepo.getById(jobId); return job; }, - // Dynamic staleTime: prevents long-term caching of incomplete data + // Dynamic staleTime: marks incomplete data as immediately stale + // Stale data refetches on mount/remount, but NOT while component stays mounted + // That's why refetchInterval is also needed (see below) staleTime: (query) => { const job = query.state.data; if (!job?.data) return 0; @@ -26,8 +28,9 @@ export function useJob(jobId: string) { const isDataComplete = job.data.candidateDetails && job.data.jobDescription; return isDataComplete ? 30000 : 0; }, - // Polling: actively checks for extraction completion while component is mounted - // Combined with staleTime, this ensures we detect when server-side extraction finishes + // Polling: actively checks for extraction completion WHILE component is mounted + // Without this, button stays disabled until user manually reloads or refocuses window + // staleTime alone only triggers refetch on mount/remount events refetchInterval: (query) => { const job = query.state.data; From fecf6ee965bbe0f5b7ec3b9025e69b6ad55ec992 Mon Sep 17 00:00:00 2001 From: Bhekani Khumalo Date: Sun, 12 Oct 2025 20:54:58 +0100 Subject: [PATCH 6/6] docs: apply same comment clarifications to useInterview Match useJob comment improvements for consistency: - Clarify staleTime only refetches on mount/remount/refocus - Explain refetchInterval needed for mounted component polling - Consistent documentation across both hooks --- src/hooks/useInterview.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/hooks/useInterview.ts b/src/hooks/useInterview.ts index b79558c4..069b0201 100644 --- a/src/hooks/useInterview.ts +++ b/src/hooks/useInterview.ts @@ -19,7 +19,9 @@ export function useInterview(interviewId: string) { return await interviewRepo.getById(interviewId); }, enabled: !!interviewId, - // Dynamic staleTime: prevents long-term caching of incomplete data + // Dynamic staleTime: marks incomplete data as immediately stale + // Stale data refetches on mount/remount, but NOT while component stays mounted + // That's why refetchInterval is also needed (see below) staleTime: (query) => { const interview = query.state.data; if (!interview?.data?.job) return 0; @@ -28,8 +30,9 @@ export function useInterview(interviewId: string) { interview.data.job.candidateDetails && interview.data.job.jobDescription; return isDataComplete ? 30000 : 0; }, - // Polling: actively checks for extraction completion while component is mounted - // Combined with staleTime, this ensures we detect when server-side extraction finishes + // Polling: actively checks for extraction completion WHILE component is mounted + // Without this, button stays disabled until user manually reloads or refocuses window + // staleTime alone only triggers refetch on mount/remount events refetchInterval: (query) => { const interview = query.state.data;