diff --git a/AGENTS.md b/AGENTS.md index e68e55eaee..e2de233017 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..5cf9e421ec 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,36 @@ 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; } } 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/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, }; 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..39bb7723ba 100644 --- a/packages/shared/src/features/opportunity/mutations.ts +++ b/packages/shared/src/features/opportunity/mutations.ts @@ -1,6 +1,10 @@ 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 { ACCEPT_OPPORTUNITY_MATCH, ADD_OPPORTUNITY_SEATS_MUTATION, @@ -446,6 +450,38 @@ 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, +): string => { + if (!error) { + return PARSE_OPPORTUNITY_ERROR_MESSAGE; + } + + 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 || PARSE_OPPORTUNITY_ERROR_MESSAGE + ); + } + + 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 = () => { 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/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 { 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..88ee56cbdb 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,27 @@ 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(getParseOpportunityMutationErrorMessage()); + + 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 +151,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 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 = {