HR-ATS-Portal/frontend/src/api/candidates.js

566 lines
20 KiB
JavaScript

import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { toDate } from '../lib/format'
import { STAGE_FROM_STATUS, STATUS_FROM_STAGE } from './pipeline'
/** Active job posts for pickers. Needs job_board.view OR candidates.view.
*
* `top` is explicit because /job/fetch now defaults to 10 — a picker dropdown
* that silently showed only the 10 newest jobs would hide the rest.
*/
export function listJobs({ top = 100 } = {}) {
return request('/job/fetch', { params: { top } })
}
/**
* Persisted scored candidates. Needs candidates.view.
* Omit jobId for the whole pool across jobs. Rows come back newest-first by
* created_at and PAGED (limit defaults to 10 server-side); `total` in the
* envelope is the full result-set size, not the page length.
*/
export function listCandidates({ jobId, limit, offset } = {}) {
return request('/candidate/scored/fetch', {
params: { job_id: jobId, limit, offset },
})
}
/** One scored candidate row by id. Needs candidates.view. 404s on unknown ids. */
export function getCandidate(candidateId) {
return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } })
}
/**
* Score uploaded CV PDFs against a job post. Needs candidates.create.
* Multipart: unreadable/oversized/non-PDF files come back as rows with
* status "failed" instead of failing the batch. Re-scoring identical bytes
* against the same job updates the existing row (no duplicates).
*/
export function scoreUploads(jobId, files) {
const form = new FormData()
form.append('job_id', jobId)
for (const file of files) form.append('files', file, file.name)
return request('/candidate/score', { method: 'POST', body: form })
}
/**
* CV bank — a private store of CVs with NO job, NO user account and NO inbox
* entry (POST /candidate/cv-bank/upload). Nothing is scored; the file just
* waits until a recruiter picks it up. Email is captured only when the CV
* contains one. One file per request. Needs candidates.create.
*/
export function uploadToCvBank(file) {
const form = new FormData()
form.append('file', file, file.name)
return request('/candidate/cv-bank/upload', { method: 'POST', body: form })
}
/**
* The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list:
* speculative uploads with no job, and rejected applicants who scored well.
*
* `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword
* overlap against that job) and sorts by it — the "a role just opened, who do
* we already have" view.
*/
export function listCvBank({
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
} = {}) {
return request('/candidate/cv-bank/fetch', {
params: {
top, skip, source, search, skills,
min_years: minYears,
band,
job_post_id: jobPostId,
},
})
}
/**
* Banked CVs worth reviewing for one job, best first. Needs candidates.view.
* Same rows as listCvBank with a job context, already cut at the threshold.
*/
export function listCvBankSuggestions({ jobPostId, top = 20, minRank } = {}) {
return request('/candidate/cv-bank/suggestions', {
params: { job_post_id: jobPostId, top, min_rank: minRank },
})
}
/**
* Run the real ATS score on CVs already in the bank. Needs candidates.create.
* This is the paid step — rank_score on the list is free keyword overlap and
* is not a score. Results land in candidates/ats_results like any other CV.
*/
export function scoreCvBank(jobId, ids) {
return request('/candidate/cv-bank/score', {
method: 'POST',
body: { job_id: jobId, ids },
})
}
/** Permanently remove a stored CV (file included). Needs candidates.delete. */
export function deleteCvBankCv(id) {
return request('/candidate/cv-bank/delete', { method: 'DELETE', params: { id } })
}
/** Browser-save a stored CV's PDF — served from the database (cv_bank_files). */
export function downloadCvBankCv(id) {
return downloadFile('/candidate/cv-bank/file', { params: { id } })
}
/**
* Object URL of a stored CV for IN-APP preview (no download). The route sends
* an attachment disposition, so the blob is re-typed to application/pdf for
* the browser's inline viewer. Caller revokes the URL when the preview closes.
*/
export function viewCvBankCv(id) {
return fetchBlobUrl('/candidate/cv-bank/file', {
params: { id },
type: 'application/pdf',
})
}
/**
* Job Matching queue — CV Import "No job" rows (`apply_via=cv_bank`).
* Needs candidates.view. `assigned` is tri-valued: omit for all, false for
* still in the bank, true for rows that already have a job_post_id.
*/
export function listMatching({ search, top = 10, skip = 0, assigned } = {}) {
return request('/candidate/matching/fetch', {
params: { search, top, skip, assigned },
})
}
/** One matching row by manual_upload_candidate id. Needs candidates.view. */
export function getMatching(id) {
return request('/candidate/matching/fetch_by_id', { params: { id } })
}
/**
* Link (or unlink) a job post on a CV-bank row. Needs candidates.edit.
* After assign the row has job_post_id + user_id and Pipeline/Candidates
* fetch it like any other manual_upload_candidate.
*/
export function assignMatchingJob(id, jobPostId) {
return request('/candidate/matching/assign', {
method: 'POST',
body: { id, job_post_id: jobPostId },
})
}
/**
* Score the decoded attachments of inbox messages against a job post.
* Needs candidates.create. messageIds are inbox_messages PK uuids (the `id`
* field the inbox list returns), not Graph message ids.
*/
export function scoreInbox(jobId, messageIds) {
return request('/candidate/score_inbox', {
method: 'POST',
body: { job_id: jobId, message_ids: messageIds },
})
}
/**
* Shared snake_case → camelCase view-model mapper for candidate rows, so the
* three candidate screens agree on field names. Fields the backend does not
* store (email, phone, stage, education…) are deliberately absent — screens
* hide those affordances rather than render placeholders (Inbox precedent).
*/
export function toCandidateView(row) {
const name = row.candidate_name || row.filename || 'Unknown'
return {
id: row.id,
jobId: row.job_id,
name,
filename: row.filename,
filePath: row.file_path || null,
source: row.source, // 'upload' | 'inbox'
currentTitle: row.job_title ?? null,
currentCompany: row.current_company ?? null,
experience: row.years_experience ?? null,
aiScore: row.match_score ?? null,
matchedSkills: Array.isArray(row.matched_keywords) ? row.matched_keywords : [],
missingSkills: Array.isArray(row.missing_keywords) ? row.missing_keywords : [],
critique: row.summary_critique ?? null,
scoringStatus: row.status, // 'completed' | 'failed'
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
applied: toDate(row.created_at),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId },
})
}
/** Total candidate-role users. Called once when the Candidates page opens. */
export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } = {}) {
return request('/candidate/fetch/users/count', {
params: { role_id: roleId, search, assigned_job_post_id: assignedJobPostId },
})
}
export function toCandidateUserView(row) {
return {
id: row.id,
userId: row.id,
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
isActive: row.is_active ?? null,
roleName: row.role_name ?? null,
applied: toDate(row.created_at),
// No ATS data on a users row — see the note above.
jobId: null,
filename: null,
source: null,
currentTitle: null,
currentCompany: null,
experience: null,
aiScore: null,
matchedSkills: [],
missingSkills: [],
critique: null,
scoringStatus: null,
errorCode: null,
errorMessage: null,
}
}
function statusKey(value) {
if (value == null || value === '') return ''
if (typeof value === 'object' && value.value != null) return String(value.value).toUpperCase()
return String(value).toUpperCase()
}
function bandOf(score, recommendation) {
if (recommendation) return recommendation
if (score == null || !Number.isFinite(Number(score))) return null
const n = Number(score)
return n >= 82 ? 'Strong Match' : n >= 65 ? 'Potential Match' : 'Weak Match'
}
/**
* GET /candidate/fetch list row -> the Candidates table.
*
* Application-centric: score, stage, job and recruiter belong to one
* inbox/manual application, not to the user account.
*/
export function toApplicationListView(row) {
const status = statusKey(row.application_status ?? row.stage)
const name = row.name || row.email || 'Unknown'
const jobTitle = row.job_title
|| row.assigned_job_post?.title
|| (Array.isArray(row.job_posts) ? row.job_posts.find((j) => j?.title)?.title : null)
|| null
const rawScore = row.ai_score ?? row.match_score
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
const score = Number.isFinite(aiScore) ? aiScore : null
return {
id: row.inbox_id != null
? `inbox:${row.inbox_id}`
: (row.manual_upload_candidate_id
? `manual:${row.manual_upload_candidate_id}`
: String(row.user_id || row.id || name)),
userId: row.user_id || null,
name,
email: row.email ?? null,
isActive: row.is_active ?? null,
jobTitle,
recruiter: row.recruiter || null,
applicationStatus: status || null,
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
source: row.source || null,
aiScore: score,
recommendation: bandOf(score, row.recommendation || null),
applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
}
export const BANK_SOURCE_LABELS = {
speculative: 'Speculative',
silver_medalist: 'Silver medalist',
}
/**
* GET /candidate/cv-bank/fetch row -> the CV Bank table.
*
* Two numbers that must never be confused: `aiScore` is a real paid ATS score
* and only exists once someone ran one; `rankScore` is free keyword overlap
* against whichever job is selected. The screen renders them differently on
* purpose.
*/
export function toBankRowView(row) {
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
const aiScore = Number.isFinite(score) ? score : null
const rank = row.rank_score == null || row.rank_score === '' ? null : Number(row.rank_score)
const years = row.years_experience == null || row.years_experience === ''
? null
: Number(row.years_experience)
const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null
return {
id: String(row.id || ''),
recordId: row.record_id != null ? String(row.record_id) : null,
source: row.bank_source || 'speculative',
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
// A silver medalist is read live from their application, so removing or
// re-scoring them is not the bank's call to make.
isStoredCv: row.bank_source !== 'silver_medalist',
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
phone: row.phone ?? null,
fileName: row.file_name ?? null,
filePath: row.file_path ?? null,
linkedinUrl: row.linkedin_url ?? null,
company: row.current_company ?? null,
title: row.current_position ?? null,
education: row.education ?? null,
skills: Array.isArray(row.skills) ? row.skills : [],
years: Number.isFinite(years) ? years : null,
aiScore,
recommendation: bandOf(aiScore, row.recommendation || null),
rankScore: Number.isFinite(rank) ? rank : null,
lastJobTitle: row.last_job_title ?? null,
bankReason: row.bank_reason ?? null,
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,
userId: row.user_id ?? null,
added: row.created_at ? new Date(row.created_at) : null,
}
}
/**
* "expires in 7 months", or null when nothing is set. Past expiry reads as
* "expired" rather than a negative count — the row still exists and someone
* has to decide what to do about it.
*/
export function expiryLabel(expiresAt, now = new Date()) {
if (!expiresAt) return null
const months = Math.round((expiresAt - now) / (1000 * 60 * 60 * 24 * 30))
if (months <= 0) return 'expired'
if (months === 1) return 'expires in 1 month'
return `expires in ${months} months`
}
/**
* Candidate profiles — the `inbox -> users -> roles` join, restricted server-side
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
*
* Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the
* tag gets a 403.
*
* `search` is an ilike over users.name / users.email only — it does NOT reach
* the résumé text or the suggested job titles.
*/
export function list({ search, limit, offset, assignedJobPostId } = {}) {
return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
})
}
export function getByUserId(userId) {
return request('/candidate/fetch', { params: { user_id: userId } })
}
export function listForManager({ limit = 50, offset = 0 } = {}) {
return request('/candidate/manager/fetch', { params: { limit, offset } })
}
/** `data` is a list on the list path and a bare object on the by-id path. */
export function toRows(res) {
if (Array.isArray(res?.data)) return res.data
return res?.data ? [res.data] : []
}
export function jobIdsOf(row) {
if (!row || typeof row !== 'object') return []
const ids = []
const add = (value) => {
if (value == null || value === '') return
const id = String(value)
if (!ids.includes(id)) ids.push(id)
}
add(row.assigned_job_post_id)
add(row.assigned_job_post?.id)
add(row.job_post_id)
return ids
}
/**
* favorite/rating live on the `inbox` row, not on the user, so the server applies
* the change to EVERY application belonging to the candidate and hands back the
* refreshed detail payload. Pipeline stage is not writable here — it moves one
* APPLICATION at a time through PATCH /candidate/stage (api/pipeline.js).
*/
export function update(userId, payload) {
return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload })
}
/**
* Manual candidate creation — POST /candidate/create/candidate (backend/job/app.py:98).
*
* Multipart, and the CV is REQUIRED, not an extra: the route declares
* `file: UploadFile = File(...)`, so a request without one is a 422, and
* injest_manual_upload then rejects the upload with 400 when pypdf extracts no
* text. The extracted text IS the record — it is what later scoring reads — so
* a scanned or image-only PDF fails here rather than storing an empty row.
* PDF only: read_file goes straight to PdfReader, so DOC/DOCX 400s.
*
* Every other field is an optional Form value, with one exception —
* candidate_email, which create_candidate rejects when blank (422). It is also
* the identity key: an unknown address creates the `users` row (role CANDIDATE,
* default password from DEFAULT_CANDIDATE_PASSWORD), a known one reuses it.
* That user write is why the route sits behind candidates.create.
*
* job_post_id must be a real job_posts UUID. Anything unparseable is coerced to
* NULL rather than raising (Manual_UPLOAD_CANDIDATE._as_uuid), so a seed id like
* "JOB-101" would silently drop the link — the picker must offer live posts from
* /job/fetch, never the seed catalogue.
*
* `platform`, `status` and `referral_by` are free-text columns, not enums; the
* UI's Source and Stage vocabularies go in verbatim, and a referrer is whatever
* the recruiter typed — often someone with no account here.
*/
export function createManual({
file, name, email, phone, jobPostId, company, currentPosition, source, experience, stage, referralBy,
}) {
const form = new FormData()
form.append('file', file)
// Blank optional fields are omitted rather than sent as "": Form(None) then
// leaves them None, and the model's own defaults apply.
const put = (key, value) => {
const text = value == null ? '' : String(value).trim()
if (text) form.append(key, text)
}
put('candidate_email', email)
put('candidate_name', name)
put('candidate_phone', phone)
put('job_post_id', jobPostId)
put('current_company', company)
put('current_position', currentPosition)
put('platform', source)
put('experience', experience)
put('status', STATUS_FROM_STAGE[stage] || stage)
put('referral_by', referralBy)
return request('/candidate/create/candidate', { method: 'POST', body: form })
}
/* ------------------------------------------------------------------
Child records of a profile.
Reads are deliberately absent: the detail payload above already bundles all
four collections, so a separate GET per tab would be a second round trip for
data the modal is holding. Writers invalidate qk.candidates.detail(userId) and
the whole modal repaints from one refetch.
Scoping differs by table and is not interchangeable — notes hang off the
candidate (users.id), while interviews, activity and feedback hang off one
application (inbox.id).
------------------------------------------------------------------ */
export function createNote({ userId, note }) {
return request('/notes/create', { method: 'POST', body: { user_id: userId, note } })
}
/**
* Edit an existing note. `created_by` is NOT reassigned server-side, so the
* note keeps its original author — editing someone else's note rewrites their
* words under their name, which is why the UI only offers this on notes the
* signed-in user wrote.
*/
export function updateNote(noteId, note) {
return request('/notes/update', {
method: 'PATCH',
params: { note_id: noteId },
body: { note },
})
}
export function createInterview({ inboxId, date, time, type, status }) {
return request('/interview/create', {
method: 'POST',
body: {
inbox_id: inboxId,
interview_date: date,
interview_time: time,
interview_type: type,
interview_status: status,
},
})
}
/** `reviewed_by` is omitted on purpose: the server stamps the caller. */
export function createFeedback({ inboxId, review, score, note }) {
return request('/feedback/create', {
method: 'POST',
body: { inbox_id: inboxId, review, score, note },
})
}
/**
* Revise a scorecard. Only the keys passed are written (the service drops
* None), and `reviewed_by` is left alone so the revision stays attributed to
* whoever originally submitted it.
*/
export function updateFeedback(feedbackId, { review, score, note } = {}) {
const body = {}
if (review != null) body.review = review
if (score != null) body.score = score
if (note != null) body.note = note
return request('/feedback/update', {
method: 'PATCH',
params: { feedback_id: feedbackId },
body,
})
}
export function createActivity({ inboxId, type, status, description }) {
return request('/activity/create', {
method: 'POST',
body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },
})
}
/**
* Audit log for one candidate. UNLIKE the four child collections above, history is
* NOT bundled into the detail payload: it is append-only and unbounded, and the
* detail query refetches on every write in the modal. Fetched lazily when the
* History tab opens, paginated server-side.
*/
export function listHistory(userId, { limit = 10, offset = 0 } = {}) {
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
}
/**
* Prior applications for one email across users, candidates, manual upload,
* form_data (and inbox). Used by Add Candidate to warn on reapply.
*/
export function fetchApplicationHistory(email) {
return request('/candidate/applications/fetch', { params: { email } })
}
/**
* Authenticated attachment download. Never send a filesystem path — the server
* resolves by owning record + index. `inboxId` is the `inbox` table PK (int),
* not `inbox_messages.id`.
*/
export function downloadDocument({ inboxId, manualUploadCandidateId, index = 0, filename } = {}) {
return downloadFile('/documents/download', {
params: {
inbox_id: inboxId,
manual_upload_candidate_id: manualUploadCandidateId,
index,
},
filename,
})
}