Skip to content

Commit aff5ec7

Browse files
authored
feat: async opportunity parsing (#5299)
1 parent 070b67f commit aff5ec7

11 files changed

Lines changed: 203 additions & 49 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ Before implementing new functionality, always check if similar code already exis
268268
- If you write similar logic in multiple places, extract it to a helper
269269
- If the logic is used only in one package → package-specific file
270270
- If the logic could be used across packages → `packages/shared/src/lib/`
271+
- Don't extract single-use code into separate functions - keep logic inline where it's used
272+
- Only extract functions when the same logic is needed in multiple places
271273

272274
4. **Real-world example** (from PostSEOSchema refactor):
273275
-**Wrong**: Duplicate author schema logic in 3 places

packages/shared/src/components/modals/recruiter/RecruiterJobLinkModal.tsx

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ReactElement } from 'react';
22
import React, { useState, useCallback } from 'react';
33
import z from 'zod';
4+
import { useMutation } from '@tanstack/react-query';
45
import type { ModalProps } from '../common/Modal';
56
import { Modal } from '../common/Modal';
67
import {
@@ -14,6 +15,15 @@ import { MagicIcon, ShieldIcon } from '../../icons';
1415
import { DragDrop } from '../../fields/DragDrop';
1516
import type { PendingSubmission } from '../../../features/opportunity/context/PendingSubmissionContext';
1617
import { ModalClose } from '../common/ModalClose';
18+
import usePersistentContext, {
19+
PersistentContextKeys,
20+
} from '../../../hooks/usePersistentContext';
21+
import {
22+
parseOpportunityMutationOptions,
23+
getParseOpportunityMutationErrorMessage,
24+
} from '../../../features/opportunity/mutations';
25+
import { useToastNotification } from '../../../hooks/useToastNotification';
26+
import type { ApiErrorResult } from '../../../graphql/common';
1727

1828
const jobLinkSchema = z.url({ message: 'Please enter a valid URL' });
1929

@@ -41,6 +51,19 @@ export const RecruiterJobLinkModal = ({
4151
const [error, setError] = useState<string>('');
4252
const [file, setFile] = useState<File | null>(null);
4353

54+
const { displayToast } = useToastNotification();
55+
const [, setPendingOpportunityId] = usePersistentContext<string | null>(
56+
PersistentContextKeys.PendingOpportunityId,
57+
null,
58+
);
59+
60+
const { mutateAsync: parseOpportunity, isPending } = useMutation({
61+
...parseOpportunityMutationOptions(),
62+
onError: (err: ApiErrorResult) => {
63+
displayToast(getParseOpportunityMutationErrorMessage(err));
64+
},
65+
});
66+
4467
const validateJobLink = useCallback((value: string) => {
4568
if (!value.trim()) {
4669
setError('');
@@ -75,19 +98,36 @@ export const RecruiterJobLinkModal = ({
7598
setError('');
7699
}, []);
77100

78-
const handleSubmit = useCallback(() => {
101+
const handleSubmit = useCallback(async () => {
102+
const payload: { url?: string; file?: File } = {};
103+
79104
if (jobLink) {
80105
const trimmedLink = jobLink.trim();
81106
if (trimmedLink && validateJobLink(trimmedLink)) {
82-
onSubmit({ type: 'url', url: trimmedLink });
83-
return;
107+
payload.url = trimmedLink;
84108
}
85109
}
86110

87111
if (file) {
88-
onSubmit({ type: 'file', file });
112+
payload.file = file;
113+
}
114+
115+
const opportunity = await parseOpportunity(payload);
116+
await setPendingOpportunityId(opportunity.id);
117+
118+
if (payload.url) {
119+
onSubmit({ type: 'url', url: payload.url });
120+
} else if (payload.file) {
121+
onSubmit({ type: 'file', file: payload.file });
89122
}
90-
}, [jobLink, file, validateJobLink, onSubmit]);
123+
}, [
124+
jobLink,
125+
file,
126+
validateJobLink,
127+
parseOpportunity,
128+
setPendingOpportunityId,
129+
onSubmit,
130+
]);
91131

92132
return (
93133
<Modal
@@ -149,7 +189,8 @@ export const RecruiterJobLinkModal = ({
149189
variant={ButtonVariant.Primary}
150190
color={ButtonColor.Cabbage}
151191
onClick={handleSubmit}
152-
disabled={(!jobLink.trim() && !file) || !!error}
192+
disabled={(!jobLink.trim() && !file) || !!error || isPending}
193+
loading={isPending}
153194
className="w-full gap-2 tablet:w-auto"
154195
>
155196
<MagicIcon />

packages/shared/src/components/recruiter/layout/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ const STATE_KEYS: Record<OpportunityState, keyof StateGroup | null> = {
200200
[OpportunityState.IN_REVIEW]: 'active',
201201
[OpportunityState.LIVE]: 'active',
202202
[OpportunityState.CLOSED]: 'paused',
203+
[OpportunityState.PARSING]: 'draft',
204+
[OpportunityState.ERROR]: 'draft',
203205
[OpportunityState.UNSPECIFIED]: null,
204206
};
205207

packages/shared/src/features/opportunity/context/OpportunityPreviewContext.tsx

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,22 @@ import {
1111
opportunityPreviewRefetchIntervalMs,
1212
OpportunityPreviewStatus,
1313
} from '../types';
14+
import { OpportunityState } from '../protobuf/opportunity';
1415
import { useAuthContext } from '../../../contexts/AuthContext';
1516
import { oneMinute } from '../../../lib/dateFormat';
1617
import { useUpdateQuery } from '../../../hooks/useUpdateQuery';
1718

1819
export type OpportunityPreviewContextType = OpportunityPreviewResponse & {
1920
opportunity?: Opportunity;
21+
isParsing?: boolean;
2022
};
2123

2224
type UseOpportunityPreviewProps = PropsWithChildren & {
2325
mockData?: OpportunityPreviewContextType;
2426
};
2527

28+
const parseOpportunityIntervalMs = 3000;
29+
2630
const [OpportunityPreviewProvider, useOpportunityPreviewContext] =
2731
createContextProvider(({ mockData }: UseOpportunityPreviewProps = {}) => {
2832
const { user } = useAuthContext();
@@ -31,6 +35,47 @@ const [OpportunityPreviewProvider, useOpportunityPreviewContext] =
3135
| string
3236
| undefined;
3337

38+
const isValidOpportunityId =
39+
!!opportunityIdParam && opportunityIdParam !== 'new';
40+
41+
const [, updateOpportunity] = useUpdateQuery(
42+
opportunityByIdOptions({ id: opportunityIdParam || '' }),
43+
);
44+
45+
// Fetch opportunity from URL param with polling for PARSING state
46+
const { data: opportunity } = useQuery({
47+
...opportunityByIdOptions({ id: opportunityIdParam || '' }),
48+
enabled: isValidOpportunityId && !mockData,
49+
refetchInterval: (query) => {
50+
const retries = Math.max(
51+
query.state.dataUpdateCount,
52+
query.state.fetchFailureCount,
53+
);
54+
55+
const state = query.state.data?.state;
56+
57+
if (state !== OpportunityState.PARSING) {
58+
return false;
59+
}
60+
61+
const maxRetries = (oneMinute * 1000) / parseOpportunityIntervalMs;
62+
63+
if (retries > maxRetries) {
64+
updateOpportunity({
65+
...query.state.data,
66+
state: OpportunityState.ERROR,
67+
});
68+
69+
return false;
70+
}
71+
72+
return parseOpportunityIntervalMs;
73+
},
74+
});
75+
76+
const isParsing = opportunity?.state === OpportunityState.PARSING;
77+
const isParseError = opportunity?.state === OpportunityState.ERROR;
78+
3479
const [, setOpportunityPreview] = useUpdateQuery(
3580
opportunityPreviewQueryOptions({
3681
opportunityId: opportunityIdParam,
@@ -39,11 +84,17 @@ const [OpportunityPreviewProvider, useOpportunityPreviewContext] =
3984
}),
4085
);
4186

87+
// Only fetch preview once opportunity is no longer in PARSING state
4288
const { data } = useQuery({
4389
...opportunityPreviewQueryOptions({
4490
opportunityId: opportunityIdParam,
4591
user: user || undefined,
46-
enabled: !mockData && opportunityIdParam !== 'new',
92+
enabled:
93+
!mockData &&
94+
isValidOpportunityId &&
95+
!!opportunity &&
96+
!isParsing &&
97+
!isParseError,
4798
}),
4899
refetchInterval: (query) => {
49100
if (
@@ -92,18 +143,11 @@ const [OpportunityPreviewProvider, useOpportunityPreviewContext] =
92143
},
93144
});
94145

95-
const opportunityId = data?.result?.opportunityId;
96-
97-
const { data: opportunity } = useQuery({
98-
...opportunityByIdOptions({ id: opportunityId || '' }),
99-
enabled: !!opportunityId && !mockData,
100-
});
101-
102146
if (mockData) {
103147
return mockData;
104148
}
105149

106-
return { ...data, opportunity };
150+
return { ...data, opportunity, isParsing };
107151
});
108152

109153
export { OpportunityPreviewProvider, useOpportunityPreviewContext };

packages/shared/src/features/opportunity/mutations.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { DefaultError, MutationOptions } from '@tanstack/react-query';
22
import type z from 'zod';
3-
import { gqlClient } from '../../graphql/common';
3+
import type {
4+
ApiErrorResult,
5+
ApiZodErrorExtension,
6+
} from '../../graphql/common';
7+
import { gqlClient, ApiError } from '../../graphql/common';
48
import {
59
ACCEPT_OPPORTUNITY_MATCH,
610
ADD_OPPORTUNITY_SEATS_MUTATION,
@@ -446,6 +450,38 @@ export const recruiterRejectOpportunityMatchMutationOptions =
446450
};
447451
};
448452

453+
export const PARSE_OPPORTUNITY_ERROR_MESSAGE =
454+
'We could not extract the job details from your submission. Please try a different file or URL.';
455+
456+
export const getParseOpportunityMutationErrorMessage = (
457+
error?: ApiErrorResult,
458+
): string => {
459+
if (!error) {
460+
return PARSE_OPPORTUNITY_ERROR_MESSAGE;
461+
}
462+
463+
const isZodError =
464+
error?.response?.errors?.[0]?.extensions?.code ===
465+
ApiError.ZodValidationError;
466+
467+
if (isZodError) {
468+
const zodError = error as ApiErrorResult<ApiZodErrorExtension>;
469+
return (
470+
zodError.response.errors[0].extensions.issues?.find(
471+
(issue) => issue.code === 'custom',
472+
)?.message || PARSE_OPPORTUNITY_ERROR_MESSAGE
473+
);
474+
}
475+
476+
if (error?.response?.errors?.[0]?.extensions?.code === ApiError.Unexpected) {
477+
return PARSE_OPPORTUNITY_ERROR_MESSAGE;
478+
}
479+
480+
return (
481+
error?.response?.errors?.[0]?.message || PARSE_OPPORTUNITY_ERROR_MESSAGE
482+
);
483+
};
484+
449485
export const parseOpportunityMutationOptions = () => {
450486
return {
451487
mutationFn: async ({ file, url }: { file?: File; url?: string }) => {

packages/shared/src/features/opportunity/protobuf/opportunity.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export enum OpportunityState {
2424
* @generated from enum value: OPPORTUNITY_STATE_IN_REVIEW = 4;
2525
*/
2626
IN_REVIEW = 4,
27+
/**
28+
* @generated from enum value: OPPORTUNITY_STATE_PARSING = 5;
29+
*/
30+
PARSING = 5,
31+
/**
32+
* @generated from enum value: OPPORTUNITY_STATE_ERROR = 6;
33+
*/
34+
ERROR = 6,
2735
}
2836

2937
/**

packages/shared/src/graphql/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export enum ApiError {
115115
ZodValidationError = 'ZOD_VALIDATION_ERROR',
116116
Conflict = 'CONFLICT',
117117
PaymentRequired = 'PAYMENT_REQUIRED',
118+
Unexpected = 'UNEXPECTED',
118119
}
119120

120121
export enum ApiErrorMessage {

packages/shared/src/hooks/usePersistentContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,5 @@ export default function usePersistentContext<T>(
5959
export enum PersistentContextKeys {
6060
AlertPushKey = 'alert_push_key',
6161
StreakAlertPushKey = 'streak_alert_push_key',
62+
PendingOpportunityId = 'pending_opportunity_id',
6263
}

packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ import {
2222
import { usePendingSubmission } from '@dailydotdev/shared/src/features/opportunity/context/PendingSubmissionContext';
2323
import { AnalyzeContent } from '@dailydotdev/shared/src/features/opportunity/components/analyze/AnalyzeContent';
2424
import { AnalyzeStatusBar } from '@dailydotdev/shared/src/components/recruiter/AnalyzeStatusBar';
25-
import { parseOpportunityMutationOptions } from '@dailydotdev/shared/src/features/opportunity/mutations';
26-
import type {
27-
ApiErrorResult,
28-
ApiZodErrorExtension,
29-
} from '@dailydotdev/shared/src/graphql/common';
30-
import { ApiError } from '@dailydotdev/shared/src/graphql/common';
31-
import { labels } from '@dailydotdev/shared/src/lib';
25+
import {
26+
parseOpportunityMutationOptions,
27+
getParseOpportunityMutationErrorMessage,
28+
} from '@dailydotdev/shared/src/features/opportunity/mutations';
29+
import type { ApiErrorResult } from '@dailydotdev/shared/src/graphql/common';
3230
import { OpportunityPreviewStatus } from '@dailydotdev/shared/src/features/opportunity/types';
31+
import { webappUrl } from '@dailydotdev/shared/src/lib/constants';
32+
import { OpportunityState } from '@dailydotdev/shared/src/features/opportunity/protobuf/opportunity';
3333
import {
3434
getLayout,
3535
layoutProps,
@@ -61,26 +61,7 @@ const useNewOpportunityParser = (): UseNewOpportunityParserResult => {
6161
onError: (error: ApiErrorResult) => {
6262
setParsingComplete(true);
6363
clearPendingSubmission();
64-
65-
const isZodError =
66-
error?.response?.errors?.[0]?.extensions?.code ===
67-
ApiError.ZodValidationError;
68-
69-
let message =
70-
error?.response?.errors?.[0]?.message || labels.error.generic;
71-
72-
if (isZodError) {
73-
const zodError = error as ApiErrorResult<ApiZodErrorExtension>;
74-
75-
// find and show custom error message or fallback to generic message
76-
message =
77-
zodError.response.errors[0].extensions.issues?.find((issue) => {
78-
return issue.code === 'custom';
79-
})?.message ||
80-
'We could not extract the job details from your submission. Please try a different file or URL.';
81-
}
82-
83-
displayToast(message);
64+
displayToast(getParseOpportunityMutationErrorMessage(error));
8465
router.push(`/recruiter?openModal=joblink`);
8566
},
8667
});
@@ -115,8 +96,27 @@ const useNewOpportunityParser = (): UseNewOpportunityParserResult => {
11596

11697
const RecruiterPageContent = () => {
11798
const router = useRouter();
118-
const { opportunity, result } = useOpportunityPreviewContext();
119-
const { isParsing } = useNewOpportunityParser();
99+
const { displayToast } = useToastNotification();
100+
const {
101+
opportunity,
102+
result,
103+
isParsing: isBackgroundParsing,
104+
} = useOpportunityPreviewContext();
105+
const { isParsing: isMutationParsing } = useNewOpportunityParser();
106+
107+
const isParseError = opportunity?.state === OpportunityState.ERROR;
108+
109+
// Show toast and redirect when background parsing fails
110+
useEffect(() => {
111+
if (isParseError) {
112+
displayToast(getParseOpportunityMutationErrorMessage());
113+
114+
router.push(`${webappUrl}recruiter?openModal=joblink`);
115+
}
116+
}, [isParseError, displayToast, router]);
117+
118+
// Consider parsing in progress if mutation is pending OR background parsing is happening
119+
const isParsing = isMutationParsing || isBackgroundParsing;
120120

121121
const loadingStep = useMemo(() => {
122122
if (isParsing) {
@@ -151,7 +151,7 @@ const RecruiterPageContent = () => {
151151
text: 'Select plan',
152152
icon: <MoveToIcon />,
153153
onClick: handlePrepareCampaignClick,
154-
disabled: !opportunity,
154+
disabled: !opportunity || isParsing,
155155
}}
156156
/>
157157
<RecruiterProgress activeStep={RecruiterProgressStep.AnalyzeAndMatch} />

0 commit comments

Comments
 (0)