diff --git a/apps/web/messages/en/candidates.json b/apps/web/messages/en/candidates.json index 1ac2192..33c6e1a 100644 --- a/apps/web/messages/en/candidates.json +++ b/apps/web/messages/en/candidates.json @@ -23,7 +23,11 @@ "tooLarge": "Files can be at most 10 MB.", "noFile": "Choose a file.", "tooShort": "Paste at least 50 characters." - } + }, + "pasteTitle": "Paste a LinkedIn profile", + "pasteIntro": "Paste the profile text or upload the PDF you saved from LinkedIn. AI drafts a structured profile for you to check, then one click saves the candidate, adds them to this position and starts the match analysis.", + "profileUrlNote": "Only stored with the candidate. We never open or collect anything from this link.", + "pasteSubmit": "Read the profile" }, "review": { "title": "Review new candidate", @@ -72,7 +76,8 @@ "needName": "Enter the candidate's name in the profile first.", "noProfile": "There is no profile draft to confirm yet.", "duplicate": "This looks like someone already in your database. Choose what to do.", - "reason": "Say why this is not a duplicate." + "reason": "Say why this is not a duplicate.", + "closed": "This position is closed, so the candidate was not added. Your pasted profile is still a draft." }, "aiFailed": "AI could not write the profile draft. {reason}", "aiErrors": { @@ -87,7 +92,10 @@ "manualHint": "You can enter the profile yourself instead. Nothing is lost: the original file stays attached.", "enterManually": "Enter the profile myself", "manualStarted": "Blank profile ready. Fill it in, then confirm.", - "readFailedManual": "You can also enter the profile yourself." + "readFailedManual": "You can also enter the profile yourself.", + "backToPosition": "Back to this position", + "confirmAddAnalyze": "Confirm, add to position and analyze", + "analysisNotReady": "Candidate added, analysis not started" }, "profile": { "invalid": "This draft cannot be shown as a form.", diff --git a/apps/web/messages/en/pipeline.json b/apps/web/messages/en/pipeline.json index 53b17ea..e079390 100644 --- a/apps/web/messages/en/pipeline.json +++ b/apps/web/messages/en/pipeline.json @@ -125,7 +125,8 @@ "errors": { "generic": "Could not add the candidate.", "closed": "This position is closed." - } + }, + "paste": "Paste a LinkedIn profile and analyze" }, "batch": { "title": "Analysis", diff --git a/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/[entryId]/page.tsx b/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/[entryId]/page.tsx index 1e734df..1daa6df 100644 --- a/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/[entryId]/page.tsx +++ b/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/[entryId]/page.tsx @@ -7,10 +7,13 @@ import { agencyAccess } from '@/lib/auth/page-access'; export default async function AnalysisPage({ params, + searchParams, }: { params: Promise<{ agencySlug: string; positionId: string; entryId: string }>; + searchParams: Promise<{ analyze?: string }>; }) { const { agencySlug, positionId, entryId } = await params; + const { analyze } = await searchParams; const access = await agencyAccess(agencySlug); if (!z.guid().safeParse(positionId).success || !z.guid().safeParse(entryId).success) notFound(); return ( @@ -19,6 +22,7 @@ export default async function AnalysisPage({ agencySlug={access.agency.slug} positionId={positionId} entryId={entryId} + autoStart={analyze === '1'} /> ); } diff --git a/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/paste/page.tsx b/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/paste/page.tsx new file mode 100644 index 0000000..b0657bb --- /dev/null +++ b/apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/paste/page.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { notFound } from 'next/navigation'; +import { z } from 'zod'; +import { PasteProfileFlow } from '@/components/pipeline/paste-profile-flow'; +import { agencyAccess } from '@/lib/auth/page-access'; + +export default async function PasteProfilePage({ + params, +}: { + params: Promise<{ agencySlug: string; positionId: string }>; +}) { + const { agencySlug, positionId } = await params; + const access = await agencyAccess(agencySlug); + if (!z.guid().safeParse(positionId).success) notFound(); + return ( + + ); +} diff --git a/apps/web/src/app/api/permission-matrix.test.ts b/apps/web/src/app/api/permission-matrix.test.ts index 920007c..4f79c21 100644 --- a/apps/web/src/app/api/permission-matrix.test.ts +++ b/apps/web/src/app/api/permission-matrix.test.ts @@ -176,6 +176,7 @@ vi.mock('@/server/positions/service', () => ({ getPosition: async () => ({}), updatePosition: async () => ({}), changeStatus: async () => ({}), + requirePosition: async () => ({ id: 'p1' }), })); vi.mock('@/server/positions/jd', () => ({ listOriginals: async () => [], @@ -247,6 +248,16 @@ vi.mock('@/server/candidates/duplicates', () => ({ vi.mock('@/server/candidates/confirm', () => ({ confirmCandidate: async () => ({ candidateId: 'c1', resolution: 'create', mergedIntoId: null }), })); +vi.mock('@/server/candidates/confirm-and-add', () => ({ + confirmAddAndPlan: async () => ({ + candidateId: 'c1', + resolution: 'create', + mergedIntoId: null, + entry: { id: 'e1' }, + alreadyInPosition: false, + analysis: { ready: true, reason: null }, + }), +})); vi.mock('@/server/candidates/service', () => ({ getCandidate: async () => ({ id: 'c1' }), discardDraft: async () => undefined, @@ -607,6 +618,8 @@ import { POST as classifyPost } from './v1/agencies/[agencyId]/pipeline/[entryId import { GET as entryGet } from './v1/agencies/[agencyId]/pipeline/[entryId]/route'; import { POST as transitionPost } from './v1/agencies/[agencyId]/pipeline/[entryId]/transitions/route'; import { POST as addToPositionPost } from './v1/agencies/[agencyId]/positions/[positionId]/candidates/route'; +import { POST as pasteIntakePost } from './v1/agencies/[agencyId]/positions/[positionId]/candidates/intake/route'; +import { POST as confirmAddPost } from './v1/agencies/[agencyId]/positions/[positionId]/candidates/confirm/route'; import { GET as pipelineGet } from './v1/agencies/[agencyId]/positions/[positionId]/pipeline/route'; import { POST as batchPost } from './v1/agencies/[agencyId]/positions/[positionId]/analyses/route'; import { GET as reviewGet } from './v1/agencies/[agencyId]/positions/[positionId]/review/route'; @@ -1414,6 +1427,28 @@ const endpoints: Endpoint[] = [ agencyParams, ), }, + { + name: 'POST paste LinkedIn profile on a position', + file: 'v1/agencies/[agencyId]/positions/[positionId]/candidates/intake/route.ts', + audience: 'agency', + allowed: AGENCY_ALL, + call: () => + pasteIntakePost( + new Request('http://x.test/', json({ profileText: 'x'.repeat(60) })), + agencyParams, + ), + }, + { + name: 'POST confirm pasted candidate, add to position and plan analysis', + file: 'v1/agencies/[agencyId]/positions/[positionId]/candidates/confirm/route.ts', + audience: 'agency', + allowed: AGENCY_ALL, + call: () => + confirmAddPost( + new Request('http://x.test/', json({ draftId: '11111111-1111-4111-8111-111111111111' })), + agencyParams, + ), + }, { name: 'GET position pipeline', file: 'v1/agencies/[agencyId]/positions/[positionId]/pipeline/route.ts', diff --git a/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/intake/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/intake/route.ts index d883dd7..d1156fd 100644 --- a/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/intake/route.ts +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/intake/route.ts @@ -1,16 +1,11 @@ // Copyright (c) 2026 Team of Wakers // SPDX-License-Identifier: MIT // route-audience: agency -import { candidateIntakeSchema, candidateIntakeTextSchema } from '@easemuse/core'; import { authorizeAgency } from '@/lib/auth/authorize'; import { withAgency } from '@/lib/db'; -import { ApiError } from '@/lib/http/errors'; -import { handleRoute, zodFields } from '@/lib/http/responses'; -import { - createCandidateDraft, - startCandidateExtraction, - type IntakeFile, -} from '@/server/candidates/intake'; +import { handleRoute } from '@/lib/http/responses'; +import { createCandidateDraft, startCandidateExtraction } from '@/server/candidates/intake'; +import { parseIntakeRequest } from '@/server/candidates/intake-request'; export const dynamic = 'force-dynamic'; @@ -28,37 +23,7 @@ export async function POST( const authed = await authorizeAgency(agencyId); if (authed instanceof Response) return authed; - let intake; - let file: IntakeFile; - if (req.headers.get('content-type')?.startsWith('multipart/form-data')) { - const form = await req.formData().catch(() => null); - const upload = form?.get('file'); - if (!(upload instanceof File)) { - throw new ApiError('invalid_input', 'Attach a file', { fields: { file: 'Attach a file' } }); - } - const parsed = candidateIntakeSchema.safeParse({ - source: form?.get('source') ?? undefined, - profileUrl: form?.get('profileUrl')?.toString() || undefined, - }); - if (!parsed.success) - throw new ApiError('invalid_input', 'Invalid input', { fields: zodFields(parsed.error) }); - intake = parsed.data; - file = { - filename: upload.name, - bytes: new Uint8Array(await upload.arrayBuffer()), - kind: 'resume', - }; - } else { - const parsed = candidateIntakeTextSchema.safeParse(await req.json().catch(() => null)); - if (!parsed.success) - throw new ApiError('invalid_input', 'Invalid input', { fields: zodFields(parsed.error) }); - intake = parsed.data; - file = { - filename: 'profile.txt', - bytes: new TextEncoder().encode(parsed.data.profileText), - kind: 'profile_text', - }; - } + const { intake, file } = await parseIntakeRequest(req); const draft = await withAgency( authed.agency.id, diff --git a/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/confirm/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/confirm/route.ts new file mode 100644 index 0000000..3a8787a --- /dev/null +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/confirm/route.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +// route-audience: agency +import { confirmCandidateInputSchema } from '@easemuse/core'; +import { authorizeAgency } from '@/lib/auth/authorize'; +import { withAgency } from '@/lib/db'; +import { created, handleRoute } from '@/lib/http/responses'; +import { parseBody } from '@/lib/http/validation'; +import { confirmAddAndPlan } from '@/server/candidates/confirm-and-add'; + +/** + * Confirms a pasted candidate, adds them to this position and says whether the match analysis can + * start (brief 4.2, 5.1). One transaction: a failure leaves nothing half done. 409 + * `possible_duplicate` until the recruiter picks a resolution, like the plain confirm. The response + * carries the pipeline `entry` and `analysis: { ready, reason }`; when ready, the caller starts the + * streamed analysis for `entry.id` (nothing has been analyzed here). + */ +export async function POST( + req: Request, + ctx: { params: Promise<{ agencyId: string; positionId: string }> }, +): Promise { + return handleRoute(async () => { + const { agencyId, positionId } = await ctx.params; + const authed = await authorizeAgency(agencyId); + if (authed instanceof Response) return authed; + const input = await parseBody(req, confirmCandidateInputSchema); + return created( + await withAgency( + authed.agency.id, + (tx) => confirmAddAndPlan(tx, authed.actor, positionId, input), + { userId: authed.userId }, + ), + ); + }); +} diff --git a/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/intake/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/intake/route.ts new file mode 100644 index 0000000..64c6bc8 --- /dev/null +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/intake/route.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +// route-audience: agency +import { authorizeAgency } from '@/lib/auth/authorize'; +import { withAgency } from '@/lib/db'; +import { handleRoute } from '@/lib/http/responses'; +import { createCandidateDraft, startCandidateExtraction } from '@/server/candidates/intake'; +import { parseIntakeRequest } from '@/server/candidates/intake-request'; +import { requirePosition } from '@/server/positions/service'; + +export const dynamic = 'force-dynamic'; + +/** + * Adds a candidate from a position by pasting a LinkedIn profile or uploading a LinkedIn PDF export: + * multipart (`file`, optional `profileUrl`) or JSON (`profileText`, optional `profileUrl`). The source + * is always LinkedIn. The link is stored as text and never fetched (brief 4.1). Creates a DRAFT + * candidate and reads it in the background: `202 { candidateId, jobRunId, positionId }`. The + * recruiter confirms the profile and adds the candidate to the position in a later step. Any member + * who can see the position. + */ +export async function POST( + req: Request, + ctx: { params: Promise<{ agencyId: string; positionId: string }> }, +): Promise { + return handleRoute(async () => { + const { agencyId, positionId } = await ctx.params; + const authed = await authorizeAgency(agencyId); + if (authed instanceof Response) return authed; + + const { intake, file } = await parseIntakeRequest(req, 'linkedin'); + const draft = await withAgency( + authed.agency.id, + async (tx) => { + await requirePosition(tx, authed.actor, positionId, 'read'); + return createCandidateDraft(tx, authed.actor, intake, file); + }, + { userId: authed.userId }, + ); + const jobRunId = await startCandidateExtraction(authed.actor, draft.documentId); + return Response.json( + { data: { candidateId: draft.candidateId, jobRunId, positionId } }, + { status: 202, headers: { 'Cache-Control': 'no-store' } }, + ); + }); +} diff --git a/apps/web/src/components/candidates/candidate-intake-form.tsx b/apps/web/src/components/candidates/candidate-intake-form.tsx index 849a70c..92ff20f 100644 --- a/apps/web/src/components/candidates/candidate-intake-form.tsx +++ b/apps/web/src/components/candidates/candidate-intake-form.tsx @@ -15,14 +15,17 @@ const MIN_TEXT = 50; /** Add a candidate (US-202): a resume file or pasted profile, where it came from, and an optional link. */ export function CandidateIntakeForm({ agencyId, + positionId, onCreated, }: { agencyId: string; + /** Pasting from a position: the source is always LinkedIn and the candidate is added to it later. */ + positionId?: string; onCreated: (candidateId: string) => void; }) { const t = useTranslations('candidates.new'); const sources = useTranslations('candidates.sources'); - const [mode, setMode] = useState<'file' | 'text'>('file'); + const [mode, setMode] = useState<'file' | 'text'>(positionId ? 'text' : 'file'); const [source, setSource] = useState('linkedin'); const [profileUrl, setProfileUrl] = useState(''); const [file, setFile] = useState(null); @@ -37,7 +40,9 @@ export function CandidateIntakeForm({ }; const submit = useMutation({ mutationFn: async () => { - const path = `/api/v1/agencies/${agencyId}/candidates/intake`; + const path = positionId + ? `/api/v1/agencies/${agencyId}/positions/${positionId}/candidates/intake` + : `/api/v1/agencies/${agencyId}/candidates/intake`; let response: Response; if (mode === 'file') { const form = new FormData(); @@ -86,21 +91,24 @@ export function CandidateIntakeForm({ submit.mutate(); }} > -

{t('title')}

-

{t('intro')}

- setSource(e.target.value as CandidateSourceValue)} - > - {CANDIDATE_SOURCES.map((s) => ( - - ))} - +

{positionId ? t('pasteTitle') : t('title')}

+

{positionId ? t('pasteIntro') : t('intro')}

+ {positionId ? null : ( + setSource(e.target.value as CandidateSourceValue)} + > + {CANDIDATE_SOURCES.map((s) => ( + + ))} + + )} setProfileUrl(e.target.value)} @@ -140,7 +148,7 @@ export function CandidateIntakeForm({ ) : null}
diff --git a/apps/web/src/components/candidates/candidate-review.tsx b/apps/web/src/components/candidates/candidate-review.tsx index 69e72c3..ceac547 100644 --- a/apps/web/src/components/candidates/candidate-review.tsx +++ b/apps/web/src/components/candidates/candidate-review.tsx @@ -37,10 +37,13 @@ export function CandidateReview({ agencyId, agencySlug, candidateId, + positionId, }: { agencyId: string; agencySlug: string; candidateId: string; + /** From a position: confirming also adds the candidate to it and opens the analysis. */ + positionId?: string; }) { const t = useTranslations('candidates.review'); const d = useTranslations('candidates.review.duplicates'); @@ -102,7 +105,11 @@ export function CandidateReview({ }); const confirm = useMutation({ mutationFn: () => - apiFetch<{ candidateId: string }>(`${base}/candidates`, { + apiFetch<{ + candidateId: string; + entry?: { id: string }; + analysis?: { ready: boolean; reason: string | null }; + }>(positionId ? `${base}/positions/${positionId}/candidates/confirm` : `${base}/candidates`, { body: { draftId: candidateId, ...(choice !== 'none' ? { resolution: choice } : {}), @@ -118,6 +125,19 @@ export function CandidateReview({ await queryClient.invalidateQueries({ queryKey: ['candidate', agencyId, res.data.candidateId], }); + if (positionId && res.data.entry) { + const { entry, analysis } = res.data; + if (analysis && !analysis.ready && analysis.reason) { + toast({ title: t('analysisNotReady'), description: analysis.reason }); + } + // The click on "Confirm, add and analyze" is the request: the analysis page starts the run. + router.push( + `/a/${agencySlug}/positions/${positionId}/candidates/${entry.id}${ + analysis?.ready ? '?analyze=1' : '' + }`, + ); + return; + } router.push(`/a/${agencySlug}/candidates/${res.data.candidateId}`); }, onError: (e) => { @@ -125,6 +145,8 @@ export function CandidateReview({ return setError(t('errors.duplicate')); if (e instanceof ApiClientError && e.fields.fullName) return setError(t('errors.needName')); if (e instanceof ApiClientError && e.fields.reason) return setError(t('errors.reason')); + if (e instanceof ApiClientError && e.status === 409 && e.message.includes('closed')) + return setError(t('errors.closed')); if (e instanceof ApiClientError && e.status === 409) return setError(t('errors.noProfile')); setError(t('errors.generic')); }, @@ -172,8 +194,15 @@ export function CandidateReview({

{t('title')}

- - {t('back')} + + {positionId ? t('backToPosition') : t('back')}
@@ -337,7 +366,7 @@ export function CandidateReview({ ) : null}