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, }) }