diff --git a/apps/web/messages/en/positions.json b/apps/web/messages/en/positions.json index 4f683cc..820668c 100644 --- a/apps/web/messages/en/positions.json +++ b/apps/web/messages/en/positions.json @@ -59,7 +59,7 @@ }, "detail": { "edit": "Edit", - "ownerLine": "Owner: {name}", + "clientOwnerLine": "{client} · Owner: {name}", "fields": { "department": "Department", "reportingLine": "Reports to", @@ -93,7 +93,80 @@ "poolLink": "Find in my pool", "benchmarkLink": "Market benchmark", "boardLink": "Candidates in this position", - "historyLink": "What has this client liked before?" + "historyLink": "What has this client liked before?", + "experienceChip": "{value} experience", + "openingsChip": "{count, plural, one {# opening} other {# openings}}", + "process": { + "label": "Hiring process", + "stepTitle": "{n}. {name}", + "stepDetail": "{state} · {detail}", + "steps": { + "jd": "Job description", + "profile": "Success profile", + "sourcing": "Sourcing", + "matching": "Matching", + "outreach": "Outreach", + "submission": "Submission" + }, + "states": { + "done": "Done", + "current": "Now", + "waiting": "Not started" + }, + "counts": { + "sourcing": "{count} in pipeline", + "matching": "{count} analyzed", + "outreach": "{count} contacted", + "submission": "{count} submitted" + }, + "jdStates": { + "none": "Not added", + "in_progress": "Waiting for approval", + "approved": "Approved" + }, + "profileStates": { + "none": "Not written", + "draft": "Draft, not confirmed", + "confirmed": "Confirmed" + } + }, + "next": { + "label": "Next:", + "addJd": "Add the client's job description below.", + "approveJd": "Structure the job description and get the hiring manager's approval.", + "confirmProfile": "Write and confirm the success profile. Matching compares candidates with it.", + "reviewDrafts": "{count, plural, one {# uploaded resume waits} other {# uploaded resumes wait}} for your review.", + "addCandidates": "Add candidates: upload resumes or LinkedIn PDFs, or find them in your pool.", + "analyze": "{count, plural, one {# candidate has} other {# candidates have}} no match analysis yet.", + "outreach": "Contact the candidates you want to approach.", + "board": "Keep the candidates moving on the candidate board.", + "open": "Open" + }, + "numbers": { + "label": "Candidates", + "pipeline": "In pipeline", + "analyzed": "Analyzed", + "interested": "Interested", + "submitted": "Submitted" + }, + "groups": { + "label": "Work on this position", + "candidates": "Candidates", + "candidatesHint": "Review board and resume uploads", + "find": "Find candidates", + "findHint": "Search criteria and your pool", + "prepare": "Prepare the search", + "prepareHint": "JD, intake questions and success profile", + "insights": "Insights", + "insightsHint": "Market benchmark and client history" + }, + "sections": { + "jd": "Job description and intake", + "details": "Position details", + "weights": "Matching weights", + "status": "Change status", + "activity": "Activity" + } }, "errors": { "generic": "Could not save. Check the values and try again.", diff --git a/apps/web/src/app/api/permission-matrix.test.ts b/apps/web/src/app/api/permission-matrix.test.ts index 0274f09..2f5eab6 100644 --- a/apps/web/src/app/api/permission-matrix.test.ts +++ b/apps/web/src/app/api/permission-matrix.test.ts @@ -247,6 +247,9 @@ vi.mock('@/server/candidates/duplicates', () => ({ vi.mock('@/server/candidates/confirm', () => ({ confirmCandidate: async () => ({ candidateId: 'c1', resolution: 'create', mergedIntoId: null }), })); +vi.mock('@/server/positions/progress', () => ({ + getPositionProgress: async () => ({ jd: 'none', successProfile: 'none', pendingDrafts: 0 }), +})); vi.mock('@/server/candidates/place', () => ({ listIntakeDrafts: async () => [], placeConfirmedCandidate: async () => ({ entryId: 'e1', added: true, canAnalyze: true }), @@ -616,6 +619,7 @@ import { GET as pipelineGet } from './v1/agencies/[agencyId]/positions/[position 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 { GET as progressGet } from './v1/agencies/[agencyId]/positions/[positionId]/progress/route'; import { PUT as orderPut, DELETE as orderDelete, @@ -1434,6 +1438,13 @@ const endpoints: Endpoint[] = [ allowed: AGENCY_ALL, call: () => intakeDraftsGet(new Request('http://x.test/'), agencyParams), }, + { + name: 'GET position progress', + file: 'v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts', + audience: 'agency', + allowed: AGENCY_ALL, + call: () => progressGet(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]/positions/[positionId]/progress/route.ts b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts new file mode 100644 index 0000000..1ce0fc5 --- /dev/null +++ b/apps/web/src/app/api/v1/agencies/[agencyId]/positions/[positionId]/progress/route.ts @@ -0,0 +1,28 @@ +// 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 { getPositionProgress } from '@/server/positions/progress'; + +export const dynamic = 'force-dynamic'; + +/** Where the position stands in the hiring process (G#10). 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) => getPositionProgress(tx, authed.actor, positionId), + { userId: authed.userId }, + ), + ); + }); +} diff --git a/apps/web/src/components/positions/position-detail.tsx b/apps/web/src/components/positions/position-detail.tsx index a30a0cb..e3250db 100644 --- a/apps/web/src/components/positions/position-detail.tsx +++ b/apps/web/src/components/positions/position-detail.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { POSITION_TRANSITIONS, REASON_REQUIRED, @@ -18,14 +18,36 @@ import { EntityTimeline } from '@/components/activity/entity-timeline'; import { Button } from '@/components/ui/button'; import { SelectField, TextField } from '@/components/ui/field'; import { ApiClientError, apiFetch } from '@/lib/api-client'; +import type { PositionProgress } from '@/server/positions/progress'; import type { PositionView } from '@/server/positions/service'; import { JdPanel } from './jd-panel'; import { PositionForm } from './position-form'; import { PortalSharing } from '@/components/pipeline/portal-sharing'; import { IntakePanel } from './intake-panel'; +import { nextAction, processSteps, type ProcessStep, type StepState } from './position-progress'; import { WeightsEditor } from './weights-editor'; -/** The position page shell (T-1-04): details, edit, status control and timeline. JD, intake and pipeline attach here later. */ +const STATUS_BADGE = + 'rounded-md border border-primary-strong px-2 py-0.5 text-sm text-primary-strong'; +const STEP_CLASS: Record = { + done: 'flex flex-col border-t-4 border-primary-strong pt-2 text-sm', + current: 'flex flex-col border-t-4 border-primary pt-2 text-sm', + waiting: 'flex flex-col border-t-4 border-border pt-2 text-sm text-muted', +}; +const NEXT_CARD = + 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-primary-strong bg-surface p-4'; + +/** A card of links on the position page: [label, href] pairs. */ +interface LinkGroup { + key: 'prepare' | 'candidates' | 'find' | 'insights'; + links: [string, string][]; +} + +/** + * The position page (T-1-04, reorganized for G#10): the header with the key facts, where the search + * stands in the hiring process and what to do next, links grouped by task, and the details, + * JD and intake, weights, status and activity folded below. + */ export function PositionDetail({ agencyId, agencySlug, @@ -88,17 +110,67 @@ export function PositionDetail({ [d('fields.workAuthorization'), position.workAuthorization ?? none], ]; + const base = `/a/${agencySlug}/positions/${positionId}`; + const place = position.workArrangement + ? `${position.location} · ${arrangementLabel(position.workArrangement, t)}` + : position.location; + const experience = position.requiredExperience + ? d('experienceChip', { value: position.requiredExperience }) + : null; + const chips = [ + place, + compensation === none ? null : compensation, + experience, + d('openingsChip', { count: position.openings }), + ].filter((c): c is string => Boolean(c)); + // Links grouped by what the recruiter is doing; editing pages only for those who may edit. + const link = (label: string, page: string): [string, string] => [label, `${base}/${page}`]; + const editOnly = (links: [string, string][]) => (canEdit ? links : []); + const groups: LinkGroup[] = [ + { + key: 'prepare', + links: editOnly([ + link(d('structureLink'), 'structure'), + link(d('questionsLink'), 'questions'), + link(d('profileLink'), 'profile'), + ]), + }, + { key: 'candidates', links: [link(d('boardLink'), 'candidates')] }, + { + key: 'find', + links: editOnly([link(d('searchLink'), 'search'), link(d('poolLink'), 'pool')]), + }, + { + key: 'insights', + links: [ + ...editOnly([link(d('benchmarkLink'), 'benchmark')]), + link(d('historyLink'), 'client-history'), + ], + }, + ]; + const visibleGroups = groups.filter((g) => g.links.length > 0); + return (
-
-

{position.title}

-

- {position.clientName} · {t(`status.${position.status}`)} -

+
+
+

{position.title}

+ {t(`status.${position.status}`)} +

- {d('ownerLine', { name: position.ownerName ?? t('unknownOwner') })} + {d('clientOwnerLine', { + client: position.clientName, + name: position.ownerName ?? t('unknownOwner'), + })}

+
    + {chips.map((chip) => ( +
  • + {chip} +
  • + ))} +
{editing || !canEdit ? null : (
); } +/** A folded section of the position page: the summary stays visible, the content opens on demand. */ +function Fold({ title, id, children }: { title: string; id?: string; children: ReactNode }) { + return ( +
+ {title} +
{children}
+
+ ); +} + +/** + * The hiring process at a glance (G#10): six steps from the brief's workflow, one suggested next + * action and the candidate numbers. Read-only; every action is a link the recruiter chooses to follow. + */ +function ProcessOverview({ + agencyId, + positionId, + base, +}: { + agencyId: string; + positionId: string; + base: string; +}) { + const d = useTranslations('positions.detail'); + const p = useTranslations('positions.detail.process'); + const path = `/api/v1/agencies/${agencyId}/positions/${positionId}/progress`; + const progress = useQuery({ + queryKey: ['position-progress', agencyId, positionId], + queryFn: ({ signal }) => apiFetch(path, { signal }), + }); + const data = progress.data?.data; + if (!data) return null; + + const next = nextAction(data); + const detail = (step: ProcessStep): string => { + if (step === 'jd') return p(`jdStates.${data.jd}`); + if (step === 'profile') return p(`profileStates.${data.successProfile}`); + const count = { + sourcing: data.counts.pipeline, + matching: data.counts.analyzed, + outreach: data.counts.contacted, + submission: data.counts.submitted, + }[step]; + return p(`counts.${step}`, { count }); + }; + const numbers = [ + ['pipeline', data.counts.pipeline], + ['analyzed', data.counts.analyzed], + ['interested', data.counts.interested], + ['submitted', data.counts.submitted], + ] as const; + + return ( +
+

+ {p('label')} +

+
    + {processSteps(data).map(({ step, state }, i) => ( +
  1. + + {p('stepTitle', { n: i + 1, name: p(`steps.${step}`) })} + + + {p('stepDetail', { state: p(`states.${state}`), detail: detail(step) })} + +
  2. + ))} +
+ +
+

+ {d('next.label')} + {d(`next.${next.key}`, { count: next.count })} +

+ + {d('next.open')} + +
+ +
+ {numbers.map(([key, value]) => ( +
+
{d(`numbers.${key}`)}
+
{value}
+
+ ))} +
+
+ ); +} + function arrangementLabel(value: string, t: ReturnType): string { return t.has(`form.arrangement.${value}`) ? t(`form.arrangement.${value}`) : value; } diff --git a/apps/web/src/components/positions/position-progress.test.ts b/apps/web/src/components/positions/position-progress.test.ts new file mode 100644 index 0000000..753427c --- /dev/null +++ b/apps/web/src/components/positions/position-progress.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import type { PositionProgress } from '@/server/positions/progress'; +import { nextAction, processSteps } from './position-progress'; + +const progress = ( + over: Partial> = {}, + counts: Partial = {}, +): PositionProgress => ({ + jd: 'approved', + successProfile: 'confirmed', + pendingDrafts: 0, + ...over, + counts: { + pipeline: 0, + analyzed: 0, + toAnalyze: 0, + contacted: 0, + interested: 0, + submitted: 0, + ...counts, + }, +}); +const states = (p: PositionProgress) => processSteps(p).map((s) => s.state); + +describe('processSteps (G#10)', () => { + it('starts at the job description', () => { + expect(states(progress({ jd: 'none', successProfile: 'none' }))).toEqual([ + 'current', + 'waiting', + 'waiting', + 'waiting', + 'waiting', + 'waiting', + ]); + }); + + it('marks the first step that is not done as current', () => { + expect(states(progress({}, { pipeline: 5, analyzed: 3 }))).toEqual([ + 'done', + 'done', + 'done', + 'done', + 'current', + 'waiting', + ]); + }); + + it('keeps later progress visible even when an earlier step is open', () => { + expect(states(progress({ successProfile: 'draft' }, { pipeline: 2 }))).toEqual([ + 'done', + 'current', + 'done', + 'waiting', + 'waiting', + 'waiting', + ]); + }); +}); + +describe('nextAction', () => { + it('follows the order of the search', () => { + expect(nextAction(progress({ jd: 'none' })).key).toBe('addJd'); + expect(nextAction(progress({ jd: 'in_progress' }))).toEqual({ + key: 'approveJd', + count: 0, + page: 'structure', + }); + expect(nextAction(progress({ successProfile: 'draft' })).page).toBe('profile'); + expect(nextAction(progress({ pendingDrafts: 3 }))).toEqual({ + key: 'reviewDrafts', + count: 3, + page: 'candidates', + }); + expect(nextAction(progress()).key).toBe('addCandidates'); + expect(nextAction(progress({}, { pipeline: 5, analyzed: 2, toAnalyze: 3 }))).toEqual({ + key: 'analyze', + count: 3, + page: 'candidates', + }); + expect(nextAction(progress({}, { pipeline: 5, analyzed: 5 })).key).toBe('outreach'); + // Candidates who are out of the search (a final state) are never suggested for analysis. + expect(nextAction(progress({}, { pipeline: 5, analyzed: 2, toAnalyze: 0 })).key).toBe( + 'outreach', + ); + expect(nextAction(progress({}, { pipeline: 5, analyzed: 5, contacted: 1 })).key).toBe('board'); + }); +}); diff --git a/apps/web/src/components/positions/position-progress.ts b/apps/web/src/components/positions/position-progress.ts new file mode 100644 index 0000000..833629b --- /dev/null +++ b/apps/web/src/components/positions/position-progress.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import type { PositionProgress } from '@/server/positions/progress'; + +/** The six steps of a search on the position page, in the brief's order (section 2). */ +export const PROCESS_STEPS = [ + 'jd', + 'profile', + 'sourcing', + 'matching', + 'outreach', + 'submission', +] as const; +export type ProcessStep = (typeof PROCESS_STEPS)[number]; +export type StepState = 'done' | 'current' | 'waiting'; + +function isDone(step: ProcessStep, p: PositionProgress): boolean { + switch (step) { + case 'jd': + return p.jd === 'approved'; + case 'profile': + return p.successProfile === 'confirmed'; + case 'sourcing': + return p.counts.pipeline > 0; + case 'matching': + return p.counts.analyzed > 0; + case 'outreach': + return p.counts.contacted > 0; + case 'submission': + return p.counts.submitted > 0; + } +} + +/** Each step done, current (the first one not done) or waiting. */ +export function processSteps(p: PositionProgress): { step: ProcessStep; state: StepState }[] { + const firstOpen = PROCESS_STEPS.findIndex((s) => !isDone(s, p)); + return PROCESS_STEPS.map((step, i) => ({ + step, + state: isDone(step, p) ? 'done' : i === firstOpen ? 'current' : 'waiting', + })); +} + +export type NextActionKey = + | 'addJd' + | 'approveJd' + | 'confirmProfile' + | 'reviewDrafts' + | 'addCandidates' + | 'analyze' + | 'outreach' + | 'board'; + +/** + * The one suggestion for the "next" card, worked out by fixed rules from the counts: a pointer for + * the recruiter, never something that runs by itself (brief 1.1). `page` is the position's sub-page + * to open, or null for a section of the position page itself. + */ +export function nextAction(p: PositionProgress): { + key: NextActionKey; + count: number; + page: 'structure' | 'profile' | 'candidates' | null; +} { + const { counts } = p; + if (p.jd === 'none') return { key: 'addJd', count: 0, page: null }; + if (p.jd === 'in_progress') return { key: 'approveJd', count: 0, page: 'structure' }; + if (p.successProfile !== 'confirmed') return { key: 'confirmProfile', count: 0, page: 'profile' }; + if (p.pendingDrafts > 0) { + return { key: 'reviewDrafts', count: p.pendingDrafts, page: 'candidates' }; + } + if (counts.pipeline === 0) return { key: 'addCandidates', count: 0, page: 'candidates' }; + if (counts.toAnalyze > 0) { + return { key: 'analyze', count: counts.toAnalyze, page: 'candidates' }; + } + if (counts.contacted === 0) return { key: 'outreach', count: 0, page: 'candidates' }; + return { key: 'board', count: 0, page: 'candidates' }; +} diff --git a/apps/web/src/components/positions/positions.test.tsx b/apps/web/src/components/positions/positions.test.tsx index 3081c24..3dcee3c 100644 --- a/apps/web/src/components/positions/positions.test.tsx +++ b/apps/web/src/components/positions/positions.test.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2026 Team of Wakers // SPDX-License-Identifier: MIT -import { screen, waitFor } from '@testing-library/react'; +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, stubApi } from '@/test/render'; @@ -189,19 +189,28 @@ describe('PositionDetail', () => { [`GET ${api}/activity`]: () => json({ data: [], nextCursor: null }), [`GET ${api}/positions/p1/jd`]: () => json({ data: [] }), [`GET ${api}/positions/p1/intake`]: () => json({ data: { answers: {}, jdApproval: null } }), + [`GET ${api}/positions/p1/progress`]: () => json({ data: progress() }), ...extra, }); vi.stubGlobal('fetch', stub.impl); return stub; }; + const progress = (over: Record = {}) => ({ + jd: 'approved', + successProfile: 'confirmed', + pendingDrafts: 0, + counts: { pipeline: 6, analyzed: 4, toAnalyze: 2, contacted: 0, interested: 0, submitted: 0 }, + ...over, + }); it('shows the details, owner and only the status moves that are allowed', async () => { serve(); renderApp(); expect(await screen.findByRole('heading', { name: 'Staff Engineer' })).toBeTruthy(); - expect(screen.getByText('90000 – 120000 USD')).toBeTruthy(); - expect(screen.getByText('Go, SQL')).toBeTruthy(); - expect(screen.getByText('Hybrid')).toBeTruthy(); + const details = screen.getByText('Position details').closest('details')!; + expect(within(details).getByText('90000 – 120000 USD')).toBeTruthy(); + expect(within(details).getByText('Go, SQL')).toBeTruthy(); + expect(within(details).getByText('Hybrid')).toBeTruthy(); const options = Array.from((screen.getByLabelText('Move to') as HTMLSelectElement).options).map( (o) => o.textContent, ); @@ -272,6 +281,61 @@ describe('PositionDetail', () => { expect(screen.queryByRole('button', { name: 'Go ahead anyway' })).toBeNull(); }); + it('shows the key facts, where the search stands and the next step (G#10)', async () => { + serve(); + renderApp(); + const owner = await screen.findByText('Northwind · Owner: Pat Owner'); + const header = owner.closest('header')!; + expect(within(header).getByText('Seoul · Hybrid')).toBeTruthy(); + expect(within(header).getByText('90000 – 120000 USD')).toBeTruthy(); + expect(within(header).getByText('2 openings')).toBeTruthy(); + + const current = await screen.findByText('5. Outreach'); + expect(current.closest('li')!.getAttribute('aria-current')).toBe('step'); + expect(screen.getByText('Done · 4 analyzed')).toBeTruthy(); + expect(screen.getByText('2 candidates have no match analysis yet.')).toBeTruthy(); + const open = screen.getByRole('link', { name: 'Open' }); + expect(open.getAttribute('href')).toBe('/a/acme/positions/p1/candidates'); + const pipeline = screen.getByText('In pipeline').closest('div')!; + expect(within(pipeline).getByText('6')).toBeTruthy(); + }); + + it('points to the success profile until it is confirmed', async () => { + serve({ + [`GET ${api}/positions/p1/progress`]: () => + json({ data: progress({ successProfile: 'draft' }) }), + }); + renderApp(); + expect( + await screen.findByText( + 'Write and confirm the success profile. Matching compares candidates with it.', + ), + ).toBeTruthy(); + const open = screen.getByRole('link', { name: 'Open' }); + expect(open.getAttribute('href')).toBe('/a/acme/positions/p1/profile'); + const step = screen.getByText('2. Success profile').closest('li')!; + expect(step.getAttribute('aria-current')).toBe('step'); + }); + + it('groups the links and hides the editing pages from someone who cannot edit', async () => { + serve(); + renderApp( + , + ); + const nav = await screen.findByRole('navigation', { name: 'Work on this position' }); + const board = within(nav).getByRole('link', { name: 'Candidates in this position' }); + expect(board.getAttribute('href')).toBe('/a/acme/positions/p1/candidates'); + expect(within(nav).queryByRole('link', { name: 'Structure the JD' })).toBeNull(); + expect(within(nav).queryByRole('link', { name: 'Market benchmark' })).toBeNull(); + const history = within(nav).getByRole('link', { name: 'What has this client liked before?' }); + expect(history.getAttribute('href')).toBe('/a/acme/positions/p1/client-history'); + }); + it('lets a warn guard be overridden with a reason, which is sent with the request', async () => { let attempt = 0; const stub = serve( diff --git a/apps/web/src/server/positions/progress.db.test.ts b/apps/web/src/server/positions/progress.db.test.ts new file mode 100644 index 0000000..fd3f7f1 --- /dev/null +++ b/apps/web/src/server/positions/progress.db.test.ts @@ -0,0 +1,173 @@ +// 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, PipelineState } 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 { addCandidateToPosition } from '@/server/pipeline/service'; +import { + adminClient, + appHandle, + appScope, + cleanup, + createAgency, + createUser, + dbAvailable, +} from '@/test/db'; +import { getPositionProgress } from './progress'; +import { createPosition } from './service'; + +describe.skipIf(!dbAvailable())('position progress (G#10)', () => { + 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 progressOf = (positionId: string) => run((tx) => getPositionProgress(tx, lead, positionId)); + /** A confirmed candidate in the position, moved straight to `state` (the moves are tested elsewhere). */ + const inState = async (positionId: string, state: PipelineState) => { + const candidate = await run((tx) => + tx.candidate.create({ + data: { + agencyId: agencyA, + fullName: `Cand ${randomUUID().slice(0, 6)}`, + source: 'linkedin', + createdById: lead.userId, + status: 'active', + }, + select: { id: true }, + }), + ); + const entry = await run((tx) => + addCandidateToPosition(tx, lead, positionId, { candidateId: candidate.id }), + ); + await run((tx) => tx.pipelineEntry.update({ where: { id: entry.id }, data: { state } })); + }; + const successProfile = (positionId: string, approve: boolean) => + 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, + }); + if (approve) await approveVersion(tx, lead, v.id); + }); + + beforeAll(async () => { + admin = await adminClient(); + handle = appHandle(2); + agencyA = await createAgency(admin, 'Progress A'); + agencyB = await createAgency(admin, 'Progress 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: 'Progress Client' }))).client.id; + }); + afterAll(async () => { + await cleanup(admin, { agencies: [agencyA, agencyB] }); + await admin.end(); + await handle.close(); + }); + + it('starts empty for a new position', async () => { + const positionId = await newPosition(); + expect(await progressOf(positionId)).toEqual({ + jd: 'none', + successProfile: 'none', + pendingDrafts: 0, + counts: { pipeline: 0, analyzed: 0, toAnalyze: 0, contacted: 0, interested: 0, submitted: 0 }, + }); + }); + + it('tells a draft success profile from a confirmed one, and sees a structured JD', async () => { + const draft = await newPosition(); + await successProfile(draft, false); + await run((tx) => + createArtifact(tx, { + agencyId: agencyA, + kind: 'structured_jd', + subject: { type: 'position', id: draft }, + }), + ); + expect(await progressOf(draft)).toMatchObject({ jd: 'in_progress', successProfile: 'draft' }); + const confirmed = await newPosition(); + await successProfile(confirmed, true); + expect((await progressOf(confirmed)).successProfile).toBe('confirmed'); + }); + + it('counts the pipeline by how far each candidate got', async () => { + const positionId = await newPosition(); + for (const state of [ + 'sourced', + 'reviewed', + 'contacting', + 'interested', + 'qualified', + 'submitted', + 'interviewing', + 'rejected', + ] as const) { + await inState(positionId, state); + } + expect((await progressOf(positionId)).counts).toEqual({ + pipeline: 8, + analyzed: 0, + // The rejected candidate is out of the search, so only the other seven wait for an analysis. + toAnalyze: 7, + contacted: 5, + interested: 2, + submitted: 2, + }); + }); + + it('counts resumes uploaded for the position that wait for review', async () => { + const positionId = await newPosition(); + await run((tx) => + tx.candidate.create({ + data: { + agencyId: agencyA, + fullName: 'Draft: cv', + source: 'linkedin', + createdById: lead.userId, + status: 'draft', + intakePositionId: positionId, + }, + }), + ); + expect((await progressOf(positionId)).pendingDrafts).toBe(1); + }); + + it('is not visible from another agency', async () => { + const positionId = await newPosition(); + await expect( + appScope(handle).withAgency(agencyB, (tx) => getPositionProgress(tx, outsider, positionId)), + ).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/apps/web/src/server/positions/progress.ts b/apps/web/src/server/positions/progress.ts new file mode 100644 index 0000000..87bbd48 --- /dev/null +++ b/apps/web/src/server/positions/progress.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Team of Wakers +// SPDX-License-Identifier: MIT +import { TERMINAL_STATES, type AgencyActor, type PipelineState } from '@easemuse/core'; +import type { ScopedTx } from '@/lib/db'; +import { findArtifact, readApproved } from '@/server/artifacts/service'; +import { isJdApproved } from './jd-approval'; +import { requirePosition } from './service'; + +/** + * Where a position stands in the hiring process (G#10; brief 2, 3, 4, 5.3), for the position page's + * process bar and "next" card. Read-only counts and states: nothing here runs AI or moves anyone. + */ +export interface PositionProgress { + /** none: no structured JD yet; in_progress: structured, not yet approved by the hiring manager. */ + jd: 'none' | 'in_progress' | 'approved'; + /** none: not written; draft: written, not confirmed; confirmed: the version matching uses (FR-INT-11). */ + successProfile: 'none' | 'draft' | 'confirmed'; + /** Resumes uploaded for this position that still wait for the recruiter's review (G#9). */ + pendingDrafts: number; + counts: { + pipeline: number; + analyzed: number; + /** People still in play (not in a final state) with no analysis yet: what the "analyze" suggestion counts. */ + toAnalyze: number; + contacted: number; + interested: number; + submitted: number; + }; +} + +/** States reached only after the first contact. Rejections and closures earlier on are not counted. */ +const CONTACTED: readonly PipelineState[] = [ + 'contacting', + 'no_response', + 'not_interested', + 'interested', + 'qualified', + 'resume_ready', + 'candidate_approved', + 'submitted', + 'client_rejected', + 'interviewing', + 'offer', + 'declined', + 'accepted', + 'reneged', + 'started', +]; +const INTERESTED: readonly PipelineState[] = [ + 'interested', + 'qualified', + 'resume_ready', + 'candidate_approved', +]; +const SUBMITTED: readonly PipelineState[] = [ + 'submitted', + 'client_rejected', + 'interviewing', + 'offer', + 'declined', + 'accepted', + 'reneged', + 'started', +]; + +export async function getPositionProgress( + tx: ScopedTx, + actor: AgencyActor, + positionId: string, +): Promise { + await requirePosition(tx, actor, positionId, 'read'); + const subject = { type: 'position' as const, id: positionId }; + const [jdArtifact, jdApproved, profileArtifact, profileApproved, entries, analyzed, drafts] = + await Promise.all([ + findArtifact(tx, { kind: 'structured_jd', subject }), + isJdApproved(tx, positionId), + findArtifact(tx, { kind: 'success_profile', subject }), + readApproved(tx, { kind: 'success_profile', subject }), + tx.pipelineEntry.findMany({ where: { positionId }, select: { id: true, state: true } }), + tx.matchAnalysis.findMany({ + where: { entry: { positionId } }, + distinct: ['entryId'], + select: { entryId: true }, + }), + tx.candidate.count({ + where: { intakePositionId: positionId, status: 'draft', mergedIntoId: null }, + }), + ]); + const analyzedIds = new Set(analyzed.map((a) => a.entryId)); + const toAnalyze = entries.filter( + (e) => !TERMINAL_STATES.includes(e.state as PipelineState) && !analyzedIds.has(e.id), + ).length; + const inStates = (states: readonly PipelineState[]) => + entries.filter((e) => states.includes(e.state as PipelineState)).length; + return { + jd: jdApproved ? 'approved' : jdArtifact ? 'in_progress' : 'none', + successProfile: profileApproved ? 'confirmed' : profileArtifact ? 'draft' : 'none', + pendingDrafts: drafts, + counts: { + pipeline: entries.length, + analyzed: analyzed.length, + toAnalyze, + contacted: inStates(CONTACTED), + interested: inStates(INTERESTED), + submitted: inStates(SUBMITTED), + }, + }; +} diff --git a/docs/planning/api-contracts.md b/docs/planning/api-contracts.md index 7a3f7da..f285432 100644 --- a/docs/planning/api-contracts.md +++ b/docs/planning/api-contracts.md @@ -185,6 +185,8 @@ Also (session D): `GET / POST /positions/:positionId/pool-matches` ("Find in my Also (G#9, upload for a position; brief 4.1, 4.2, 5): `POST /candidates/intake` takes an optional `positionId` (anyone who can see the position; 409 when it is closed) and keeps it on the draft; nothing is added to the position yet. `GET /positions/:positionId/intake-drafts` lists those drafts still waiting for review (`{candidateId, filename, createdAt, state: reading | readFailed | drafting | aiFailed | ready}`). `POST /candidates` takes optional `positionId` and `analyze`: the confirmed candidate is added to the position in the same transaction (an existing entry is reused; 409 `position_closed`), then the match analysis is queued as a batch of one. The answer adds `placement {entryId, added, canAnalyze}` and `analysis {status: queued | not_requested | needs_success_profile | not_allowed | failed, jobRunId?}`; an analysis that cannot start never undoes the confirmation. +Also (G#10, position page): `GET /positions/:positionId/progress` (anyone who can see the position) returns `{jd: none | in_progress | approved, successProfile: none | draft | confirmed, pendingDrafts, counts {pipeline, analyzed, contacted, interested, submitted}}` for the process bar and the "next" card. Read-only; it runs nothing. + ## 13. Reuse and portal extras (session C, 2026-10-07) Agency API, under `/api/v1/agencies/:agencyId`. Every role may call them; the service narrows it further (a recruiter sees the positions the agency's visibility rule shows, and only the entry's owner, a lead or an admin resolves a proposal or asks for a draft). 404 for another agency, 409 for a state conflict.