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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/web/messages/en/candidates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand All @@ -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.",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/messages/en/pipeline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -19,6 +22,7 @@ export default async function AnalysisPage({
agencySlug={access.agency.slug}
positionId={positionId}
entryId={entryId}
autoStart={analyze === '1'}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<PasteProfileFlow
agencyId={access.agency.id}
agencySlug={access.agency.slug}
positionId={positionId}
/>
);
}
35 changes: 35 additions & 0 deletions apps/web/src/app/api/permission-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => [],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 },
),
);
});
}
Original file line number Diff line number Diff line change
@@ -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<Response> {
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' } },
);
});
}
40 changes: 24 additions & 16 deletions apps/web/src/components/candidates/candidate-intake-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CandidateSourceValue>('linkedin');
const [profileUrl, setProfileUrl] = useState('');
const [file, setFile] = useState<File | null>(null);
Expand All @@ -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();
Expand Down Expand Up @@ -86,21 +91,24 @@ export function CandidateIntakeForm({
submit.mutate();
}}
>
<h1 className="text-2xl font-semibold">{t('title')}</h1>
<p className="text-sm text-muted">{t('intro')}</p>
<SelectField
label={t('source')}
value={source}
onChange={(e) => setSource(e.target.value as CandidateSourceValue)}
>
{CANDIDATE_SOURCES.map((s) => (
<option key={s} value={s}>
{sources(s)}
</option>
))}
</SelectField>
<h1 className="text-2xl font-semibold">{positionId ? t('pasteTitle') : t('title')}</h1>
<p className="text-sm text-muted">{positionId ? t('pasteIntro') : t('intro')}</p>
{positionId ? null : (
<SelectField
label={t('source')}
value={source}
onChange={(e) => setSource(e.target.value as CandidateSourceValue)}
>
{CANDIDATE_SOURCES.map((s) => (
<option key={s} value={s}>
{sources(s)}
</option>
))}
</SelectField>
)}
<TextField
label={t('profileUrl')}
hint={positionId ? t('profileUrlNote') : undefined}
type="url"
value={profileUrl}
onChange={(e) => setProfileUrl(e.target.value)}
Expand Down Expand Up @@ -140,7 +148,7 @@ export function CandidateIntakeForm({
) : null}
<div>
<Button type="submit" disabled={submit.isPending}>
{t('submit')}
{positionId ? t('pasteSubmit') : t('submit')}
</Button>
</div>
</form>
Expand Down
Loading
Loading