From 48362e5b306354ae9aa8c0802103f08d9d5de8dc Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 14 Jan 2026 17:24:17 +0100 Subject: [PATCH 1/5] feat: async opportunity parsing --- AGENTS.md | 2 + .../recruiter/RecruiterJobLinkModal.tsx | 55 ++++++++++++++-- .../context/OpportunityPreviewContext.tsx | 62 ++++++++++++++++--- .../src/features/opportunity/mutations.ts | 27 +++++++- .../opportunity/protobuf/opportunity.ts | 8 +++ .../shared/src/hooks/usePersistentContext.ts | 1 + .../recruiter/[opportunityId]/analyze.tsx | 62 ++++++++++--------- packages/webapp/pages/recruiter/index.tsx | 21 ++++++- 8 files changed, 189 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff02b4af33..b9c8cdc4be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -268,6 +268,8 @@ Before implementing new functionality, always check if similar code already exis - If you write similar logic in multiple places, extract it to a helper - If the logic is used only in one package → package-specific file - If the logic could be used across packages → `packages/shared/src/lib/` + - Don't extract single-use code into separate functions - keep logic inline where it's used + - Only extract functions when the same logic is needed in multiple places 4. **Real-world example** (from PostSEOSchema refactor): - ❌ **Wrong**: Duplicate author schema logic in 3 places diff --git a/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx b/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx index 92fddaefca..a54c0ce5f3 100644 --- a/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx +++ b/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React, { useState, useCallback } from 'react'; import z from 'zod'; +import { useMutation } from '@tanstack/react-query'; import type { ModalProps } from '../common/Modal'; import { Modal } from '../common/Modal'; import { @@ -14,6 +15,15 @@ import { MagicIcon, ShieldIcon } from '../../icons'; import { DragDrop } from '../../fields/DragDrop'; import type { PendingSubmission } from '../../../features/opportunity/context/PendingSubmissionContext'; import { ModalClose } from '../common/ModalClose'; +import usePersistentContext, { + PersistentContextKeys, +} from '../../../hooks/usePersistentContext'; +import { + parseOpportunityMutationOptions, + getParseOpportunityMutationErrorMessage, +} from '../../../features/opportunity/mutations'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import type { ApiErrorResult } from '../../../graphql/common'; const jobLinkSchema = z.url({ message: 'Please enter a valid URL' }); @@ -41,6 +51,19 @@ export const RecruiterJobLinkModal = ({ const [error, setError] = useState(''); const [file, setFile] = useState(null); + const { displayToast } = useToastNotification(); + const [, setPendingOpportunityId] = usePersistentContext( + PersistentContextKeys.PendingOpportunityId, + null, + ); + + const { mutateAsync: parseOpportunity, isPending } = useMutation({ + ...parseOpportunityMutationOptions(), + onError: (err: ApiErrorResult) => { + displayToast(getParseOpportunityMutationErrorMessage(err)); + }, + }); + const validateJobLink = useCallback((value: string) => { if (!value.trim()) { setError(''); @@ -75,19 +98,38 @@ export const RecruiterJobLinkModal = ({ setError(''); }, []); - const handleSubmit = useCallback(() => { + const handleSubmit = useCallback(async () => { + const payload: { url?: string; file?: File } = {}; + if (jobLink) { const trimmedLink = jobLink.trim(); if (trimmedLink && validateJobLink(trimmedLink)) { - onSubmit({ type: 'url', url: trimmedLink }); - return; + payload.url = trimmedLink; } + + return; } if (file) { - onSubmit({ type: 'file', file }); + payload.file = file; + } + + const opportunity = await parseOpportunity(payload); + await setPendingOpportunityId(opportunity.id); + + if (payload.url) { + onSubmit({ type: 'url', url: payload.url }); + } else if (payload.file) { + onSubmit({ type: 'file', file: payload.file }); } - }, [jobLink, file, validateJobLink, onSubmit]); + }, [ + jobLink, + file, + validateJobLink, + parseOpportunity, + setPendingOpportunityId, + onSubmit, + ]); return ( diff --git a/packages/shared/src/features/opportunity/context/OpportunityPreviewContext.tsx b/packages/shared/src/features/opportunity/context/OpportunityPreviewContext.tsx index ef2798e3af..bcc3e98a7e 100644 --- a/packages/shared/src/features/opportunity/context/OpportunityPreviewContext.tsx +++ b/packages/shared/src/features/opportunity/context/OpportunityPreviewContext.tsx @@ -11,18 +11,22 @@ import { opportunityPreviewRefetchIntervalMs, OpportunityPreviewStatus, } from '../types'; +import { OpportunityState } from '../protobuf/opportunity'; import { useAuthContext } from '../../../contexts/AuthContext'; import { oneMinute } from '../../../lib/dateFormat'; import { useUpdateQuery } from '../../../hooks/useUpdateQuery'; export type OpportunityPreviewContextType = OpportunityPreviewResponse & { opportunity?: Opportunity; + isParsing?: boolean; }; type UseOpportunityPreviewProps = PropsWithChildren & { mockData?: OpportunityPreviewContextType; }; +const parseOpportunityIntervalMs = 3000; + const [OpportunityPreviewProvider, useOpportunityPreviewContext] = createContextProvider(({ mockData }: UseOpportunityPreviewProps = {}) => { const { user } = useAuthContext(); @@ -31,6 +35,47 @@ const [OpportunityPreviewProvider, useOpportunityPreviewContext] = | string | undefined; + const isValidOpportunityId = + !!opportunityIdParam && opportunityIdParam !== 'new'; + + const [, updateOpportunity] = useUpdateQuery( + opportunityByIdOptions({ id: opportunityIdParam || '' }), + ); + + // Fetch opportunity from URL param with polling for PARSING state + const { data: opportunity } = useQuery({ + ...opportunityByIdOptions({ id: opportunityIdParam || '' }), + enabled: isValidOpportunityId && !mockData, + refetchInterval: (query) => { + const retries = Math.max( + query.state.dataUpdateCount, + query.state.fetchFailureCount, + ); + + const state = query.state.data?.state; + + if (state !== OpportunityState.PARSING) { + return false; + } + + const maxRetries = (oneMinute * 1000) / parseOpportunityIntervalMs; + + if (retries > maxRetries) { + updateOpportunity({ + ...query.state.data, + state: OpportunityState.ERROR, + }); + + return false; + } + + return parseOpportunityIntervalMs; + }, + }); + + const isParsing = opportunity?.state === OpportunityState.PARSING; + const isParseError = opportunity?.state === OpportunityState.ERROR; + const [, setOpportunityPreview] = useUpdateQuery( opportunityPreviewQueryOptions({ opportunityId: opportunityIdParam, @@ -39,11 +84,17 @@ const [OpportunityPreviewProvider, useOpportunityPreviewContext] = }), ); + // Only fetch preview once opportunity is no longer in PARSING state const { data } = useQuery({ ...opportunityPreviewQueryOptions({ opportunityId: opportunityIdParam, user: user || undefined, - enabled: !mockData && opportunityIdParam !== 'new', + enabled: + !mockData && + isValidOpportunityId && + !!opportunity && + !isParsing && + !isParseError, }), refetchInterval: (query) => { if ( @@ -92,18 +143,11 @@ const [OpportunityPreviewProvider, useOpportunityPreviewContext] = }, }); - const opportunityId = data?.result?.opportunityId; - - const { data: opportunity } = useQuery({ - ...opportunityByIdOptions({ id: opportunityId || '' }), - enabled: !!opportunityId && !mockData, - }); - if (mockData) { return mockData; } - return { ...data, opportunity }; + return { ...data, opportunity, isParsing }; }); export { OpportunityPreviewProvider, useOpportunityPreviewContext }; diff --git a/packages/shared/src/features/opportunity/mutations.ts b/packages/shared/src/features/opportunity/mutations.ts index 50971d7d62..77260909d2 100644 --- a/packages/shared/src/features/opportunity/mutations.ts +++ b/packages/shared/src/features/opportunity/mutations.ts @@ -1,6 +1,11 @@ import type { DefaultError, MutationOptions } from '@tanstack/react-query'; import type z from 'zod'; -import { gqlClient } from '../../graphql/common'; +import type { + ApiErrorResult, + ApiZodErrorExtension, +} from '../../graphql/common'; +import { gqlClient, ApiError } from '../../graphql/common'; +import { labels } from '../../lib'; import { ACCEPT_OPPORTUNITY_MATCH, ADD_OPPORTUNITY_SEATS_MUTATION, @@ -446,6 +451,26 @@ export const recruiterRejectOpportunityMatchMutationOptions = }; }; +export const getParseOpportunityMutationErrorMessage = ( + error: ApiErrorResult, +): string => { + const isZodError = + error?.response?.errors?.[0]?.extensions?.code === + ApiError.ZodValidationError; + + if (isZodError) { + const zodError = error as ApiErrorResult; + return ( + zodError.response.errors[0].extensions.issues?.find( + (issue) => issue.code === 'custom', + )?.message || + 'We could not extract the job details from your submission. Please try a different file or URL.' + ); + } + + return error?.response?.errors?.[0]?.message || labels.error.generic; +}; + export const parseOpportunityMutationOptions = () => { return { mutationFn: async ({ file, url }: { file?: File; url?: string }) => { diff --git a/packages/shared/src/features/opportunity/protobuf/opportunity.ts b/packages/shared/src/features/opportunity/protobuf/opportunity.ts index d073e2a123..ed76256b0d 100644 --- a/packages/shared/src/features/opportunity/protobuf/opportunity.ts +++ b/packages/shared/src/features/opportunity/protobuf/opportunity.ts @@ -24,6 +24,14 @@ export enum OpportunityState { * @generated from enum value: OPPORTUNITY_STATE_IN_REVIEW = 4; */ IN_REVIEW = 4, + /** + * @generated from enum value: OPPORTUNITY_STATE_PARSING = 5; + */ + PARSING = 5, + /** + * @generated from enum value: OPPORTUNITY_STATE_ERROR = 6; + */ + ERROR = 6, } /** diff --git a/packages/shared/src/hooks/usePersistentContext.ts b/packages/shared/src/hooks/usePersistentContext.ts index 414dfde4ba..3b33ecff04 100644 --- a/packages/shared/src/hooks/usePersistentContext.ts +++ b/packages/shared/src/hooks/usePersistentContext.ts @@ -59,4 +59,5 @@ export default function usePersistentContext( export enum PersistentContextKeys { AlertPushKey = 'alert_push_key', StreakAlertPushKey = 'streak_alert_push_key', + PendingOpportunityId = 'pending_opportunity_id', } diff --git a/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx b/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx index 1d50a29eef..09d89688a1 100644 --- a/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx @@ -22,14 +22,14 @@ import { import { usePendingSubmission } from '@dailydotdev/shared/src/features/opportunity/context/PendingSubmissionContext'; import { AnalyzeContent } from '@dailydotdev/shared/src/features/opportunity/components/analyze/AnalyzeContent'; import { AnalyzeStatusBar } from '@dailydotdev/shared/src/components/recruiter/AnalyzeStatusBar'; -import { parseOpportunityMutationOptions } from '@dailydotdev/shared/src/features/opportunity/mutations'; -import type { - ApiErrorResult, - ApiZodErrorExtension, -} from '@dailydotdev/shared/src/graphql/common'; -import { ApiError } from '@dailydotdev/shared/src/graphql/common'; -import { labels } from '@dailydotdev/shared/src/lib'; +import { + parseOpportunityMutationOptions, + getParseOpportunityMutationErrorMessage, +} from '@dailydotdev/shared/src/features/opportunity/mutations'; +import type { ApiErrorResult } from '@dailydotdev/shared/src/graphql/common'; import { OpportunityPreviewStatus } from '@dailydotdev/shared/src/features/opportunity/types'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { OpportunityState } from '@dailydotdev/shared/src/features/opportunity/protobuf/opportunity'; import { getLayout, layoutProps, @@ -61,26 +61,7 @@ const useNewOpportunityParser = (): UseNewOpportunityParserResult => { onError: (error: ApiErrorResult) => { setParsingComplete(true); clearPendingSubmission(); - - const isZodError = - error?.response?.errors?.[0]?.extensions?.code === - ApiError.ZodValidationError; - - let message = - error?.response?.errors?.[0]?.message || labels.error.generic; - - if (isZodError) { - const zodError = error as ApiErrorResult; - - // find and show custom error message or fallback to generic message - message = - zodError.response.errors[0].extensions.issues?.find((issue) => { - return issue.code === 'custom'; - })?.message || - 'We could not extract the job details from your submission. Please try a different file or URL.'; - } - - displayToast(message); + displayToast(getParseOpportunityMutationErrorMessage(error)); router.push(`/recruiter?openModal=joblink`); }, }); @@ -115,8 +96,29 @@ const useNewOpportunityParser = (): UseNewOpportunityParserResult => { const RecruiterPageContent = () => { const router = useRouter(); - const { opportunity, result } = useOpportunityPreviewContext(); - const { isParsing } = useNewOpportunityParser(); + const { displayToast } = useToastNotification(); + const { + opportunity, + result, + isParsing: isBackgroundParsing, + } = useOpportunityPreviewContext(); + const { isParsing: isMutationParsing } = useNewOpportunityParser(); + + const isParseError = opportunity?.state === OpportunityState.ERROR; + + // Show toast and redirect when background parsing fails + useEffect(() => { + if (isParseError) { + displayToast( + 'Failed to process your job description. Please try again with a different file or URL.', + ); + + router.push(`${webappUrl}recruiter?openModal=joblink`); + } + }, [isParseError, displayToast, router]); + + // Consider parsing in progress if mutation is pending OR background parsing is happening + const isParsing = isMutationParsing || isBackgroundParsing; const loadingStep = useMemo(() => { if (isParsing) { @@ -151,7 +153,7 @@ const RecruiterPageContent = () => { text: 'Select plan', icon: , onClick: handlePrepareCampaignClick, - disabled: !opportunity, + disabled: !opportunity || isParsing, }} /> diff --git a/packages/webapp/pages/recruiter/index.tsx b/packages/webapp/pages/recruiter/index.tsx index 0fa46b0ea7..7f495bb2d7 100644 --- a/packages/webapp/pages/recruiter/index.tsx +++ b/packages/webapp/pages/recruiter/index.tsx @@ -14,6 +14,10 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; import { DashboardView } from '@dailydotdev/shared/src/features/recruiter/components/DashboardView'; import { OnboardingView } from '@dailydotdev/shared/src/features/recruiter/components/OnboardingView'; +import usePersistentContext, { + PersistentContextKeys, +} from '@dailydotdev/shared/src/hooks/usePersistentContext'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { getLayout, layoutProps, @@ -25,6 +29,9 @@ function RecruiterPage(): ReactElement { const { setPendingSubmission } = usePendingSubmission(); const { user, loadingUser } = useAuthContext(); const hasInitializedRef = useRef(false); + const [pendingOpportunityId, setPendingOpportunityId] = usePersistentContext< + string | null + >(PersistentContextKeys.PendingOpportunityId, null); // Check if user already has opportunities (jobs) const { data: opportunitiesData, isLoading: isLoadingOpportunities } = @@ -32,10 +39,18 @@ function RecruiterPage(): ReactElement { ...getOpportunitiesOptions(), enabled: !!user, }); - const navigateToAnalyze = useCallback(() => { + const navigateToAnalyze = useCallback(async () => { + if (pendingOpportunityId) { + setPendingOpportunityId(null); + await router.push( + `${webappUrl}recruiter/${pendingOpportunityId}/analyze`, + ); + } else { + await router.push(`${webappUrl}recruiter/new/analyze`); + } + closeModal(); - router.push(`/recruiter/new/analyze`); - }, [closeModal, router]); + }, [closeModal, pendingOpportunityId, setPendingOpportunityId, router]); // Use a ref so the modal always calls the latest version of this function // This avoids stale closures since the modal captures the callback when opened From 7d2f6b367731f6b1cd55f22f1064b50a993d091f Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 14 Jan 2026 17:27:27 +0100 Subject: [PATCH 2/5] fix: lint --- packages/shared/src/components/recruiter/layout/Sidebar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/shared/src/components/recruiter/layout/Sidebar.tsx b/packages/shared/src/components/recruiter/layout/Sidebar.tsx index f71cd0e353..ff872366e6 100644 --- a/packages/shared/src/components/recruiter/layout/Sidebar.tsx +++ b/packages/shared/src/components/recruiter/layout/Sidebar.tsx @@ -200,6 +200,8 @@ const STATE_KEYS: Record = { [OpportunityState.IN_REVIEW]: 'active', [OpportunityState.LIVE]: 'active', [OpportunityState.CLOSED]: 'paused', + [OpportunityState.PARSING]: 'draft', + [OpportunityState.ERROR]: 'draft', [OpportunityState.UNSPECIFIED]: null, }; From 52dca12c4e1893e5288befd558e940f4988d69dc Mon Sep 17 00:00:00 2001 From: capJavert Date: Wed, 14 Jan 2026 17:32:53 +0100 Subject: [PATCH 3/5] fix: lint --- packages/webapp/pages/recruiter/review/[id].tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webapp/pages/recruiter/review/[id].tsx b/packages/webapp/pages/recruiter/review/[id].tsx index e6506d9ba6..11cafd3b92 100644 --- a/packages/webapp/pages/recruiter/review/[id].tsx +++ b/packages/webapp/pages/recruiter/review/[id].tsx @@ -38,6 +38,8 @@ const STATE_LABELS: Record = { [OpportunityState.LIVE]: 'Live', [OpportunityState.CLOSED]: 'Closed', [OpportunityState.UNSPECIFIED]: 'Unknown', + [OpportunityState.PARSING]: 'Parsing', + [OpportunityState.ERROR]: 'Error', }; const STATE_COLORS: Record = { @@ -46,6 +48,8 @@ const STATE_COLORS: Record = { [OpportunityState.LIVE]: 'bg-accent-cabbage-default text-white', [OpportunityState.CLOSED]: 'bg-surface-float text-text-tertiary', [OpportunityState.UNSPECIFIED]: 'bg-surface-float text-text-tertiary', + [OpportunityState.PARSING]: 'bg-surface-float text-text-tertiary', + [OpportunityState.ERROR]: 'bg-status-danger text-white', }; type ContentSectionProps = { From 66cb6f6bf315d454ce5afb95d226de6c18bdf279 Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 15 Jan 2026 11:57:03 +0100 Subject: [PATCH 4/5] fix: feedback --- .../modals/recruiter/RecruiterJobLinkModal.tsx | 2 -- .../shared/src/features/opportunity/mutations.ts | 12 +++++++++--- .../pages/recruiter/[opportunityId]/analyze.tsx | 4 +--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx b/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx index a54c0ce5f3..5cf9e421ec 100644 --- a/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx +++ b/packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx @@ -106,8 +106,6 @@ export const RecruiterJobLinkModal = ({ if (trimmedLink && validateJobLink(trimmedLink)) { payload.url = trimmedLink; } - - return; } if (file) { diff --git a/packages/shared/src/features/opportunity/mutations.ts b/packages/shared/src/features/opportunity/mutations.ts index 77260909d2..f81fb56321 100644 --- a/packages/shared/src/features/opportunity/mutations.ts +++ b/packages/shared/src/features/opportunity/mutations.ts @@ -451,9 +451,16 @@ export const recruiterRejectOpportunityMatchMutationOptions = }; }; +export const PARSE_OPPORTUNITY_ERROR_MESSAGE = + 'We could not extract the job details from your submission. Please try a different file or URL.'; + export const getParseOpportunityMutationErrorMessage = ( - error: ApiErrorResult, + error?: ApiErrorResult, ): string => { + if (!error) { + return PARSE_OPPORTUNITY_ERROR_MESSAGE; + } + const isZodError = error?.response?.errors?.[0]?.extensions?.code === ApiError.ZodValidationError; @@ -463,8 +470,7 @@ export const getParseOpportunityMutationErrorMessage = ( return ( zodError.response.errors[0].extensions.issues?.find( (issue) => issue.code === 'custom', - )?.message || - 'We could not extract the job details from your submission. Please try a different file or URL.' + )?.message || PARSE_OPPORTUNITY_ERROR_MESSAGE ); } diff --git a/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx b/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx index 09d89688a1..88ee56cbdb 100644 --- a/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx @@ -109,9 +109,7 @@ const RecruiterPageContent = () => { // Show toast and redirect when background parsing fails useEffect(() => { if (isParseError) { - displayToast( - 'Failed to process your job description. Please try again with a different file or URL.', - ); + displayToast(getParseOpportunityMutationErrorMessage()); router.push(`${webappUrl}recruiter?openModal=joblink`); } From eb1ce2b1c470c56e892faa11894c67e413de7efb Mon Sep 17 00:00:00 2001 From: capJavert Date: Thu, 15 Jan 2026 13:12:52 +0100 Subject: [PATCH 5/5] feat: make sure to use message for parse opportunity as fallback --- packages/shared/src/features/opportunity/mutations.ts | 9 +++++++-- packages/shared/src/graphql/common.ts | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/opportunity/mutations.ts b/packages/shared/src/features/opportunity/mutations.ts index f81fb56321..39bb7723ba 100644 --- a/packages/shared/src/features/opportunity/mutations.ts +++ b/packages/shared/src/features/opportunity/mutations.ts @@ -5,7 +5,6 @@ import type { ApiZodErrorExtension, } from '../../graphql/common'; import { gqlClient, ApiError } from '../../graphql/common'; -import { labels } from '../../lib'; import { ACCEPT_OPPORTUNITY_MATCH, ADD_OPPORTUNITY_SEATS_MUTATION, @@ -474,7 +473,13 @@ export const getParseOpportunityMutationErrorMessage = ( ); } - return error?.response?.errors?.[0]?.message || labels.error.generic; + if (error?.response?.errors?.[0]?.extensions?.code === ApiError.Unexpected) { + return PARSE_OPPORTUNITY_ERROR_MESSAGE; + } + + return ( + error?.response?.errors?.[0]?.message || PARSE_OPPORTUNITY_ERROR_MESSAGE + ); }; export const parseOpportunityMutationOptions = () => { diff --git a/packages/shared/src/graphql/common.ts b/packages/shared/src/graphql/common.ts index e103609450..2cc0f9db6e 100644 --- a/packages/shared/src/graphql/common.ts +++ b/packages/shared/src/graphql/common.ts @@ -115,6 +115,7 @@ export enum ApiError { ZodValidationError = 'ZOD_VALIDATION_ERROR', Conflict = 'CONFLICT', PaymentRequired = 'PAYMENT_REQUIRED', + Unexpected = 'UNEXPECTED', } export enum ApiErrorMessage {