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) => ( - - {sources(s)} - - ))} - + {positionId ? t('pasteTitle') : t('title')} + {positionId ? t('pasteIntro') : t('intro')} + {positionId ? null : ( + setSource(e.target.value as CandidateSourceValue)} + > + {CANDIDATE_SOURCES.map((s) => ( + + {sources(s)} + + ))} + + )} setProfileUrl(e.target.value)} @@ -140,7 +148,7 @@ export function CandidateIntakeForm({ ) : null} - {t('submit')} + {positionId ? t('pasteSubmit') : t('submit')} 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} confirm.mutate()}> - {t('confirm')} + {positionId ? t('confirmAddAnalyze') : t('confirm')} discard.mutate()}> {t('discard')} diff --git a/apps/web/src/components/candidates/candidates.test.tsx b/apps/web/src/components/candidates/candidates.test.tsx index f255f0a..aa9c6e0 100644 --- a/apps/web/src/components/candidates/candidates.test.tsx +++ b/apps/web/src/components/candidates/candidates.test.tsx @@ -83,6 +83,25 @@ describe('CandidateIntakeForm', () => { expect(screen.getByRole('alert').textContent).toBe('Upload a PDF, DOCX or plain text file.'), ); }); + + it('pastes a LinkedIn profile from a position: no source question, sent to the position route', async () => { + const sent: { url: string; body: unknown }[] = []; + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init: RequestInit = {}) => { + sent.push({ url: String(input), body: JSON.parse(String(init.body)) }); + return json({ data: { candidateId: 'c9', jobRunId: 'j9', positionId: 'p1' } }, 202); + }); + const onCreated = vi.fn(); + const user = userEvent.setup(); + renderApp(); + expect(screen.getByRole('heading', { name: 'Paste a LinkedIn profile' })).toBeTruthy(); + expect(screen.queryByLabelText('Where did you find them?')).toBeNull(); + expect(screen.getByText(/We never open or collect anything from this link/)).toBeTruthy(); + await user.type(screen.getByLabelText('Profile text'), 'x'.repeat(60)); + await user.click(screen.getByRole('button', { name: 'Read the profile' })); + await waitFor(() => expect(onCreated).toHaveBeenCalledWith('c9')); + expect(sent[0]!.url).toBe(`${api}/positions/p1/candidates/intake`); + expect(sent[0]!.body).toEqual({ source: 'linkedin', profileText: 'x'.repeat(60) }); + }); }); const emptyProfile = (): Record => ({ @@ -493,4 +512,87 @@ describe('CandidateReview', () => { ); void within; }); + + describe('from a position', () => { + const viewFromPosition = () => + renderApp( + , + ); + const confirmUrl = `${api}/positions/p1/candidates/confirm`; + + it('confirms, adds to the position and opens the analysis to start it', async () => { + const stub = serve(candidate({ profileArtifactId: 'art1' }), [], { + [`POST ${confirmUrl}`]: () => + json( + { + data: { + candidateId: 'c1', + resolution: 'create', + mergedIntoId: null, + entry: { id: 'e1' }, + alreadyInPosition: false, + analysis: { ready: true, reason: null }, + }, + }, + 201, + ), + }); + const user = userEvent.setup(); + viewFromPosition(); + await user.click( + await screen.findByRole('button', { name: 'Confirm, add to position and analyze' }), + ); + await waitFor(() => + expect(push).toHaveBeenCalledWith('/a/acme/positions/p1/candidates/e1?analyze=1'), + ); + expect( + stub.calls.find((c) => c.method === 'POST' && c.url.pathname === confirmUrl)!.body, + ).toEqual({ draftId: 'c1' }); + expect(screen.queryByRole('button', { name: 'Confirm candidate' })).toBeNull(); + }); + + it('does not start the analysis when it is not ready, and says why', async () => { + serve(candidate({ profileArtifactId: 'art1' }), [], { + [`POST ${confirmUrl}`]: () => + json( + { + data: { + candidateId: 'c1', + resolution: 'create', + mergedIntoId: null, + entry: { id: 'e1' }, + alreadyInPosition: false, + analysis: { + ready: false, + reason: "Confirm the position's success profile first", + }, + }, + }, + 201, + ), + }); + const user = userEvent.setup(); + viewFromPosition(); + await user.click( + await screen.findByRole('button', { name: 'Confirm, add to position and analyze' }), + ); + await waitFor(() => expect(push).toHaveBeenCalledWith('/a/acme/positions/p1/candidates/e1')); + expect(await screen.findByText('Candidate added, analysis not started')).toBeTruthy(); + expect(screen.getByText("Confirm the position's success profile first")).toBeTruthy(); + }); + + it('keeps the draft and explains when the position is closed', async () => { + serve(candidate({ profileArtifactId: 'art1' }), [], { + [`POST ${confirmUrl}`]: () => + json({ error: { code: 'conflict', message: 'This position is closed' } }, 409), + }); + const user = userEvent.setup(); + viewFromPosition(); + await user.click( + await screen.findByRole('button', { name: 'Confirm, add to position and analyze' }), + ); + expect((await screen.findByRole('alert')).textContent).toContain('This position is closed'); + expect(push).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/src/components/pipeline/add-candidate-panel.tsx b/apps/web/src/components/pipeline/add-candidate-panel.tsx index 54fcaea..16b10d3 100644 --- a/apps/web/src/components/pipeline/add-candidate-panel.tsx +++ b/apps/web/src/components/pipeline/add-candidate-panel.tsx @@ -111,9 +111,20 @@ export function AddCandidatePanel({ {error} ) : null} - - {t('new')} - + + + {t('paste')} + + + {t('new')} + + ); } diff --git a/apps/web/src/components/pipeline/analysis-view.test.tsx b/apps/web/src/components/pipeline/analysis-view.test.tsx index c858d04..7f89265 100644 --- a/apps/web/src/components/pipeline/analysis-view.test.tsx +++ b/apps/web/src/components/pipeline/analysis-view.test.tsx @@ -354,4 +354,37 @@ describe('AnalysisView', () => { '/a/acme/positions/p1/candidates', ); }); + + describe('auto start (paste and analyze)', () => { + const post = (stub: ReturnType) => + stub.calls.filter((c) => c.method === 'POST' && c.url.pathname.endsWith('/analysis')); + const stream = () => + new Response('', { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); + + it('starts the first analysis once on arrival', async () => { + const stub = serve(detail(), { [`POST ${api}/pipeline/e1/analysis`]: stream }, true); + renderApp( + , + ); + await waitFor(() => expect(post(stub)).toHaveLength(1)); + await screen.findByRole('heading', { name: 'Alex Kim' }); + expect(post(stub)).toHaveLength(1); + }); + + it('never replaces an existing analysis on its own', async () => { + const stub = serve(detail(), { [`POST ${api}/pipeline/e1/analysis`]: stream }); + renderApp( + , + ); + await screen.findByText('78'); + expect(post(stub)).toHaveLength(0); + }); + + it('waits for the recruiter to click when not asked to auto start', async () => { + const stub = serve(detail(), { [`POST ${api}/pipeline/e1/analysis`]: stream }, true); + view(); + await screen.findByRole('button', { name: 'Analyze' }); + expect(post(stub)).toHaveLength(0); + }); + }); }); diff --git a/apps/web/src/components/pipeline/analysis-view.tsx b/apps/web/src/components/pipeline/analysis-view.tsx index 97acbdc..5a16856 100644 --- a/apps/web/src/components/pipeline/analysis-view.tsx +++ b/apps/web/src/components/pipeline/analysis-view.tsx @@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { matchingWeightsSchema, type MatchWeightKey, type PipelineState } from '@easemuse/core'; import { StreamingDraft } from '@/components/artifacts/streaming-draft'; import { useAiRun } from '@/components/artifacts/use-ai-run'; @@ -37,11 +37,14 @@ export function AnalysisView({ agencySlug, positionId, entryId, + autoStart = false, }: { agencyId: string; agencySlug: string; positionId: string; entryId: string; + /** Start the analysis once on arrival: set only when the recruiter just chose "Confirm, add and analyze". */ + autoStart?: boolean; }) { const t = useTranslations('pipeline.analysis'); const portalT = useTranslations('pipeline.portalAdmin'); @@ -76,6 +79,13 @@ export function AnalysisView({ await queryClient.invalidateQueries({ queryKey: ['review', agencyId, positionId] }); }; const run = useAiRun(`${base}/pipeline/${entryId}/analysis`, { onDone: () => void refresh() }); + const autoStarted = useRef(false); + useEffect(() => { + // Only a first analysis: an existing one is never replaced without a click on Analyze again. + if (!autoStart || autoStarted.current || !latest.isSuccess || latest.data?.data) return; + autoStarted.current = true; + void run.start(); + }, [autoStart, latest.isSuccess, latest.data, run]); const review = useMutation({ mutationFn: () => apiFetch(`${base}/analyses/${analysisId}/review`, { method: 'POST' }), onSuccess: async () => { diff --git a/apps/web/src/components/pipeline/paste-profile-flow.tsx b/apps/web/src/components/pipeline/paste-profile-flow.tsx new file mode 100644 index 0000000..0c6627a --- /dev/null +++ b/apps/web/src/components/pipeline/paste-profile-flow.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +'use client'; + +import { useState } from 'react'; +import { CandidateIntakeForm } from '@/components/candidates/candidate-intake-form'; +import { CandidateReview } from '@/components/candidates/candidate-review'; + +/** + * Paste a LinkedIn profile on a position and get to a match analysis draft on one screen: paste or + * upload, review the profile AI drafted (and any duplicate), then one confirm that saves the + * candidate, adds them to the position and starts the analysis. Nothing is fetched from LinkedIn. + */ +export function PasteProfileFlow({ + agencyId, + agencySlug, + positionId, +}: { + agencyId: string; + agencySlug: string; + positionId: string; +}) { + const [candidateId, setCandidateId] = useState(null); + return candidateId ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/server/candidates/confirm-and-add.db.test.ts b/apps/web/src/server/candidates/confirm-and-add.db.test.ts new file mode 100644 index 0000000..6c7f27a --- /dev/null +++ b/apps/web/src/server/candidates/confirm-and-add.db.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { randomUUID } from 'node:crypto'; +import type pg from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { AgencyActor } from '@easemuse/core'; +import type { DbHandle } from '@/lib/db/client'; +import { addDraftVersion, approveVersion, createArtifact } from '@/server/artifacts/service'; +import { createClient } from '@/server/clients/service'; +import { createPosition } from '@/server/positions/service'; +import { + adminClient, + appHandle, + appScope, + cleanup, + createAgency, + createUser, + dbAvailable, +} from '@/test/db'; +import { confirmAddAndPlan } from './confirm-and-add'; +import { createCandidateDraft } from './intake'; + +/** Random letters, so two fixture people never look alike to the duplicate check (name + employer). */ +const letters = (n: number) => + [...randomUUID().replaceAll('-', '')] + .map((c) => String.fromCharCode(97 + Number.parseInt(c, 16))) + .join('') + .slice(0, n); + +/** A new person each call; pass the same object to a second draft to make a real duplicate. */ +const profile = (over: Record = {}) => { + const company = `${letters(10)} Corp`; + return { + fullName: `${letters(8)} ${letters(8)}`, + email: `${letters(10)}@x.com`, + phone: null, + currentPosition: { title: 'HR Director', company }, + careerHistory: [ + { + company, + title: 'HR Director', + start: '2019', + end: null, + current: true, + summary: 'Managed a team of 20', + }, + ], + education: [], + coreSkills: ['payroll'], + industryExperience: [], + technicalSkills: [], + leadershipExperience: null, + teamSize: '20', + careerProgression: null, + location: 'Seoul', + workAuthorization: null, + compensationExpectation: null, + majorAchievements: [], + potentialMotivation: null, + potentialConcerns: [], + ...over, + }; +}; +const successProfile = { + summary: 'A senior HR leader', + criteria: [{ id: 'c1', text: '8 years in HR', category: 'must_have', basis: 'jd' }], + expectations: { sixMonths: '', twelveMonths: '' }, + keyChallenges: [], +}; + +describe.skipIf(!dbAvailable())('confirm, add to position and plan the analysis', () => { + let admin: pg.Client; + let handle: DbHandle; + let agencyId: string; + let actor: AgencyActor; + let clientId: string; + type Tx = Parameters['withAgency']>[1]>[0]; + const run = (fn: (tx: Tx) => Promise) => appScope(handle).withAgency(agencyId, fn); + + const newPosition = async (opts: { successProfile?: boolean } = {}) => { + const id = ( + await run((tx) => + createPosition(tx, actor, { clientId, title: 'Head of HR', location: 'Seoul' }), + ) + ).id; + if (opts.successProfile) { + const artifact = await run((tx) => + createArtifact(tx, { + agencyId, + kind: 'success_profile', + subject: { type: 'position', id }, + }), + ); + const v = await run((tx) => + addDraftVersion(tx, { + agencyId, + artifactId: artifact.id, + content: successProfile, + origin: 'human', + createdById: actor.userId, + }), + ); + await run((tx) => approveVersion(tx, actor, v.id)); + } + return id; + }; + /** A pasted LinkedIn profile as the AI step leaves it: a draft candidate with a profile draft. */ + const pasted = async (content: Record = profile()) => { + const draft = await run((tx) => + createCandidateDraft( + tx, + actor, + { source: 'linkedin' }, + { + filename: 'profile.txt', + bytes: new TextEncoder().encode(`profile ${randomUUID()}`), + kind: 'profile_text', + }, + ), + ); + const artifact = await run((tx) => + createArtifact(tx, { + agencyId, + kind: 'candidate_profile', + subject: { type: 'candidate', id: draft.candidateId }, + }), + ); + await run((tx) => + addDraftVersion(tx, { + agencyId, + artifactId: artifact.id, + content, + origin: 'ai', + createdById: actor.userId, + }), + ); + return draft.candidateId; + }; + const go = (positionId: string, draftId: string, rest: Record = {}) => + run((tx) => confirmAddAndPlan(tx, actor, positionId, { draftId, ...rest })); + const rows = async (sql: string, args: unknown[]) => (await admin.query(sql, args)).rows; + + beforeAll(async () => { + admin = await adminClient(); + handle = appHandle(2); + agencyId = await createAgency(admin, 'Paste A'); + const u = await createUser(admin); + await admin.query( + `INSERT INTO "Membership" (id, "agencyId", "userId", role, active, "updatedAt") VALUES (gen_random_uuid(), $1, $2, 'recruiter', true, now())`, + [agencyId, u], + ); + actor = { kind: 'agency', userId: u, agencyId, role: 'recruiter' }; + clientId = (await run((tx) => createClient(tx, actor, { name: 'Paste Client' }))).client.id; + }); + afterAll(async () => { + await cleanup(admin, { agencies: [agencyId] }); + await admin.end(); + await handle.close(); + }); + + it('confirms, adds to the position as sourced and offers the analysis when both profiles are approved', async () => { + const positionId = await newPosition({ successProfile: true }); + const draftId = await pasted(); + const result = await go(positionId, draftId); + expect(result.candidateId).toBe(draftId); + expect(result.entry).toMatchObject({ positionId, candidateId: draftId, state: 'sourced' }); + expect(result.alreadyInPosition).toBe(false); + expect(result.analysis).toEqual({ ready: true, reason: null }); + const [c] = await rows(`SELECT status FROM "Candidate" WHERE id = $1`, [draftId]); + expect(c.status).toBe('active'); + // Nothing was analyzed: the recruiter's click starts the streamed analysis afterwards. + expect(await rows(`SELECT 1 FROM "Artifact" WHERE kind = 'match_analysis'`, [])).toEqual([]); + }); + + it('confirms and adds but says why the analysis cannot start without a success profile', async () => { + const positionId = await newPosition(); + const result = await go(positionId, await pasted()); + expect(result.entry.state).toBe('sourced'); + expect(result.analysis.ready).toBe(false); + expect(result.analysis.reason).toMatch(/success profile/i); + }); + + it('asks for a duplicate resolution and changes nothing until it is given', async () => { + const positionId = await newPosition({ successProfile: true }); + const shared = profile(); + await go(positionId, await pasted(shared)); + const second = await pasted(shared); + const other = await newPosition({ successProfile: true }); + await expect(go(other, second)).rejects.toMatchObject({ code: 'possible_duplicate' }); + const [c] = await rows(`SELECT status FROM "Candidate" WHERE id = $1`, [second]); + expect(c.status).toBe('draft'); + expect(await rows(`SELECT 1 FROM "PipelineEntry" WHERE "positionId" = $1`, [other])).toEqual( + [], + ); + }); + + it('does not offer an analysis on stale facts after a merge into an existing candidate', async () => { + const positionId = await newPosition({ successProfile: true }); + const shared = profile(); + const original = await pasted(shared); + await go(positionId, original); + const again = await pasted(shared); + const result = await go(positionId, again, { resolution: 'merge', targetId: original }); + expect(result.mergedIntoId).toBe(original); + expect(result.candidateId).toBe(original); + expect(result.alreadyInPosition).toBe(true); + expect(result.analysis.ready).toBe(false); + }); + + it('leaves the candidate a draft when the position is closed', async () => { + const positionId = await newPosition({ successProfile: true }); + await admin.query(`UPDATE "Position" SET status = 'cancelled' WHERE id = $1`, [positionId]); + const draftId = await pasted(); + await expect(go(positionId, draftId)).rejects.toMatchObject({ status: 409 }); + const [c] = await rows(`SELECT status FROM "Candidate" WHERE id = $1`, [draftId]); + expect(c.status).toBe('draft'); + }); + + it("treats a position that is not this agency's as not found", async () => { + const draftId = await pasted(); + await expect(go(randomUUID(), draftId)).rejects.toMatchObject({ status: 404 }); + const [c] = await rows(`SELECT status FROM "Candidate" WHERE id = $1`, [draftId]); + expect(c.status).toBe('draft'); + }); +}); diff --git a/apps/web/src/server/candidates/confirm-and-add.ts b/apps/web/src/server/candidates/confirm-and-add.ts new file mode 100644 index 0000000..48d2ce3 --- /dev/null +++ b/apps/web/src/server/candidates/confirm-and-add.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import type { AgencyActor, ConfirmCandidateInput } from '@easemuse/core'; +import type { ScopedTx } from '@/lib/db'; +import { ApiError } from '@/lib/http/errors'; +import { planMatchAnalysis } from '@/server/matching/analyze'; +import { + addCandidateToPosition, + getEntryForCandidate, + type PipelineEntryView, +} from '@/server/pipeline/service'; +import { requirePosition } from '@/server/positions/service'; +import { confirmCandidate, type ConfirmResult } from './confirm'; + +/** Why the analysis was not offered, in words the recruiter can act on. */ +export interface AnalysisReadiness { + ready: boolean; + reason: string | null; +} + +export interface ConfirmAndAddResult extends ConfirmResult { + entry: PipelineEntryView; + /** True when the surviving candidate was already in this position (a merge into someone in the pipeline). */ + alreadyInPosition: boolean; + /** Whether the recruiter can now start the match analysis. Nothing has been analyzed yet. */ + analysis: AnalysisReadiness; +} + +/** + * Confirms a pasted profile, adds the person to the position and says whether the match analysis + * can start (brief 4.2, 5.1). The recruiter's click on this action is the request for the analysis + * (FR-AI-01), but the model is not called here: the caller starts the existing streamed analysis for + * the returned entry. Duplicate resolution is the same as for any confirm (409 `possible_duplicate` + * until the recruiter chooses). After a merge the existing candidate's approved profile has not + * changed (the pasted one is a new draft to review), so no analysis is offered on stale facts. + */ +export async function confirmAddAndPlan( + tx: ScopedTx, + actor: AgencyActor, + positionId: string, + input: ConfirmCandidateInput, +): Promise { + await requirePosition(tx, actor, positionId, 'read'); + const confirmed = await confirmCandidate(tx, actor, input); + + const existing = await getEntryForCandidate(tx, actor, positionId, confirmed.candidateId); + const entry = + existing ?? + (await addCandidateToPosition(tx, actor, positionId, { candidateId: confirmed.candidateId })); + + const analysis = await readiness(tx, actor, entry.id, confirmed.mergedIntoId !== null); + return { ...confirmed, entry, alreadyInPosition: existing !== null, analysis }; +} + +async function readiness( + tx: ScopedTx, + actor: AgencyActor, + entryId: string, + merged: boolean, +): Promise { + if (merged) { + return { + ready: false, + reason: + 'The pasted profile was merged into an existing candidate. Review and confirm the updated profile, then run the analysis.', + }; + } + try { + await planMatchAnalysis(tx, actor, entryId); + return { ready: true, reason: null }; + } catch (e) { + if (e instanceof ApiError && e.status === 409) return { ready: false, reason: e.message }; + throw e; + } +} diff --git a/apps/web/src/server/candidates/intake-request.test.ts b/apps/web/src/server/candidates/intake-request.test.ts new file mode 100644 index 0000000..a8b9b66 --- /dev/null +++ b/apps/web/src/server/candidates/intake-request.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiError } from '@/lib/http/errors'; +import { parseIntakeRequest } from './intake-request'; + +const PROFILE = `Alex Kim\nSenior HR Director at Northwind (2019-present)\nLed a team of 12 across payroll and benefits.\nHR Manager at Contoso (2014-2019)`; +const URL_IN = 'https://www.linkedin.com/in/alex-kim'; + +const json = (body: unknown) => + new Request('http://x.test/intake', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + +describe('parseIntakeRequest', () => { + afterEach(() => vi.restoreAllMocks()); + + it('reads pasted text and keeps the profile link as text', async () => { + const { intake, file } = await parseIntakeRequest( + json({ source: 'referral', profileText: PROFILE, profileUrl: URL_IN }), + ); + expect(intake).toMatchObject({ source: 'referral', profileUrl: URL_IN }); + expect(file.kind).toBe('profile_text'); + expect(new TextDecoder().decode(file.bytes)).toBe(PROFILE); + }); + + it('forces the source, even when the caller sent another or none', async () => { + const other = await parseIntakeRequest( + json({ source: 'referral', profileText: PROFILE }), + 'linkedin', + ); + expect(other.intake.source).toBe('linkedin'); + const none = await parseIntakeRequest(json({ profileText: PROFILE }), 'linkedin'); + expect(none.intake.source).toBe('linkedin'); + }); + + it('reads a LinkedIn PDF export from multipart and forces the source', async () => { + const form = new FormData(); + form.set('file', new File([new Uint8Array([37, 80, 68, 70])], 'Profile.pdf')); + form.set('source', 'other'); + form.set('profileUrl', URL_IN); + const { intake, file } = await parseIntakeRequest( + new Request('http://x.test/intake', { method: 'POST', body: form }), + 'linkedin', + ); + expect(intake).toEqual({ source: 'linkedin', profileUrl: URL_IN }); + expect(file).toMatchObject({ filename: 'Profile.pdf', kind: 'resume' }); + expect(file.bytes.length).toBe(4); + }); + + it('never fetches the profile link', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + await parseIntakeRequest(json({ profileText: PROFILE, profileUrl: URL_IN }), 'linkedin'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refuses text that is too short, a bad link and a missing file', async () => { + await expect( + parseIntakeRequest(json({ profileText: 'short' }), 'linkedin'), + ).rejects.toBeInstanceOf(ApiError); + await expect( + parseIntakeRequest(json({ profileText: PROFILE, profileUrl: 'not a url' }), 'linkedin'), + ).rejects.toBeInstanceOf(ApiError); + await expect( + parseIntakeRequest( + new Request('http://x.test/intake', { method: 'POST', body: new FormData() }), + 'linkedin', + ), + ).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/apps/web/src/server/candidates/intake-request.ts b/apps/web/src/server/candidates/intake-request.ts new file mode 100644 index 0000000..7437d30 --- /dev/null +++ b/apps/web/src/server/candidates/intake-request.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { + candidateIntakeSchema, + candidateIntakeTextSchema, + type CandidateIntake, + type CandidateSourceValue, +} from '@easemuse/core'; +import { ApiError } from '@/lib/http/errors'; +import { zodFields } from '@/lib/http/responses'; +import type { IntakeFile } from './intake'; + +/** + * Reads an intake request: multipart (`file`, `source`, optional `profileUrl`) or JSON (`profileText`, + * `source`, optional `profileUrl`). `forcedSource` replaces whatever the caller sent, for entry points + * that know where the profile came from (the profile link is only stored, never fetched). + */ +export async function parseIntakeRequest( + req: Request, + forcedSource?: CandidateSourceValue, +): Promise<{ intake: CandidateIntake; file: IntakeFile }> { + const invalid = (error: Parameters[0]) => + new ApiError('invalid_input', 'Invalid input', { fields: zodFields(error) }); + + 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: forcedSource ?? form?.get('source') ?? undefined, + profileUrl: form?.get('profileUrl')?.toString() || undefined, + }); + if (!parsed.success) throw invalid(parsed.error); + return { + intake: parsed.data, + file: { + filename: upload.name, + bytes: new Uint8Array(await upload.arrayBuffer()), + kind: 'resume', + }, + }; + } + + const body = (await req.json().catch(() => null)) as Record | null; + const parsed = candidateIntakeTextSchema.safeParse( + forcedSource && body && typeof body === 'object' ? { ...body, source: forcedSource } : body, + ); + if (!parsed.success) throw invalid(parsed.error); + return { + intake: parsed.data, + file: { + filename: 'profile.txt', + bytes: new TextEncoder().encode(parsed.data.profileText), + kind: 'profile_text', + }, + }; +} diff --git a/apps/web/src/server/pipeline/service.ts b/apps/web/src/server/pipeline/service.ts index 3babbee..69efdac 100644 --- a/apps/web/src/server/pipeline/service.ts +++ b/apps/web/src/server/pipeline/service.ts @@ -98,6 +98,21 @@ export async function getEntry( return toView(await loadEntry(tx, actor, entryId)); } +/** The entry of a candidate in a position the person may see, or null when they are not in it. */ +export async function getEntryForCandidate( + tx: ScopedTx, + actor: AgencyActor, + positionId: string, + candidateId: string, +): Promise { + await requirePosition(tx, actor, positionId, 'read'); + const row = await tx.pipelineEntry.findUnique({ + where: { positionId_candidateId: { positionId, candidateId } }, + select, + }); + return row ? toView(row) : null; +} + /** Everyone in a position's pipeline, oldest first (the dashboard read model comes with T-3-07). */ export async function listPositionPipeline( tx: ScopedTx, diff --git a/docs/planning/api-contracts.md b/docs/planning/api-contracts.md index 15d7679..ec2802b 100644 --- a/docs/planning/api-contracts.md +++ b/docs/planning/api-contracts.md @@ -76,6 +76,8 @@ Audience `admin`; requires `User.platformRole`. Roles: **O** owner, **Ad** admin | Method | Path | Role | Body | Response | FR / US | | ------ | -------------------------------- | ------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------- | | POST | `/candidates/intake` | R+ | multipart resume **or** `{profileText}` + `{source, profileUrl?}` | `202 jobRunId` → `CandidateDraft {duplicates[], profileDraft}` | SRC-02/03, US-202 | +| POST | `/positions/:id/candidates/intake` | R+ | multipart LinkedIn PDF export **or** `{profileText}` + `{profileUrl?}` (source is always LinkedIn; the link is stored, never fetched) | `202 {candidateId, jobRunId, positionId}` | SRC-02, §4.1, C4 | +| POST | `/positions/:id/candidates/confirm` | R+ | `ConfirmCandidateInput` (same as `POST /candidates`) | `201 {candidateId, resolution, mergedIntoId, entry, alreadyInPosition, analysis: {ready, reason}}`; 409 `possible_duplicate` until resolved. Confirm + add as sourced in one transaction; the analysis is started by the caller with `POST /pipeline/:entryId/analysis` when `ready` | §4.2, §5.1, FR-MAT-02, DP-2 | | POST | `/candidates` | R+ | `ConfirmCandidateInput {draftId, resolution: create\|merge\|link, reason?}` | `Candidate` · 409 `possible_duplicate` without resolution | SRC-04, US-203 | | GET | `/candidates` | R+ | `?q=&skills=&location=&seniority=&tags=&cursor=` | `CandidateSummary[]` (p95 < 2 s) | SRC-08, US-207 | | GET | `/search` | R+ | `?q=` | `{clients[], positions[], candidates[]}` | PLT-06 | diff --git a/docs/planning/implementation-notes.md b/docs/planning/implementation-notes.md index 1721430..0227b6b 100644 --- a/docs/planning/implementation-notes.md +++ b/docs/planning/implementation-notes.md @@ -67,6 +67,7 @@ Choices made while building Release 1 where the brief or the planning documents | 59 | **Interview times:** the hiring manager offers 1 to 5 future times on a submitted candidate (one open offer at a time); the recruiter confirms one, which creates the round with the normal rules (a submitted candidate moves to interviewing), or declines all. A time that has passed since is refused. The default round name is "Interview round N". | FR-HMP-06, decision 4. | `portal/extras.ts`, `portal/resolve-extras.ts` | | 60 | **A candidate's own updates:** a second submission while one is waiting replaces the fields it names. Accepting email, phone, compensation or work authorization makes a new approved version of the profile (the resume file is untouched) and so marks match analyses stale; availability goes to the candidate record. It needs a confirmed profile first (409 otherwise). The portal sign-in address does not change when an email is accepted. Activity log entries name the fields, never the values. | FR-CDP-04; profile fields come from the approved profile (T-2-08). | `portal/resolve-extras.ts` | | 61 | The end-to-end run found two real faults, now fixed: approving a draft did not refresh the rest of the screen (the profile still said "no version confirmed"; every query is now refreshed after an approval), and choosing today's date for a submission or a candidate decision early in the day was sent as noon UTC and refused as "in the future" (`lib/dates.ts` kept at the merge). Contact details (emails, phone numbers) are also taken out of pasted notes before they reach a model. | Found by walking the loop, not by unit tests. | `artifact-editor.tsx`, `lib/dates.ts` | +| 62 | **Paste a LinkedIn profile on a position (G#9):** one screen takes pasted text or a LinkedIn PDF export, AI drafts the profile, and one click ("Confirm, add to position and analyze") confirms the candidate, adds them to the position and opens the analysis, which starts on arrival. The source is always LinkedIn; the profile link is stored as text and is never opened or fetched (brief 4.1, C4). | The analysis needs an approved candidate profile (`planMatchAnalysis`), so the recruiter's confirm click is also the request for the analysis (DP-2); the profile and the analysis stay drafts. Confirm + add run in one transaction, so a closed position leaves the pasted profile a draft. After a **merge** into an existing candidate no analysis is offered, because the approved profile has not changed yet (the pasted one is a new draft to review). When the position has no confirmed success profile the candidate is still added and the page says why the analysis did not start. | `server/candidates/intake-request.ts`, `confirm-and-add.ts`, `positions/[positionId]/candidates/intake`, `.../confirm`, `components/pipeline/paste-profile-flow.tsx`, `analysis-view.tsx` (`autoStart`) | ## Running things diff --git a/docs/planning/tasks.md b/docs/planning/tasks.md index 7c6a6df..2dec127 100644 --- a/docs/planning/tasks.md +++ b/docs/planning/tasks.md @@ -366,6 +366,7 @@ Broken down 2026-10-07. Sessions C (reuse and history, the brief's section 14) a | T-2-16 | Voyage client in the AI gateway, `ProfileEmbedding` indexing job on profile approval, cost metering | 6 | Done (2026-10-07). Live path needs the Voyage key; tested against a fake embedder and a real pgvector | ADR-008 | | T-2-17 | "Find in my pool": up to 25 candidates for a success profile, with one-line reasons; nothing added until the recruiter adds | 7 | Done (2026-10-07). Semantic search with a keyword fallback; reasons by A8 | US-205 | | T-2-18 | "Check open positions" for a candidate: Strong, Moderate or Low fit per open position with reasons | 6 | Done (2026-10-07). Panel on the candidate page; A9 | US-206 | +| T-2-19 | Paste a LinkedIn profile on a position and run the match analysis in one flow (G#9): position-scoped intake, "confirm, add to position and analyze", paste page and auto-started analysis | 8 | Done 2026-10-15 (code, component tests and route matrix pass; the new database tests in `confirm-and-add.db.test.ts` were written but not yet run, they need a local test database). English strings only (Korean is still a skeleton, ADR-015) | US-202, US-204, US-302, §4.1, §5.1 | | T-1-15 | Benchmark: web search step and own-data step (two-step), items with source URL and date, "Our data" separate from "Web", no paid sources | 10 | Done (2026-10-07). The web step stays off until `WEB_SEARCH=on` and the Anthropic account allows it; tested against a fake search | US-108, ADR-005 | | T-1-16 | Benchmark report UI: accept suggested JD changes one by one | 6 | Done (2026-10-07). Suggestions are decided on the approved report and land in a draft of the structured JD | US-108 |
{t('intro')}
{positionId ? t('pasteIntro') : t('intro')}