From 48f4bf0f067e0d45c2fa87a4cba46b6250bf1621 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Thu, 15 Oct 2026 13:42:25 -0700 Subject: [PATCH 1/5] feat(candidates): position-scoped paste or PDF intake for LinkedIn profiles Brief 4.1/4.2. POST positions/[positionId]/candidates/intake takes pasted text or a LinkedIn PDF export, forces source=linkedin, stores the profile link as text only (never fetched) and reuses the existing draft and extraction jobs. The request parsing moves to a shared helper used by both intake routes. Generated with AI assistance Co-Authored-By: Interactor Build --- .../[agencyId]/candidates/intake/route.ts | 43 +---------- .../[positionId]/candidates/intake/route.ts | 45 ++++++++++++ .../server/candidates/intake-request.test.ts | 73 +++++++++++++++++++ .../src/server/candidates/intake-request.ts | 59 +++++++++++++++ 4 files changed, 181 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/intake/route.ts create mode 100644 apps/web/src/server/candidates/intake-request.test.ts create mode 100644 apps/web/src/server/candidates/intake-request.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/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/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', + }, + }; +} From f7844ae459c03b15cfae0c15aed0adccd963bc80 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Thu, 15 Oct 2026 14:25:37 -0700 Subject: [PATCH 2/5] feat(candidates): confirm a pasted profile, add it to a position and plan the analysis in one action Brief 4.2, 5.1. POST positions/[positionId]/candidates/confirm runs the normal confirm (duplicate resolution kept), adds the candidate as sourced and reports whether the analysis can start, in one transaction. It does not call the model; no analysis is offered after a merge or without a confirmed success profile. Generated with AI assistance Co-Authored-By: Interactor Build --- .../web/src/app/api/permission-matrix.test.ts | 35 +++ .../[positionId]/candidates/confirm/route.ts | 35 +++ .../candidates/confirm-and-add.db.test.ts | 214 ++++++++++++++++++ .../src/server/candidates/confirm-and-add.ts | 75 ++++++ apps/web/src/server/pipeline/service.ts | 15 ++ 5 files changed, 374 insertions(+) create mode 100644 apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/candidates/confirm/route.ts create mode 100644 apps/web/src/server/candidates/confirm-and-add.db.test.ts create mode 100644 apps/web/src/server/candidates/confirm-and-add.ts 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]/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/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..2b1ee77 --- /dev/null +++ b/apps/web/src/server/candidates/confirm-and-add.db.test.ts @@ -0,0 +1,214 @@ +// 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'; + +const profile = (over: Record = {}) => ({ + fullName: 'Alex Kim', + email: `alex-${randomUUID()}@x.com`, + phone: null, + currentPosition: { title: 'HR Director', company: 'Northwind' }, + careerHistory: [ + { + company: 'Northwind', + 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({ email: `dup-${randomUUID()}@x.com` }); + 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({ email: `merge-${randomUUID()}@x.com` }); + 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/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, From 2743ec8c8da2cdf3c476fd45184d286550977f1a Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Thu, 15 Oct 2026 14:25:37 -0700 Subject: [PATCH 3/5] feat(web): paste a LinkedIn profile on a position and analyze in one flow Brief 4.1, 5.1. A paste page on the position, the review step with one "Confirm, add to position and analyze" click, and an analysis page that starts the first analysis on arrival. The profile link is stored, never fetched. Generated with AI assistance Co-Authored-By: Interactor Build --- apps/web/messages/en/candidates.json | 14 ++- apps/web/messages/en/pipeline.json | 3 +- .../candidates/[entryId]/page.tsx | 4 + .../[positionId]/candidates/paste/page.tsx | 23 ++++ .../candidates/candidate-intake-form.tsx | 40 ++++--- .../candidates/candidate-review.tsx | 37 ++++++- .../components/candidates/candidates.test.tsx | 102 ++++++++++++++++++ .../pipeline/add-candidate-panel.tsx | 17 ++- .../pipeline/analysis-view.test.tsx | 33 ++++++ .../src/components/pipeline/analysis-view.tsx | 12 ++- .../pipeline/paste-profile-flow.tsx | 34 ++++++ 11 files changed, 291 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/app/(workbench)/a/[agencySlug]/positions/[positionId]/candidates/paste/page.tsx create mode 100644 apps/web/src/components/pipeline/paste-profile-flow.tsx 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/components/candidates/candidate-intake-form.tsx b/apps/web/src/components/candidates/candidate-intake-form.tsx index 849a70c..92ff20f 100644 --- a/apps/web/src/components/candidates/candidate-intake-form.tsx +++ b/apps/web/src/components/candidates/candidate-intake-form.tsx @@ -15,14 +15,17 @@ const MIN_TEXT = 50; /** Add a candidate (US-202): a resume file or pasted profile, where it came from, and an optional link. */ export function CandidateIntakeForm({ agencyId, + positionId, onCreated, }: { agencyId: string; + /** Pasting from a position: the source is always LinkedIn and the candidate is added to it later. */ + positionId?: string; onCreated: (candidateId: string) => void; }) { const t = useTranslations('candidates.new'); const sources = useTranslations('candidates.sources'); - const [mode, setMode] = useState<'file' | 'text'>('file'); + const [mode, setMode] = useState<'file' | 'text'>(positionId ? 'text' : 'file'); const [source, setSource] = useState('linkedin'); const [profileUrl, setProfileUrl] = useState(''); const [file, setFile] = useState(null); @@ -37,7 +40,9 @@ export function CandidateIntakeForm({ }; const submit = useMutation({ mutationFn: async () => { - const path = `/api/v1/agencies/${agencyId}/candidates/intake`; + const path = positionId + ? `/api/v1/agencies/${agencyId}/positions/${positionId}/candidates/intake` + : `/api/v1/agencies/${agencyId}/candidates/intake`; let response: Response; if (mode === 'file') { const form = new FormData(); @@ -86,21 +91,24 @@ export function CandidateIntakeForm({ submit.mutate(); }} > -

{t('title')}

-

{t('intro')}

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

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

+

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

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

{t('title')}

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