455 lines
16 KiB
JavaScript
455 lines
16 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 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),
|
|
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 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,
|
|
})
|
|
}
|