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

463 lines
17 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 { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
import { toDate } from '../lib/format'
import { 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 stored-CV bank, newest first — GET /candidate/cv-bank/fetch. */
export function listCvBank({ top = 100, skip = 0 } = {}) {
return request('/candidate/cv-bank/fetch', { params: { top, skip } })
}
/** 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),
}
}
/**
* Candidate USER accounts — `users` rows filtered by role, not the scored
* `candidates` table. Needs candidates.view.
*
* role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
* default). We send it explicitly so a missing param cannot list the wrong people.
*
* This route:
* - it returns `{data, status_code}` with NO `total` on the list; use
* GET /candidate/fetch/users/count (once on page open) for the pager total;
* - `top`/`skip` page the list; the Candidates screen sends the user's page
* size as `top` and `(page-1)*top` as `skip`;
* - `assigned_job_post_id` keeps only users assigned to that job post
* (`inbox_messages.assigned_job_post_id`). Omit it for All Jobs.
* - it accepts a `search` query param but never forwards it to the service
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
* server-side. Filtering stays client-side on the fetched page until that
* is fixed.
*/
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 },
})
}
/**
* `users` row -> the row shape the Candidates table renders.
*
* A user account carries identity only. Everything the ATS produces
* (score, matched skills, critique, the job it was scored against) lives in the
* `candidates` table keyed by job_id + content hash, with no user_id to join on, so
* those fields are null here by construction rather than by omission.
*/
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,
}
}
/**
* 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.
*
* `assignedJobPostId` maps to `assigned_job_post_id` and keeps only people
* assigned to that job. Empty / omitted is All Jobs.
*/
export function list({ search, limit, offset, assignedJobPostId } = {}) {
return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
})
}
/**
* 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 } })
}
/**
* Candidates allocated to jobs this hiring manager owns — requisitions they
* created (or are assigned on) → linked job posts → applications.
* Needs candidates.view. Server-scoped; recruiters should not use this.
*/
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] : []
}
/**
* Assigned job-post ids only. `job_posts` / suggested_job_post_ids are
* matcher hints, not an assignment — the Candidates / Talent Pool job
* filter must not treat a suggestion as a link.
*/
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 } })
}
/**
* 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,
})
}