Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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' });

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

const { displayToast } = useToastNotification();
const [, setPendingOpportunityId] = usePersistentContext<string | null>(
PersistentContextKeys.PendingOpportunityId,
null,
);

const { mutateAsync: parseOpportunity, isPending } = useMutation({
...parseOpportunityMutationOptions(),
onError: (err: ApiErrorResult) => {
displayToast(getParseOpportunityMutationErrorMessage(err));
},
});

const validateJobLink = useCallback((value: string) => {
if (!value.trim()) {
setError('');
Expand Down Expand Up @@ -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 (
<Modal
Expand Down Expand Up @@ -149,7 +189,8 @@ export const RecruiterJobLinkModal = ({
variant={ButtonVariant.Primary}
color={ButtonColor.Cabbage}
onClick={handleSubmit}
disabled={(!jobLink.trim() && !file) || !!error}
disabled={(!jobLink.trim() && !file) || !!error || isPending}
loading={isPending}
className="w-full gap-2 tablet:w-auto"
>
<MagicIcon />
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/components/recruiter/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ const STATE_KEYS: Record<OpportunityState, keyof StateGroup | null> = {
[OpportunityState.IN_REVIEW]: 'active',
[OpportunityState.LIVE]: 'active',
[OpportunityState.CLOSED]: 'paused',
[OpportunityState.PARSING]: 'draft',
[OpportunityState.ERROR]: 'draft',
[OpportunityState.UNSPECIFIED]: null,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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 };
38 changes: 37 additions & 1 deletion packages/shared/src/features/opportunity/mutations.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ApiZodErrorExtension>;
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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/graphql/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export enum ApiError {
ZodValidationError = 'ZOD_VALIDATION_ERROR',
Conflict = 'CONFLICT',
PaymentRequired = 'PAYMENT_REQUIRED',
Unexpected = 'UNEXPECTED',
}

export enum ApiErrorMessage {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/hooks/usePersistentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@ export default function usePersistentContext<T>(
export enum PersistentContextKeys {
AlertPushKey = 'alert_push_key',
StreakAlertPushKey = 'streak_alert_push_key',
PendingOpportunityId = 'pending_opportunity_id',
}
60 changes: 30 additions & 30 deletions packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ApiZodErrorExtension>;

// 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`);
},
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -151,7 +151,7 @@ const RecruiterPageContent = () => {
text: 'Select plan',
icon: <MoveToIcon />,
onClick: handlePrepareCampaignClick,
disabled: !opportunity,
disabled: !opportunity || isParsing,
}}
/>
<RecruiterProgress activeStep={RecruiterProgressStep.AnalyzeAndMatch} />
Expand Down
Loading