Skip to content
Merged
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
13 changes: 12 additions & 1 deletion apps/web/messages/en/candidates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions apps/web/messages/en/pipeline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}…",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 12 additions & 7 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ model Position {
intake IntakeRecord?
pipeline PipelineEntry[]
transitions PositionTransition[]
intakeDrafts Candidate[]

@@index([agencyId, status])
@@index([agencyId, clientId])
Expand Down Expand Up @@ -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])
Expand All @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/app/api/permission-matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) });
Expand Down
29 changes: 25 additions & 4 deletions apps/web/src/app/api/v1/agencies/[agencyId]/candidates/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 });
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Response> {
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,
}),
);
});
}
63 changes: 60 additions & 3 deletions apps/web/src/components/candidates/candidate-review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<AnalysisRequest['status'], string>;

/** Review of a new candidate (US-202, US-203): read status, the AI profile draft, duplicates, and the decision. */
export function CandidateReview({
agencyId,
Expand All @@ -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();
Expand All @@ -54,6 +71,9 @@ export function CandidateReview({
const [choice, setChoice] = useState<Choice>('none');
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(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],
Expand Down Expand Up @@ -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<ConfirmAnswer>(`${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'));
Expand Down Expand Up @@ -330,6 +365,28 @@ export function CandidateReview({
onChange={(e) => setReason(e.target.value)}
/>
) : null}
{intakePosition ? (
<fieldset className="flex flex-col gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={addToPosition}
onChange={(e) => setAddToPosition(e.target.checked)}
/>
{placement('add', { title: intakePosition.title })}
</label>
<label className="flex items-center gap-2 pl-6">
<input
type="checkbox"
checked={addToPosition && analyze}
disabled={!addToPosition}
onChange={(e) => setAnalyze(e.target.checked)}
/>
{placement('analyze')}
</label>
<p className="pl-6 text-muted">{placement('analyzeHint')}</p>
</fieldset>
) : null}
{error ? (
<p role="alert" className="text-sm text-danger">
{error}
Expand Down
Loading
Loading