228 lines
8.2 KiB
JavaScript
228 lines
8.2 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'
|
|
import { toDate } from '../lib/format'
|
|
|
|
/**
|
|
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
|
*
|
|
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
|
* used to be folded into Hired / Screening). CLOSED is the inbox default for
|
|
* an application that did not progress — it reads as Rejected, same as the
|
|
* board's Rejected column, not as Shortlist.
|
|
*
|
|
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
|
* board — a card with no column is a candidate nobody sees.
|
|
*/
|
|
export const STAGE_FROM_STATUS = {
|
|
PENDING: 'Shortlist',
|
|
PROCESS: 'Screening',
|
|
SCREENING: 'Screening',
|
|
ONHOLD: 'On Hold',
|
|
ASSESSMENT: 'Assessment',
|
|
INTERVIEW: 'Interview',
|
|
OFFER: 'Offer',
|
|
APPROVED: 'Approved',
|
|
HIRED: 'Hired',
|
|
CLOSED: 'Rejected',
|
|
REJECTED: 'Rejected',
|
|
}
|
|
|
|
/**
|
|
* Column -> the status WRITTEN on a drop. Not the inverse of the map above:
|
|
* PROCESS is readable as Screening but is never written, so the vocabulary
|
|
* converges on the canonical value as cards get moved. Shortlist writes
|
|
* PENDING because the enum has no SHORTLIST member. Rejected writes REJECTED
|
|
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
|
*/
|
|
export const STATUS_FROM_STAGE = {
|
|
Shortlist: 'PENDING',
|
|
Screening: 'SCREENING',
|
|
'On Hold': 'ONHOLD',
|
|
Assessment: 'ASSESSMENT',
|
|
Interview: 'INTERVIEW',
|
|
Offer: 'OFFER',
|
|
Approved: 'APPROVED',
|
|
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 board columns. Unmapped keys (UNKNOWN)
|
|
* land in Shortlist, 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] ?? 'Shortlist'
|
|
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
|
|
}
|
|
|
|
/** Card fields are rendered as React children — objects throw, not just look blank. */
|
|
function asText(value, fallback = null) {
|
|
if (value == null) return fallback
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim()
|
|
return trimmed || fallback
|
|
}
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
return fallback
|
|
}
|
|
|
|
function asScore(value) {
|
|
if (value == null || value === '') return null
|
|
const n = typeof value === 'number' ? value : Number(value)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
|
|
function statusKey(value) {
|
|
if (typeof value === 'string') return value
|
|
if (value && typeof value === 'object' && typeof value.value === 'string') return value.value
|
|
return null
|
|
}
|
|
|
|
function sourceFields(row, kind) {
|
|
if (kind === 'manual') {
|
|
const id = row.id
|
|
return {
|
|
id: id != null ? `manual:${id}` : `manual:${row.user_id ?? row.email ?? 'unknown'}`,
|
|
inboxId: null,
|
|
manualUploadId: id ?? null,
|
|
jobId: row.job_post_id ?? null,
|
|
jobTitle: asText(row.title),
|
|
currentTitle: asText(row.current_position),
|
|
currentCompany: asText(row.current_company),
|
|
source: asText(row.platform) || asText(row.apply_via) || 'Manual',
|
|
}
|
|
}
|
|
return {
|
|
id: row.inbox_id ?? `inbox:${row.user_id ?? row.email ?? 'unknown'}`,
|
|
inboxId: row.inbox_id ?? null,
|
|
manualUploadId: null,
|
|
jobId: row.assigned_job_post_id ?? null,
|
|
jobTitle: asText(row.title),
|
|
currentTitle: asText(row.current_title),
|
|
currentCompany: asText(row.current_employment),
|
|
source: null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pipeline inbox or manual-upload row -> one kanban card, or null if the row
|
|
* cannot be rendered. Inbox `id` is the inbox id, not the user id: the board
|
|
* is one card per APPLICATION. `userId` rides along for the profile deep link.
|
|
*/
|
|
export function toBoardCard(row, kind = 'inbox') {
|
|
if (!row || typeof row !== 'object') return null
|
|
const src = sourceFields(row, kind)
|
|
const status = statusKey(row.application_status)
|
|
return {
|
|
...src,
|
|
userId: row.user_id ?? null,
|
|
name: asText(row.name) || asText(row.email) || 'Unknown',
|
|
email: asText(row.email),
|
|
stage: STAGE_FROM_STATUS[status] ?? 'Shortlist',
|
|
status,
|
|
experience: asText(row.experience),
|
|
aiScore: asScore(row.ats_result?.overall_score),
|
|
recommendation: asText(row.ats_result?.band),
|
|
applied: toDate(row.created_at),
|
|
isReapplicant: Boolean(row.is_reapplicant),
|
|
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
|
}
|
|
}
|
|
|
|
/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */
|
|
export function toManualBoardCard(row) {
|
|
return toBoardCard(row, 'manual')
|
|
}
|