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

230 lines
9.1 KiB
JavaScript

/* ============================================================
candidates.js — candidate endpoints (backend/job/app.py).
Two data families share this module:
- ATS scoring (persisted `candidates` table): listJobs, listCandidates,
getCandidate, scoreUploads, scoreInbox, toCandidateView.
- Candidate profiles (inbox -> users -> roles join): list, getByUserId,
toRows.
Same conventions as inbox.js: one named export per endpoint, no hooks,
camelCase params mapped to snake_case at the call boundary, and every
function returns the parsed {data, total, status_code} envelope.
============================================================ */
import { request } from '../lib/apiClient'
/** Active job posts for pickers. Needs job_board.view OR candidates.view. */
export function listJobs() {
return request('/job/fetch')
}
/**
* Persisted scoring leaderboard. Needs candidates.view.
* Omit jobId for the whole pool across jobs; rows are ordered completed-by-
* score-desc, then failed rows.
*/
export function listCandidates({ jobId } = {}) {
return request('/candidate/scored/fetch', { params: { job_id: jobId } })
}
/** 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 })
}
/**
* 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,
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: row.created_at ? new Date(row.created_at) : null,
inboxMessageId: row.inbox_message_id ?? null,
}
}
/**
* 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 } = {}) {
return request('/candidate/fetch', { params: { search, limit, offset } })
}
/**
* One candidate by users.id.
*
* Passing user_id switches the endpoint into DETAIL mode
* (backend/job/candidate/views.py:get_candidate), which is a different and much
* larger payload than the list rows: résumé text, the AI match verdict, phone,
* education, source, documents, favorite/rating, and the four child collections
* — interviews, activity, feedback, notes — flattened across every inbox row the
* candidate owns.
*
* NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT
* rather than a one-element list when user_id matches exactly one row
* (backend/inbox/models.py:68-70). Callers must normalise — see toRows().
*/
export function getByUserId(userId) {
return request('/candidate/fetch', { params: { user_id: userId } })
}
/** `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] : []
}
/**
* 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 — no endpoint
* updates inbox_messages.application_status yet.
*/
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, 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('platform', source)
put('experience', experience)
put('status', 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 } })
}
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 },
})
}
export function createActivity({ inboxId, type, status, description }) {
return request('/activity/create', {
method: 'POST',
body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },
})
}