195 lines
6.9 KiB
JavaScript
195 lines
6.9 KiB
JavaScript
/* ============================================================
|
|
pipeline.js — the kanban board's endpoints (backend/job/app.py).
|
|
|
|
The board is one read: GET /pipeline/candidates/fetch returns inbox +
|
|
manual_upload cards plus per-status counts. Dropping a card fires
|
|
PATCH /candidate/stage with `inbox_id` or `manual_upload_id`.
|
|
|
|
Inbox stage lives on inbox_messages.application_status; manual stage lives
|
|
on manual_upload_candidate.status (same Candidate_application_Status values).
|
|
History is application_stage_transitions in both cases.
|
|
============================================================ */
|
|
|
|
import { request } from '../lib/apiClient'
|
|
|
|
/**
|
|
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
|
*
|
|
* The enum has 11 values and the board 7 columns, so this is deliberately
|
|
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
|
|
* and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and
|
|
* APPROVED is the pre-HIRED spelling of a hire.
|
|
*
|
|
* Anything unmapped falls through to Applied rather than vanishing from the
|
|
* board — a card with no column is a candidate nobody sees.
|
|
*/
|
|
export const STAGE_FROM_STATUS = {
|
|
PENDING: 'Applied',
|
|
CLOSED: 'Applied',
|
|
PROCESS: 'Screening',
|
|
ONHOLD: 'Screening',
|
|
SCREENING: 'Screening',
|
|
ASSESSMENT: 'Assessment',
|
|
INTERVIEW: 'Interview',
|
|
OFFER: 'Offer',
|
|
HIRED: 'Hired',
|
|
APPROVED: 'Hired',
|
|
REJECTED: 'Rejected',
|
|
}
|
|
|
|
/**
|
|
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
|
|
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
|
|
* never written, so the vocabulary converges on the canonical value as cards get
|
|
* moved. Applied writes PENDING because the enum has no APPLIED member.
|
|
*/
|
|
export const STATUS_FROM_STAGE = {
|
|
Applied: 'PENDING',
|
|
Screening: 'SCREENING',
|
|
Assessment: 'ASSESSMENT',
|
|
Interview: 'INTERVIEW',
|
|
Offer: 'OFFER',
|
|
Hired: 'HIRED',
|
|
Rejected: 'REJECTED',
|
|
}
|
|
|
|
/**
|
|
* Move one application to another stage. Requires pipeline.edit.
|
|
*
|
|
* Inbox cards send `inboxId` (INTEGER inbox.id). Manual-upload cards send
|
|
* `manualUploadId` (manual_upload_candidate.id UUID). The server 400s if both
|
|
* or neither are present, and 400s a no-op move ("already at stage").
|
|
*/
|
|
export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) {
|
|
return request('/candidate/stage', {
|
|
method: 'PATCH',
|
|
body: {
|
|
...(inboxId != null ? { inbox_id: inboxId } : {}),
|
|
...(manualUploadId != null ? { manual_upload_id: manualUploadId } : {}),
|
|
to_stage: toStage,
|
|
change_reason: changeReason ?? null,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Inbox + manual-upload applications for the board — GET /pipeline/candidates/fetch
|
|
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
|
|
* `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter.
|
|
*/
|
|
export function listApplications({ jobId, limit, offset } = {}) {
|
|
return request('/pipeline/candidates/fetch', {
|
|
params: { job_post_id: jobId, limit, offset },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
|
|
* land in Applied, same as STAGE_FROM_STATUS's card fallback.
|
|
*/
|
|
export function toStageCounts(byStatus) {
|
|
const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0]))
|
|
for (const [status, n] of Object.entries(byStatus || {})) {
|
|
const stage = STAGE_FROM_STATUS[status] ?? 'Applied'
|
|
counts[stage] = (counts[stage] ?? 0) + (n || 0)
|
|
}
|
|
return counts
|
|
}
|
|
|
|
/**
|
|
* Stage history for one application — GET /pipeline/transitions/fetch
|
|
* (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage
|
|
* the candidate is in now. The board itself does not render history; this is the
|
|
* feed behind a stage timeline on the profile.
|
|
*
|
|
* One of inboxId / manualUploadId / transitionId is required — the route 400s
|
|
* with none of them.
|
|
*/
|
|
export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) {
|
|
return request('/pipeline/transitions/fetch', {
|
|
params: { inbox_id: inboxId, manual_upload_id: manualUploadId, transition_id: transitionId },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* One candidate's CURRENT ATS score — GET /pipeline/candidate/score/fetch
|
|
* (pipeline.view). Envelope is `{ data: { manual, inbox }, total, status_code }`,
|
|
* where each side is `{overall_score, band, job_post_id, computed_at,
|
|
* candidate_id, user_id}` or null.
|
|
*
|
|
* Score only. It carries no matched/missing keywords and no critique — those
|
|
* live on the scored-candidate row (/candidate/scored/fetch).
|
|
*
|
|
* `jobPostId` pins the score to one application; omitted (as the talent pool
|
|
* does, where a candidate need not have an assigned post) the newest current
|
|
* score across the candidate's applications wins.
|
|
*/
|
|
export function fetchCandidateScore({ userId, jobPostId } = {}) {
|
|
return request('/pipeline/candidate/score/fetch', {
|
|
params: { user_id: userId, job_post_id: jobPostId },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The two-sided envelope -> one score row, or null.
|
|
*
|
|
* A candidate is reached either through the inbox (they mailed us) or through
|
|
* Add Candidate (manual_upload); both sides resolve the same ats_results table,
|
|
* so whichever side answered is the score. Inbox wins a tie because an emailed
|
|
* application is the one the pipeline board is showing.
|
|
*/
|
|
export function toAtsScore(res) {
|
|
const data = res?.data ?? {}
|
|
return data.inbox ?? data.manual ?? null
|
|
}
|
|
|
|
function sourceFields(row, kind) {
|
|
if (kind === 'manual') {
|
|
return {
|
|
id: `manual:${row.id}`,
|
|
inboxId: null,
|
|
manualUploadId: row.id,
|
|
jobId: row.job_post_id ?? null,
|
|
jobTitle: row.title ?? null,
|
|
currentTitle: row.current_position || null,
|
|
currentCompany: row.current_company || null,
|
|
}
|
|
}
|
|
return {
|
|
id: row.inbox_id,
|
|
inboxId: row.inbox_id,
|
|
manualUploadId: null,
|
|
jobId: row.assigned_job_post_id ?? null,
|
|
jobTitle: row.title ?? null,
|
|
currentTitle: row.current_title || null,
|
|
currentCompany: row.current_employment || null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pipeline inbox or manual-upload row -> one kanban card.
|
|
*
|
|
* Inbox `id` is the inbox id, not the user id: the board is one card per
|
|
* APPLICATION. `userId` rides along for the deep link into the profile.
|
|
*/
|
|
export function toBoardCard(row, kind = 'inbox') {
|
|
const src = sourceFields(row, kind)
|
|
return {
|
|
...src,
|
|
userId: row.user_id ?? null,
|
|
name: row.name || row.email || 'Unknown',
|
|
email: row.email ?? null,
|
|
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
|
|
status: row.application_status ?? null,
|
|
experience: row.experience || null,
|
|
aiScore: row.ats_result?.overall_score ?? null,
|
|
recommendation: row.ats_result?.band ?? null,
|
|
applied: row.created_at ? new Date(row.created_at) : null,
|
|
}
|
|
}
|
|
|
|
/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */
|
|
export function toManualBoardCard(row) {
|
|
return toBoardCard(row, 'manual')
|
|
}
|