diff --git a/apps/web/messages/en/candidates.json b/apps/web/messages/en/candidates.json index 1ac2192..5817cf7 100644 --- a/apps/web/messages/en/candidates.json +++ b/apps/web/messages/en/candidates.json @@ -63,6 +63,16 @@ "link": "Link to {name}: keeps both candidates", "none": "Save as a new candidate" }, + "placement": { + "add": "Also add to {title}", + "analyze": "and ask AI for the match analysis", + "analyzeHint": "AI compares the confirmed profile with the position's confirmed success profile. The result is a draft for you to review; it never moves or hides anyone.", + "added": "Candidate saved and added to {title}.", + "queued": "Match analysis requested. The score appears on the position's candidate list.", + "needsSuccessProfile": "The match analysis did not start: confirm the position's success profile first.", + "notAllowed": "The match analysis did not start: another recruiter owns this candidate in the position.", + "failed": "The match analysis did not start. Start it from the position's candidate list." + }, "confirm": "Confirm candidate", "confirmed": "Candidate saved.", "discard": "Discard draft", @@ -72,7 +82,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.", + "positionClosed": "The position is closed. Clear \"Also add\" to save the candidate without it." }, "aiFailed": "AI could not write the profile draft. {reason}", "aiErrors": { diff --git a/apps/web/messages/en/pipeline.json b/apps/web/messages/en/pipeline.json index 53b17ea..e3e4617 100644 --- a/apps/web/messages/en/pipeline.json +++ b/apps/web/messages/en/pipeline.json @@ -127,6 +127,43 @@ "closed": "This position is closed." } }, + "intake": { + "title": "Upload resumes or LinkedIn PDFs", + "intro": "Up to {max} files at a time (PDF, Word or text). Each becomes a draft to review. When you confirm one, you can add it to this position and ask for the match analysis.", + "linkedinHint": "From LinkedIn: open the profile, choose More, then Save to PDF, and upload that file. EaseMuse never opens LinkedIn itself.", + "source": "Source", + "files": "Files", + "tooMany": "Only the first {max} files are kept.", + "profileUrl": "Profile link for {name} (optional)", + "profileUrlHint": "For example https://www.linkedin.com/in/… It is saved with the candidate and helps find duplicates.", + "start": "{count, plural, =0 {Upload} one {Upload # file} other {Upload # files}}", + "uploadsLabel": "Upload results", + "uploadStates": { + "waiting": "Waiting", + "uploading": "Uploading…", + "sent": "Uploaded" + }, + "errors": { + "unsupported": "Not a PDF, Word or text file.", + "tooLarge": "Larger than 10 MB.", + "badUrl": "The profile link is not a valid web address.", + "closed": "This position is closed.", + "generic": "Could not upload. Try again." + }, + "draftsTitle": "Waiting for your review", + "draftsEmpty": "No uploaded resume is waiting for review.", + "file": "File", + "status": "Status", + "draftStates": { + "reading": "Reading the file…", + "readFailed": "Could not read the file", + "drafting": "AI is writing the profile draft…", + "aiFailed": "AI could not write the profile draft", + "ready": "Ready to review" + }, + "review": "Review {name}", + "reviewLink": "Review" + }, "batch": { "title": "Analysis", "running": "Analyzing {completed} of {total}…", diff --git a/apps/web/prisma/migrations/20261013090000_candidate_intake_position/migration.sql b/apps/web/prisma/migrations/20261013090000_candidate_intake_position/migration.sql new file mode 100644 index 0000000..d91be9e --- /dev/null +++ b/apps/web/prisma/migrations/20261013090000_candidate_intake_position/migration.sql @@ -0,0 +1,6 @@ +-- G#9: the position a resume was uploaded for, so confirming the draft can add it there and request the match analysis. +ALTER TABLE "Candidate" ADD COLUMN "intakePositionId" UUID; + +CREATE INDEX "Candidate_agencyId_intakePositionId_status_idx" ON "Candidate"("agencyId", "intakePositionId", "status"); + +ALTER TABLE "Candidate" ADD CONSTRAINT "Candidate_intakePositionId_fkey" FOREIGN KEY ("intakePositionId") REFERENCES "Position"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 5b4d40c..1bf64a1 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -231,6 +231,7 @@ model Position { intake IntakeRecord? pipeline PipelineEntry[] transitions PositionTransition[] + intakeDrafts Candidate[] @@index([agencyId, status]) @@index([agencyId, clientId]) @@ -366,18 +367,21 @@ model Candidate { doNotContactReason String? doNotContactAt DateTime? mergedIntoId String? @db.Uuid + // The position the resume was uploaded for (G#9): confirming the draft offers to add it there and analyze + intakePositionId String? @db.Uuid anonymizedAt DateTime? // set when the data-rights job has removed the personal data (ADR-016) searchText Unsupported("tsvector")? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade) - documents CandidateDocument[] - pipeline PipelineEntry[] - portalUser PortalUser? - consents Consent[] - updates CandidateUpdateProposal[] - notes CandidateNote[] + agency Agency @relation(fields: [agencyId], references: [id], onDelete: Cascade) + intakePosition Position? @relation(fields: [intakePositionId], references: [id], onDelete: SetNull) + documents CandidateDocument[] + pipeline PipelineEntry[] + portalUser PortalUser? + consents Consent[] + updates CandidateUpdateProposal[] + notes CandidateNote[] @@index([agencyId, status]) @@index([agencyId, email]) @@ -392,6 +396,7 @@ model Candidate { @@index([employerKeys], type: Gin) @@index([searchText], type: Gin) @@index([agencyId, fullName]) + @@index([agencyId, intakePositionId, status]) } // Recruiter notes on a candidate (T-2-13, FR-SRC-09): free text only the agency sees. diff --git a/apps/web/src/app/api/permission-matrix.test.ts b/apps/web/src/app/api/permission-matrix.test.ts index 920007c..0274f09 100644 --- a/apps/web/src/app/api/permission-matrix.test.ts +++ b/apps/web/src/app/api/permission-matrix.test.ts @@ -247,6 +247,11 @@ vi.mock('@/server/candidates/duplicates', () => ({ vi.mock('@/server/candidates/confirm', () => ({ confirmCandidate: async () => ({ candidateId: 'c1', resolution: 'create', mergedIntoId: null }), })); +vi.mock('@/server/candidates/place', () => ({ + listIntakeDrafts: async () => [], + placeConfirmedCandidate: async () => ({ entryId: 'e1', added: true, canAnalyze: true }), + requestPlacementAnalysis: async () => ({ status: 'queued', jobRunId: 'run1' }), +})); vi.mock('@/server/candidates/service', () => ({ getCandidate: async () => ({ id: 'c1' }), discardDraft: async () => undefined, @@ -610,6 +615,7 @@ import { POST as addToPositionPost } from './v1/agencies/[agencyId]/positions/[p 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'; +import { GET as intakeDraftsGet } from './v1/agencies/[agencyId]/positions/[positionId]/intake-drafts/route'; import { PUT as orderPut, DELETE as orderDelete, @@ -1421,6 +1427,13 @@ const endpoints: Endpoint[] = [ allowed: AGENCY_ALL, call: () => pipelineGet(new Request('http://x.test/'), agencyParams), }, + { + name: 'GET position intake drafts', + file: 'v1/agencies/[agencyId]/positions/[positionId]/intake-drafts/route.ts', + audience: 'agency', + allowed: AGENCY_ALL, + call: () => intakeDraftsGet(new Request('http://x.test/'), agencyParams), + }, { name: 'POST batch analysis', file: 'v1/agencies/[agencyId]/positions/[positionId]/analyses/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..91eed52 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 @@ -15,9 +15,10 @@ import { export const dynamic = 'force-dynamic'; /** - * Adds a candidate from a resume: multipart (`file`, `source`, optional `profileUrl`) or JSON - * (`profileText`, `source`, optional `profileUrl`). Creates a DRAFT candidate and reads the file in the - * background: `202 { candidateId, jobRunId }`. Any member. + * Adds a candidate from a resume: multipart (`file`, `source`, optional `profileUrl`, `positionId`) or + * JSON (`profileText`, `source`, optional `profileUrl`, `positionId`). Creates a DRAFT candidate and + * reads the file in the background: `202 { candidateId, jobRunId }`. Any member who can see the + * position, when one is given (G#9). */ export async function POST( req: Request, @@ -39,6 +40,7 @@ export async function POST( const parsed = candidateIntakeSchema.safeParse({ source: form?.get('source') ?? undefined, profileUrl: form?.get('profileUrl')?.toString() || undefined, + positionId: form?.get('positionId')?.toString() || undefined, }); if (!parsed.success) throw new ApiError('invalid_input', 'Invalid input', { fields: zodFields(parsed.error) }); diff --git a/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/route.ts index 72c5f96..f77fd02 100644 --- a/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/route.ts +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/candidates/route.ts @@ -8,12 +8,21 @@ import { pageQuerySchema } from '@/lib/http/pagination'; import { created, handleRoute, ok } from '@/lib/http/responses'; import { parseBody, parseQuery } from '@/lib/http/validation'; import { confirmCandidate } from '@/server/candidates/confirm'; +import { + placeConfirmedCandidate, + requestPlacementAnalysis, + type AnalysisRequest, +} from '@/server/candidates/place'; import { searchCandidates } from '@/server/candidates/search'; /** * Confirms a draft candidate (US-202, US-203). 409 `possible_duplicate` when someone already in the * database looks the same and no `resolution` (`create` with a reason, `merge` or `link` with a * `targetId`) was chosen. + * + * With `positionId` the confirmed candidate is also added to that position in the same transaction, + * and with `analyze` the match analysis is then queued (G#9). `placement` and `analysis` in the + * answer say what happened; an analysis that cannot start does not undo the confirmation. */ export async function POST( req: Request, @@ -24,11 +33,23 @@ export async function POST( 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) => confirmCandidate(tx, authed.actor, input), { - userId: authed.userId, - }), + const { positionId } = input; + const confirmed = await withAgency( + authed.agency.id, + async (tx) => { + const result = await confirmCandidate(tx, authed.actor, input); + const placement = positionId + ? await placeConfirmedCandidate(tx, authed.actor, result.candidateId, positionId) + : null; + return { ...result, placement }; + }, + { userId: authed.userId }, ); + const analysis: AnalysisRequest = + input.analyze && positionId && confirmed.placement + ? await requestPlacementAnalysis(authed.actor, positionId, confirmed.placement) + : { status: 'not_requested' }; + return created({ ...confirmed, analysis }); }); } diff --git a/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/intake-drafts/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/intake-drafts/route.ts new file mode 100644 index 0000000..bbc1bb5 --- /dev/null +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/intake-drafts/route.ts @@ -0,0 +1,26 @@ +// 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, ok } from '@/lib/http/responses'; +import { listIntakeDrafts } from '@/server/candidates/place'; + +export const dynamic = 'force-dynamic'; + +/** Resumes uploaded for this position that wait for review (G#9). Anyone who can see the position. */ +export async function GET( + _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; + return ok( + await withAgency(authed.agency.id, (tx) => listIntakeDrafts(tx, authed.actor, positionId), { + userId: authed.userId, + }), + ); + }); +} diff --git a/apps/web/src/components/candidates/candidate-review.tsx b/apps/web/src/components/candidates/candidate-review.tsx index 69e72c3..a740fea 100644 --- a/apps/web/src/components/candidates/candidate-review.tsx +++ b/apps/web/src/components/candidates/candidate-review.tsx @@ -14,6 +14,7 @@ import { TextField } from '@/components/ui/field'; import { useToast } from '@/components/ui/toast'; import { ApiClientError, apiFetch } from '@/lib/api-client'; import type { DuplicateMatch } from '@/server/candidates/duplicates'; +import type { AnalysisRequest, Placement } from '@/server/candidates/place'; import type { CandidateView } from '@/server/candidates/service'; import { CandidateProfileEditor } from './candidate-profile-editor'; @@ -32,6 +33,21 @@ const AI_ERROR_CODES = [ type Choice = 'none' | DuplicateResolution; +/** What `POST /candidates` answers; `placement` and `analysis` only matter for an upload made for a position (G#9). */ +interface ConfirmAnswer { + candidateId: string; + placement?: Placement | null; + analysis?: AnalysisRequest; +} + +const ANALYSIS_MESSAGES = { + queued: 'queued', + not_requested: 'queued', + needs_success_profile: 'needsSuccessProfile', + not_allowed: 'notAllowed', + failed: 'failed', +} as const satisfies Record; + /** Review of a new candidate (US-202, US-203): read status, the AI profile draft, duplicates, and the decision. */ export function CandidateReview({ agencyId, @@ -45,6 +61,7 @@ export function CandidateReview({ const t = useTranslations('candidates.review'); const d = useTranslations('candidates.review.duplicates'); const decision = useTranslations('candidates.review.decision'); + const placement = useTranslations('candidates.review.placement'); const states = useTranslations('common.states'); const { toast } = useToast(); const router = useRouter(); @@ -54,6 +71,9 @@ export function CandidateReview({ const [choice, setChoice] = useState('none'); const [reason, setReason] = useState(''); const [error, setError] = useState(null); + // Uploaded for a position (G#9): confirming can add the candidate there and ask for the analysis. + const [addToPosition, setAddToPosition] = useState(true); + const [analyze, setAnalyze] = useState(true); const query = useQuery({ queryKey: ['candidate', agencyId, candidateId], @@ -100,29 +120,44 @@ export function CandidateReview({ }, onError: () => setError(t('errors.generic')), }); + const intakePosition = query.data?.data.intakePosition ?? null; const confirm = useMutation({ mutationFn: () => - apiFetch<{ candidateId: string }>(`${base}/candidates`, { + apiFetch(`${base}/candidates`, { body: { draftId: candidateId, ...(choice !== 'none' ? { resolution: choice } : {}), ...(choice === 'merge' || choice === 'link' ? { targetId } : {}), ...(reason.trim() ? { reason: reason.trim() } : {}), + ...(intakePosition && addToPosition ? { positionId: intakePosition.id, analyze } : {}), }, }), onSuccess: async (res) => { - toast({ title: t('confirmed') }); + const placed = res.data.placement ? intakePosition : null; + const analysis = res.data.analysis?.status ?? 'not_requested'; + toast({ + title: placed ? placement('added', { title: placed.title }) : t('confirmed'), + ...(placed && analysis !== 'not_requested' + ? { description: placement(ANALYSIS_MESSAGES[analysis]) } + : {}), + }); // The candidate page shares this cache entry: drop the draft's copy so it shows the saved // candidate (a merge lands on the existing candidate, so refresh that one too). await queryClient.invalidateQueries({ queryKey: ['candidate', agencyId, candidateId] }); await queryClient.invalidateQueries({ queryKey: ['candidate', agencyId, res.data.candidateId], }); - router.push(`/a/${agencySlug}/candidates/${res.data.candidateId}`); + router.push( + placed + ? `/a/${agencySlug}/positions/${placed.id}/candidates` + : `/a/${agencySlug}/candidates/${res.data.candidateId}`, + ); }, onError: (e) => { if (e instanceof ApiClientError && e.code === 'possible_duplicate') return setError(t('errors.duplicate')); + if (e instanceof ApiClientError && e.code === 'position_closed') + return setError(t('errors.positionClosed')); 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) return setError(t('errors.noProfile')); @@ -330,6 +365,28 @@ export function CandidateReview({ onChange={(e) => setReason(e.target.value)} /> ) : null} + {intakePosition ? ( +
+ + +

{placement('analyzeHint')}

+
+ ) : null} {error ? (

{error} diff --git a/apps/web/src/components/candidates/candidates.test.tsx b/apps/web/src/components/candidates/candidates.test.tsx index f255f0a..dc51610 100644 --- a/apps/web/src/components/candidates/candidates.test.tsx +++ b/apps/web/src/components/candidates/candidates.test.tsx @@ -413,6 +413,93 @@ describe('CandidateReview', () => { expect(reads()).toBeGreaterThan(before); }); + it('adds a draft uploaded for a position there and asks for the analysis on confirm (G#9)', async () => { + const stub = serve( + candidate({ profileArtifactId: 'art1', intakePosition: { id: 'p1', title: 'Head of HR' } }), + [], + { + [`POST ${api}/candidates`]: () => + json( + { + data: { + candidateId: 'c1', + resolution: 'create', + mergedIntoId: null, + placement: { entryId: 'e1', added: true, canAnalyze: true }, + analysis: { status: 'queued', jobRunId: 'j1' }, + }, + }, + 201, + ), + }, + ); + const user = userEvent.setup(); + view(); + const add = await screen.findByRole('checkbox', { name: 'Also add to Head of HR' }); + const analyze = screen.getByRole('checkbox', { name: 'and ask AI for the match analysis' }); + expect((add as HTMLInputElement).checked).toBe(true); + expect((analyze as HTMLInputElement).checked).toBe(true); + await user.click(screen.getByRole('button', { name: 'Confirm candidate' })); + await waitFor(() => expect(push).toHaveBeenCalledWith('/a/acme/positions/p1/candidates')); + expect( + stub.calls.find((c) => c.method === 'POST' && c.url.pathname.endsWith('/candidates'))!.body, + ).toEqual({ draftId: 'c1', positionId: 'p1', analyze: true }); + }); + + it('confirms without the position when the recruiter clears "Also add"', async () => { + const stub = serve( + candidate({ profileArtifactId: 'art1', intakePosition: { id: 'p1', title: 'Head of HR' } }), + [], + { + [`POST ${api}/candidates`]: () => + json( + { + data: { + candidateId: 'c1', + resolution: 'create', + mergedIntoId: null, + placement: null, + analysis: { status: 'not_requested' }, + }, + }, + 201, + ), + }, + ); + const user = userEvent.setup(); + view(); + await user.click(await screen.findByRole('checkbox', { name: 'Also add to Head of HR' })); + expect( + ( + screen.getByRole('checkbox', { + name: 'and ask AI for the match analysis', + }) as HTMLInputElement + ).disabled, + ).toBe(true); + await user.click(screen.getByRole('button', { name: 'Confirm candidate' })); + await waitFor(() => expect(push).toHaveBeenCalledWith('/a/acme/candidates/c1')); + expect( + stub.calls.find((c) => c.method === 'POST' && c.url.pathname.endsWith('/candidates'))!.body, + ).toEqual({ draftId: 'c1' }); + }); + + it('says the position is closed instead of "no profile"', async () => { + serve( + candidate({ profileArtifactId: 'art1', intakePosition: { id: 'p1', title: 'Head of HR' } }), + [], + { + [`POST ${api}/candidates`]: () => + json({ error: { code: 'position_closed', message: 'This position is closed' } }, 409), + }, + ); + const user = userEvent.setup(); + view(); + await user.click(await screen.findByRole('button', { name: 'Confirm candidate' })); + expect((await screen.findByRole('alert')).textContent).toBe( + 'The position is closed. Clear "Also add" to save the candidate without it.', + ); + }); + it('needs a decision when there is a possible duplicate, and sends the merge with the chosen candidate', async () => { const stub = serve(candidate({ profileArtifactId: 'art1' }), [match()], { [`POST ${api}/candidates`]: () => diff --git a/apps/web/src/components/pipeline/pipeline.test.tsx b/apps/web/src/components/pipeline/pipeline.test.tsx index e72f5e9..c0484f3 100644 --- a/apps/web/src/components/pipeline/pipeline.test.tsx +++ b/apps/web/src/components/pipeline/pipeline.test.tsx @@ -327,6 +327,7 @@ describe('ReviewBoard', () => { stubApiFor({ [`GET ${pos}/review`]: () => json(dashboard(rows, explainOnly)), [`GET ${pos}`]: () => json({ data: { ownerId: 'u1' } }), + [`GET ${pos}/intake-drafts`]: () => json({ data: [] }), ...extra, }); const board = (role: 'admin' | 'lead' | 'recruiter' = 'recruiter', userId = 'u1') => diff --git a/apps/web/src/components/pipeline/position-intake.test.tsx b/apps/web/src/components/pipeline/position-intake.test.tsx new file mode 100644 index 0000000..bec83a2 --- /dev/null +++ b/apps/web/src/components/pipeline/position-intake.test.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderApp } from '@/test/render'; +import { PositionIntake } from './position-intake'; + +afterEach(() => vi.unstubAllGlobals()); +const json = (body: unknown, status = 200) => Response.json(body, { status }); +const drafts = '/api/v1/agencies/a1/positions/p1/intake-drafts'; +const intake = '/api/v1/agencies/a1/candidates/intake'; +const pdf = (name: string) => new File(['%PDF-1.4'], name, { type: 'application/pdf' }); + +/** Fetch stub that also takes the multipart upload (the shared stubApi reads bodies as JSON). */ +function serve( + list: () => unknown[], + upload: (form: FormData) => Response = () => json({ data: { candidateId: 'c9' } }, 202), +) { + const uploads: FormData[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const path = new URL(String(input), 'http://localhost').pathname; + if (path === drafts && (init.method ?? 'GET') === 'GET') return json({ data: list() }); + if (path === intake && init.method === 'POST') { + uploads.push(init.body as FormData); + return upload(init.body as FormData); + } + throw new Error(`Unexpected request: ${init.method ?? 'GET'} ${path}`); + }), + ); + return uploads; +} +const view = () => renderApp(); +const ready = { + candidateId: 'c9', + filename: 'alex.pdf', + createdAt: '2026-10-13T00:00:00.000Z', + state: 'ready', +}; + +describe('PositionIntake (G#9)', () => { + it('uploads the chosen files for this position, with each profile link', async () => { + let uploaded = false; + const uploads = serve(() => (uploaded ? [ready] : [])); + const user = userEvent.setup(); + view(); + expect(await screen.findByText('No uploaded resume is waiting for review.')).toBeTruthy(); + await user.upload(screen.getByLabelText('Files'), [pdf('alex.pdf'), pdf('kim.pdf')]); + await user.type( + screen.getByLabelText('Profile link for alex.pdf (optional)'), + 'https://www.linkedin.com/in/alex', + ); + uploaded = true; + await user.click(screen.getByRole('button', { name: 'Upload 2 files' })); + await waitFor(() => expect(uploads).toHaveLength(2)); + const alex = uploads.find((f) => (f.get('file') as File).name === 'alex.pdf')!; + expect(alex.get('positionId')).toBe('p1'); + expect(alex.get('source')).toBe('linkedin'); + expect(alex.get('profileUrl')).toBe('https://www.linkedin.com/in/alex'); + const kim = uploads.find((f) => (f.get('file') as File).name === 'kim.pdf')!; + expect(kim.get('profileUrl')).toBeNull(); + + const row = (await screen.findByRole('rowheader', { name: 'alex.pdf' })).closest('tr')!; + expect(within(row).getByText('Ready to review')).toBeTruthy(); + const link = within(row).getByRole('link', { name: 'Review alex.pdf' }); + expect(link.getAttribute('href')).toBe('/a/acme/candidates/c9/review'); + }); + + it('keeps at most 10 files and says so', async () => { + serve(() => []); + const user = userEvent.setup(); + view(); + const files = Array.from({ length: 12 }, (_, i) => pdf(`cv-${i}.pdf`)); + await user.upload(await screen.findByLabelText('Files'), files); + expect(screen.getByText('Only the first 10 files are kept.')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Upload 10 files' })).toBeTruthy(); + }); + + it('says why a file was refused', async () => { + serve( + () => [], + () => json({ error: { code: 'conflict', message: 'This position is closed' } }, 409), + ); + const user = userEvent.setup(); + view(); + await user.upload(await screen.findByLabelText('Files'), [pdf('alex.pdf')]); + await user.click(screen.getByRole('button', { name: 'Upload 1 file' })); + expect(await screen.findByText('This position is closed.')).toBeTruthy(); + }); + + it('shows drafts still being read without a review link', async () => { + serve(() => [ + { + candidateId: 'c1', + filename: 'new.pdf', + createdAt: '2026-10-13T00:00:00.000Z', + state: 'reading', + }, + ]); + view(); + const row = (await screen.findByRole('rowheader', { name: 'new.pdf' })).closest('tr')!; + expect(within(row).getByText('Reading the file…')).toBeTruthy(); + expect(within(row).queryByRole('link')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/pipeline/position-intake.tsx b/apps/web/src/components/pipeline/position-intake.tsx new file mode 100644 index 0000000..d158149 --- /dev/null +++ b/apps/web/src/components/pipeline/position-intake.tsx @@ -0,0 +1,269 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +'use client'; + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { + CANDIDATE_SOURCES, + POSITION_INTAKE_MAX_FILES, + type CandidateSourceValue, +} from '@easemuse/core'; +import { Button } from '@/components/ui/button'; +import { SelectField, TextField } from '@/components/ui/field'; +import { apiFetch } from '@/lib/api-client'; +import type { IntakeDraft } from '@/server/candidates/place'; + +const PARALLEL_UPLOADS = 3; +const MAX_BYTES = 10 * 1024 * 1024; +const TYPES = /\.(pdf|docx|txt)$/i; + +type Rejection = 'unsupported' | 'tooLarge' | 'badUrl' | 'closed' | 'generic'; +type Upload = + | { name: string; state: 'waiting' | 'uploading' | 'sent' } + | { name: string; state: 'rejected'; reason: Rejection }; + +interface Chosen { + file: File; + profileUrl: string; +} + +async function sendOne( + agencyId: string, + positionId: string, + source: CandidateSourceValue, + { file, profileUrl }: Chosen, +): Promise { + const reject = (reason: Rejection): Upload => ({ name: file.name, state: 'rejected', reason }); + if (!TYPES.test(file.name)) return reject('unsupported'); + if (file.size > MAX_BYTES) return reject('tooLarge'); + const form = new FormData(); + form.set('file', file); + form.set('source', source); + form.set('positionId', positionId); + if (profileUrl.trim()) form.set('profileUrl', profileUrl.trim()); + try { + const response = await fetch(`/api/v1/agencies/${agencyId}/candidates/intake`, { + method: 'POST', + body: form, + }); + if (response.ok) return { name: file.name, state: 'sent' }; + const body = (await response.json().catch(() => null)) as { + error?: { code?: string; fields?: Record }; + } | null; + const code = body?.error?.code; + if (code === 'unsupported_type') return reject('unsupported'); + if (code === 'file_too_large') return reject('tooLarge'); + if (body?.error?.fields?.profileUrl) return reject('badUrl'); + if (response.status === 409) return reject('closed'); + return reject('generic'); + } catch { + return reject('generic'); + } +} + +/** + * Resumes or LinkedIn profile PDFs uploaded straight into a position (G#9; brief 4.1, 4.2, 5). + * Each file becomes a draft candidate as in the bulk upload; the recruiter reviews and confirms each + * one, and that confirmation can add the candidate here and ask for the match analysis. The files + * are what the recruiter saved themselves: nothing is fetched from LinkedIn (brief 4.1, C4). + */ +export function PositionIntake({ + agencyId, + agencySlug, + positionId, +}: { + agencyId: string; + agencySlug: string; + positionId: string; +}) { + const t = useTranslations('pipeline.intake'); + const sources = useTranslations('candidates.sources'); + const queryClient = useQueryClient(); + const [source, setSource] = useState('linkedin'); + const [chosen, setChosen] = useState([]); + const [uploads, setUploads] = useState(null); + const [notice, setNotice] = useState(null); + const [running, setRunning] = useState(false); + + const key = ['intake-drafts', agencyId, positionId]; + const drafts = useQuery({ + queryKey: key, + queryFn: ({ signal }) => + apiFetch( + `/api/v1/agencies/${agencyId}/positions/${positionId}/intake-drafts`, + { signal }, + ), + // Keep checking while a file is being read or the AI profile draft is on its way. + refetchInterval: (q) => + q.state.data?.data.some((d) => d.state === 'reading' || d.state === 'drafting') + ? 3000 + : false, + }); + + const choose = (list: FileList | null) => { + const all = [...(list ?? [])]; + setNotice( + all.length > POSITION_INTAKE_MAX_FILES + ? t('tooMany', { max: POSITION_INTAKE_MAX_FILES }) + : null, + ); + setChosen(all.slice(0, POSITION_INTAKE_MAX_FILES).map((file) => ({ file, profileUrl: '' }))); + }; + + const start = async () => { + if (running || chosen.length === 0) return; + setRunning(true); + const queue = [...chosen]; + setUploads(queue.map((c) => ({ name: c.file.name, state: 'waiting' as const }))); + const patch = (index: number, next: Upload) => + setUploads((current) => current?.map((u, i) => (i === index ? next : u)) ?? null); + let cursor = 0; + const worker = async () => { + while (cursor < queue.length) { + const index = cursor++; + const item = queue[index]!; + patch(index, { name: item.file.name, state: 'uploading' }); + patch(index, await sendOne(agencyId, positionId, source, item)); + } + }; + await Promise.all(Array.from({ length: Math.min(PARALLEL_UPLOADS, queue.length) }, worker)); + setRunning(false); + setChosen([]); + await queryClient.invalidateQueries({ queryKey: key }); + }; + + const list = drafts.data?.data ?? []; + + return ( +

+

+ {t('title')} +

+

{t('intro', { max: POSITION_INTAKE_MAX_FILES })}

+

{t('linkedinHint')}

+ +
{ + e.preventDefault(); + void start(); + }} + > + setSource(e.target.value as CandidateSourceValue)} + > + {CANDIDATE_SOURCES.map((s) => ( + + ))} + + + {notice ? ( +

+ {notice} +

+ ) : null} + {chosen.length > 0 ? ( +
    + {chosen.map((c, i) => ( +
  • + + setChosen((all) => + all.map((x, j) => (j === i ? { ...x, profileUrl: e.target.value } : x)), + ) + } + /> +
  • + ))} +
+ ) : null} +
+ +
+
+ + {uploads ? ( +
    + {uploads.map((u, i) => ( +
  • + {u.name} + {u.state === 'rejected' ? ( + {t(`errors.${u.reason}`)} + ) : ( + {t(`uploadStates.${u.state}`)} + )} +
  • + ))} +
+ ) : null} + +

{t('draftsTitle')}

+ {drafts.isSuccess && list.length === 0 ? ( +

{t('draftsEmpty')}

+ ) : null} + {list.length > 0 ? ( + + + + + + + + + + {list.map((d) => ( + + + + + ))} + +
{t('draftsTitle')}
+ {t('file')} + + {t('status')} +
+ {d.filename} + + + {t(`draftStates.${d.state}`)} + {d.state === 'reading' || d.state === 'drafting' ? null : ( + + {t('reviewLink')} + + )} + +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/pipeline/review-board.tsx b/apps/web/src/components/pipeline/review-board.tsx index 51967e7..c4f73ca 100644 --- a/apps/web/src/components/pipeline/review-board.tsx +++ b/apps/web/src/components/pipeline/review-board.tsx @@ -14,6 +14,7 @@ import { apiFetch } from '@/lib/api-client'; import type { Dashboard, DashboardRow } from '@/server/matching/dashboard'; import { AddCandidatePanel } from './add-candidate-panel'; import { BatchProgress } from './batch-progress'; +import { PositionIntake } from './position-intake'; import { TransitionControl } from './transition-control'; type SortMode = 'suggested' | 'name' | 'state'; @@ -158,6 +159,8 @@ export function ReviewBoard({ onAdded={refresh} /> + +
{ + if (intake.positionId) { + // Uploaded for a position (G#9): the person must see it and it must still take candidates. + const position = await requirePosition(tx, actor, intake.positionId, 'read'); + if (position.status === 'filled' || position.status === 'cancelled') { + throw new ApiError('conflict', 'This position is closed', { status: 409 }); + } + } const stored = await storeOriginal(tx, { agencyId: actor.agencyId, filename: file.filename, @@ -51,6 +59,7 @@ export async function createCandidateDraft( profileUrl: intake.profileUrl ?? null, ...matchKeys({ profileUrl: intake.profileUrl }), status: 'draft', + intakePositionId: intake.positionId ?? null, createdById: actor.userId, }, select: { id: true }, @@ -71,7 +80,12 @@ export async function createCandidateDraft( action: 'candidate.draft_created', entityType: 'candidate', entityId: candidate.id, - data: { source: intake.source, kind: file.kind, documentId: document.id }, + data: { + source: intake.source, + kind: file.kind, + documentId: document.id, + ...(intake.positionId ? { positionId: intake.positionId } : {}), + }, }); return { candidateId: candidate.id, documentId: document.id }; } diff --git a/apps/web/src/server/candidates/place.db.test.ts b/apps/web/src/server/candidates/place.db.test.ts new file mode 100644 index 0000000..3e8dae3 --- /dev/null +++ b/apps/web/src/server/candidates/place.db.test.ts @@ -0,0 +1,175 @@ +// 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 { createCandidateDraft } from './intake'; +import { listIntakeDrafts, placeConfirmedCandidate } from './place'; + +describe.skipIf(!dbAvailable())('upload for a position, confirm, analyze (G#9)', () => { + let admin: pg.Client; + let handle: DbHandle; + let agencyA: string; + let agencyB: string; + let lead: AgencyActor; + let outsider: AgencyActor; + let clientId: string; + type Tx = Parameters['withAgency']>[1]>[0]; + const run = (fn: (tx: Tx) => Promise) => appScope(handle).withAgency(agencyA, fn); + + const newPosition = async () => + (await run((tx) => createPosition(tx, lead, { clientId, title: 'Role', location: 'Seoul' }))) + .id; + const upload = (positionId?: string) => + run((tx) => + createCandidateDraft( + tx, + lead, + { source: 'linkedin', ...(positionId ? { positionId } : {}) }, + { + filename: `${randomUUID()}.pdf`, + bytes: new TextEncoder().encode(`resume ${randomUUID()}`), + kind: 'resume', + }, + ), + ); + /** What confirming does to the row; the confirmation itself is covered in confirm.db.test.ts. */ + const markConfirmed = (candidateId: string) => + run((tx) => tx.candidate.update({ where: { id: candidateId }, data: { status: 'active' } })); + const approveSuccessProfile = (positionId: string) => + run(async (tx) => { + const artifact = await createArtifact(tx, { + agencyId: agencyA, + kind: 'success_profile', + subject: { type: 'position', id: positionId }, + }); + const v = await addDraftVersion(tx, { + agencyId: agencyA, + artifactId: artifact.id, + content: { + summary: 'Ideal candidate', + criteria: [{ id: 'c1', text: '8 years in HR', category: 'must_have', basis: 'jd' }], + expectations: { sixMonths: '', twelveMonths: '' }, + keyChallenges: [], + }, + origin: 'human', + createdById: lead.userId, + }); + await approveVersion(tx, lead, v.id); + }); + const close = (positionId: string) => + run((tx) => tx.position.update({ where: { id: positionId }, data: { status: 'cancelled' } })); + + beforeAll(async () => { + admin = await adminClient(); + handle = appHandle(2); + agencyA = await createAgency(admin, 'Place A'); + agencyB = await createAgency(admin, 'Place B'); + const [u1, u2] = [await createUser(admin), await createUser(admin)]; + await admin.query( + `INSERT INTO "Membership" (id, "agencyId", "userId", role, active, "updatedAt") VALUES (gen_random_uuid(), $1, $2, 'lead', true, now())`, + [agencyA, u1], + ); + lead = { kind: 'agency', userId: u1, agencyId: agencyA, role: 'lead' }; + outsider = { kind: 'agency', userId: u2, agencyId: agencyB, role: 'recruiter' }; + clientId = (await run((tx) => createClient(tx, lead, { name: 'Place Client' }))).client.id; + }); + afterAll(async () => { + await cleanup(admin, { agencies: [agencyA, agencyB] }); + await admin.end(); + await handle.close(); + }); + + it('keeps the position on the draft and lists it as waiting for review', async () => { + const positionId = await newPosition(); + const draft = await upload(positionId); + const row = await admin + .query(`SELECT "intakePositionId", status FROM "Candidate" WHERE id = $1`, [ + draft.candidateId, + ]) + .then((r) => r.rows[0]); + expect(row).toEqual({ intakePositionId: positionId, status: 'draft' }); + const drafts = await run((tx) => listIntakeDrafts(tx, lead, positionId)); + expect(drafts.map((d) => [d.candidateId, d.state])).toEqual([[draft.candidateId, 'reading']]); + // Nothing is in the position yet: only the recruiter's confirmation puts it there. + expect( + await run((tx) => tx.pipelineEntry.count({ where: { candidateId: draft.candidateId } })), + ).toBe(0); + }); + + it('does not list drafts of other positions, confirmed ones, or plain uploads', async () => { + const positionId = await newPosition(); + const other = await newPosition(); + const mine = await upload(positionId); + const confirmed = await upload(positionId); + await markConfirmed(confirmed.candidateId); + await upload(other); + await upload(); + const drafts = await run((tx) => listIntakeDrafts(tx, lead, positionId)); + expect(drafts.map((d) => d.candidateId)).toEqual([mine.candidateId]); + }); + + it('refuses an upload for a closed position, or one in another agency', async () => { + const positionId = await newPosition(); + await close(positionId); + await expect(upload(positionId)).rejects.toMatchObject({ status: 409 }); + const open = await newPosition(); + await expect( + appScope(handle).withAgency(agencyB, (tx) => listIntakeDrafts(tx, outsider, open)), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('adds the confirmed candidate to the position in "sourced", once', async () => { + const positionId = await newPosition(); + const draft = await upload(positionId); + await markConfirmed(draft.candidateId); + const first = await run((tx) => + placeConfirmedCandidate(tx, lead, draft.candidateId, positionId), + ); + expect(first).toMatchObject({ added: true, canAnalyze: false }); + const entry = await run((tx) => + tx.pipelineEntry.findUniqueOrThrow({ where: { id: first.entryId } }), + ); + expect(entry).toMatchObject({ state: 'sourced', ownerId: lead.userId, positionId }); + // Already there (for example after merging into someone in the position): the same entry. + const again = await run((tx) => + placeConfirmedCandidate(tx, lead, draft.candidateId, positionId), + ); + expect(again).toEqual({ ...first, added: false }); + }); + + it('says the analysis can run only when the success profile is confirmed', async () => { + const positionId = await newPosition(); + await approveSuccessProfile(positionId); + const draft = await upload(positionId); + await markConfirmed(draft.candidateId); + const placed = await run((tx) => + placeConfirmedCandidate(tx, lead, draft.candidateId, positionId), + ); + expect(placed.canAnalyze).toBe(true); + }); + + it('refuses to place into a closed position with its own error code', async () => { + const positionId = await newPosition(); + const draft = await upload(positionId); + await markConfirmed(draft.candidateId); + await close(positionId); + await expect( + run((tx) => placeConfirmedCandidate(tx, lead, draft.candidateId, positionId)), + ).rejects.toMatchObject({ code: 'position_closed', status: 409 }); + }); +}); diff --git a/apps/web/src/server/candidates/place.test.ts b/apps/web/src/server/candidates/place.test.ts new file mode 100644 index 0000000..6be3d37 --- /dev/null +++ b/apps/web/src/server/candidates/place.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AgencyActor } from '@easemuse/core'; +import { ApiError } from '@/lib/http/errors'; +import { intakeDraftState, requestPlacementAnalysis } from './place'; +import type { CandidateView } from './service'; + +const startBatchAnalysis = vi.hoisted(() => vi.fn()); +vi.mock('@/server/matching/batch', () => ({ startBatchAnalysis })); + +const actor: AgencyActor = { kind: 'agency', userId: 'u1', agencyId: 'a1', role: 'recruiter' }; +const placement = { entryId: 'e1', added: true, canAnalyze: true }; + +afterEach(() => startBatchAnalysis.mockReset()); + +describe('requestPlacementAnalysis (G#9)', () => { + it('queues the analysis of the one placed candidate', async () => { + startBatchAnalysis.mockResolvedValue({ jobRunId: 'run1', total: 1 }); + expect(await requestPlacementAnalysis(actor, 'p1', placement)).toEqual({ + status: 'queued', + jobRunId: 'run1', + }); + expect(startBatchAnalysis).toHaveBeenCalledWith(actor, 'p1', { entryIds: ['e1'] }); + }); + + it('does not start without a confirmed success profile', async () => { + expect( + await requestPlacementAnalysis(actor, 'p1', { ...placement, canAnalyze: false }), + ).toEqual({ status: 'needs_success_profile' }); + expect(startBatchAnalysis).not.toHaveBeenCalled(); + }); + + it('reports a candidate owned by another recruiter, and any other failure, without throwing', async () => { + startBatchAnalysis.mockRejectedValueOnce(new ApiError('forbidden', 'No', { status: 403 })); + expect(await requestPlacementAnalysis(actor, 'p1', placement)).toEqual({ + status: 'not_allowed', + }); + startBatchAnalysis.mockRejectedValueOnce(new Error('queue down')); + expect(await requestPlacementAnalysis(actor, 'p1', placement)).toEqual({ status: 'failed' }); + }); +}); + +describe('intakeDraftState', () => { + const view = (over: Partial = {}) => + ({ + profileArtifactId: null, + profileAi: null, + documents: [{ extractionStatus: 'ready' }], + ...over, + }) as CandidateView; + const doc = (extractionStatus: 'pending' | 'ready' | 'failed') => + [{ extractionStatus }] as CandidateView['documents']; + + it('follows the draft from reading to ready', () => { + expect(intakeDraftState(view({ documents: doc('pending') }))).toBe('reading'); + expect(intakeDraftState(view({ documents: doc('failed') }))).toBe('readFailed'); + expect(intakeDraftState(view())).toBe('drafting'); + expect(intakeDraftState(view({ profileAi: { status: 'failed', error: 'ai_failed' } }))).toBe( + 'aiFailed', + ); + expect(intakeDraftState(view({ profileArtifactId: 'art1' }))).toBe('ready'); + }); +}); diff --git a/apps/web/src/server/candidates/place.ts b/apps/web/src/server/candidates/place.ts new file mode 100644 index 0000000..79de97a --- /dev/null +++ b/apps/web/src/server/candidates/place.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import type { AgencyActor } from '@easemuse/core'; +import type { ScopedTx } from '@/lib/db'; +import { ApiError } from '@/lib/http/errors'; +import { readApproved } from '@/server/artifacts/service'; +import { startBatchAnalysis } from '@/server/matching/batch'; +import { addCandidateToPosition } from '@/server/pipeline/service'; +import { requirePosition } from '@/server/positions/service'; +import { getCandidate, type CandidateView } from './service'; + +/** + * Upload for a position, confirm, analyze (G#9; brief 4.1, 4.2, 5.1, 5.2). When the recruiter + * confirms a draft that was uploaded for a position, the same click can add the candidate there and + * ask for the match analysis. Nothing here runs without that click (DP-2), the analysis is a draft + * like any other (DP-3), and no score moves or hides anyone (FR-MAT-03). + */ +export interface Placement { + entryId: string; + /** False when the candidate was already in the position (for example after a merge). */ + added: boolean; + /** The position has a confirmed success profile, so an analysis can run. */ + canAnalyze: boolean; +} + +export type AnalysisRequest = + | { status: 'queued'; jobRunId: string } + | { status: 'not_requested' } + /** The position's success profile is not confirmed yet (FR-INT-11), so there is nothing to compare with. */ + | { status: 'needs_success_profile' } + /** The candidate is in the position under another recruiter, who owns the analysis. */ + | { status: 'not_allowed' } + | { status: 'failed' }; + +/** Most drafts the position page lists at once: a few uploads of 10 (POSITION_INTAKE_MAX_FILES). */ +const DRAFT_LIST_LIMIT = 50; + +export type IntakeDraftState = 'reading' | 'readFailed' | 'drafting' | 'aiFailed' | 'ready'; + +export interface IntakeDraft { + candidateId: string; + filename: string; + createdAt: Date; + state: IntakeDraftState; +} + +/** Where a draft stands on its way to the recruiter's review. */ +export function intakeDraftState(c: CandidateView): IntakeDraftState { + if (c.documents.some((d) => d.extractionStatus === 'pending')) return 'reading'; + if (c.profileArtifactId) return 'ready'; + if (c.documents.some((d) => d.extractionStatus === 'failed')) return 'readFailed'; + if (c.profileAi?.status === 'failed') return 'aiFailed'; + return 'drafting'; +} + +/** + * Drafts uploaded for this position that still wait for the recruiter's review, newest first, so + * the position page can show them after the upload table is gone (G#9). Confirmed ones are in the + * pipeline instead; discarded or merged ones are gone. + */ +export async function listIntakeDrafts( + tx: ScopedTx, + actor: AgencyActor, + positionId: string, +): Promise { + await requirePosition(tx, actor, positionId, 'read'); + const rows = await tx.candidate.findMany({ + where: { intakePositionId: positionId, status: 'draft', mergedIntoId: null }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: DRAFT_LIST_LIMIT, + select: { id: true }, + }); + const drafts: IntakeDraft[] = []; + for (const row of rows) { + const c = await getCandidate(tx, row.id); + drafts.push({ + candidateId: c.id, + filename: c.documents.at(-1)?.filename ?? c.fullName, + createdAt: c.createdAt, + state: intakeDraftState(c), + }); + } + return drafts; +} + +/** Adds the confirmed candidate to the position, or finds the entry that is already there. */ +export async function placeConfirmedCandidate( + tx: ScopedTx, + actor: AgencyActor, + candidateId: string, + positionId: string, +): Promise { + const position = await requirePosition(tx, actor, positionId, 'read'); + if (position.status === 'filled' || position.status === 'cancelled') { + // Its own code, so the review page can say so instead of "no profile to confirm" (also a 409). + throw new ApiError('position_closed', 'This position is closed', { status: 409 }); + } + const existing = await tx.pipelineEntry.findUnique({ + where: { positionId_candidateId: { positionId, candidateId } }, + select: { id: true }, + }); + const entryId = existing + ? existing.id + : (await addCandidateToPosition(tx, actor, positionId, { candidateId })).id; + const successProfile = await readApproved(tx, { + kind: 'success_profile', + subject: { type: 'position', id: positionId }, + }); + return { entryId, added: !existing, canAnalyze: Boolean(successProfile) }; +} + +/** + * Queues the analysis of one placed candidate. Runs after the confirmation is saved: the candidate + * stays confirmed and in the position even when the analysis cannot start, and the answer says why. + */ +export async function requestPlacementAnalysis( + actor: AgencyActor, + positionId: string, + placement: Placement, +): Promise { + if (!placement.canAnalyze) return { status: 'needs_success_profile' }; + try { + const { jobRunId } = await startBatchAnalysis(actor, positionId, { + entryIds: [placement.entryId], + }); + return { status: 'queued', jobRunId }; + } catch (error) { + if (error instanceof ApiError && error.status === 403) return { status: 'not_allowed' }; + return { status: 'failed' }; + } +} diff --git a/apps/web/src/server/candidates/service.ts b/apps/web/src/server/candidates/service.ts index 30d88d6..5c3ec51 100644 --- a/apps/web/src/server/candidates/service.ts +++ b/apps/web/src/server/candidates/service.ts @@ -37,6 +37,11 @@ export interface CandidateView { clientName: string; state: string; }[]; + /** + * The position the resume was uploaded for (G#9), while it still takes candidates. The review page + * offers to add the confirmed candidate there and request the match analysis. + */ + intakePosition: { id: string; title: string } | null; /** The newest profile artifact (draft or approved), if the AI has produced one. */ profileArtifactId: string | null; approvedProfileVersionId: string | null; @@ -63,7 +68,7 @@ export interface CandidateView { export async function getCandidate(tx: ScopedTx, candidateId: string): Promise { const row = await tx.candidate.findUnique({ where: { id: candidateId } }); if (!row || row.status === 'discarded') throw notFound('Candidate'); - const [documents, artifact, entries, attempt] = await Promise.all([ + const [documents, artifact, entries, attempt, intakePosition] = await Promise.all([ tx.candidateDocument.findMany({ where: { candidateId }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], @@ -89,6 +94,12 @@ export async function getCandidate(tx: ScopedTx, candidateId: string): Promise undefined)) .optional(), + positionId: z + .guid() + .or(z.literal('').transform(() => undefined)) + .optional(), }); export type CandidateIntake = z.infer; @@ -41,6 +52,9 @@ export type DuplicateResolution = (typeof DUPLICATE_RESOLUTIONS)[number]; * `POST /candidates`: confirm a draft (US-202, US-203). Without a resolution the draft is saved only * if no duplicate was found. `create` anyway needs a reason when duplicates exist; `merge` and `link` * name the existing candidate. + * + * `positionId` (G#9) also adds the confirmed candidate to that position, and `analyze` then asks for + * the match analysis: the recruiter's click on "Confirm" is the request (brief 1.1, DP-2). */ export const confirmCandidateInputSchema = z .object({ @@ -48,6 +62,8 @@ export const confirmCandidateInputSchema = z resolution: z.enum(DUPLICATE_RESOLUTIONS).optional(), targetId: z.guid().optional(), reason: z.string().trim().min(3).max(500).optional(), + positionId: z.guid().optional(), + analyze: z.boolean().optional(), }) .refine( (v) => (v.resolution === 'merge' || v.resolution === 'link' ? Boolean(v.targetId) : true), @@ -55,7 +71,11 @@ export const confirmCandidateInputSchema = z message: 'Choose the existing candidate', path: ['targetId'], }, - ); + ) + .refine((v) => !v.analyze || Boolean(v.positionId), { + message: 'Choose the position to analyze against', + path: ['positionId'], + }); export type ConfirmCandidateInput = z.infer; /** `PATCH /candidates/:id/do-not-contact`: setting or clearing the flag always needs a reason. */