230 lines
9.1 KiB
JavaScript
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 },
|
|
})
|
|
}
|