/* ============================================================ 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 and * is shown as CLOSED. Only REJECTED reads as Rejected. * * 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: 'CLOSED', 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 = { CLOSED: 'CLOSED', 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, search } = {}) { return request('/pipeline/candidates/fetch', { params: { job_post_id: jobId, limit, offset, search }, }) } /** * 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') }