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; } 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. 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..069b0201 --- /dev/null +++ b/src/hooks/useInterview.ts @@ -0,0 +1,50 @@ +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: 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; + + const isDataComplete = + interview.data.job.candidateDetails && interview.data.job.jobDescription; + return isDataComplete ? 30000 : 0; + }, + // 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; + + // 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 e6b4f4f7..d283638d 100644 --- a/src/hooks/useJob.ts +++ b/src/hooks/useJob.ts @@ -18,6 +18,30 @@ 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: 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; + + const isDataComplete = job.data.candidateDetails && job.data.jobDescription; + return isDataComplete ? 30000 : 0; + }, + // 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; + + // 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; + }, }); }