diff --git a/backend/job/app.py b/backend/job/app.py index 8aff510..6b405e9 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -106,6 +106,7 @@ async def create_manual_candidate( platform: str | None = Form(None), experience: str | None = Form(None), status: str | None = Form(None), + referral_by: str | None = Form(None), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), session: AsyncSession = Depends(get_session), ): @@ -124,6 +125,7 @@ async def create_manual_candidate( platform=platform, experience=experience, status=status, + referral_by=referral_by, full_text=parsed.get("text") or "", current_user=current_user.get("id"), ) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 81be3a7..a6fc3c8 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -35,6 +35,17 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") experience: str = Field(default="") status: str = Field(default="") + # Free text, not a users FK: a referrer is often someone outside the system + # (a client, a former colleague), and recruiters type whatever the candidate + # told them. "" rather than NULL keeps it consistent with the columns above. + # + # server_default is load-bearing and NOT decoration, unlike the columns above + # — they arrived with the CREATE TABLE, this one arrives as an ALTER. The + # startup autogenerate would emit `ADD COLUMN referral_by VARCHAR NOT NULL`, + # which Postgres rejects outright on a table that already holds rows. The + # DEFAULT backfills them. Pass the bare "" — SQLAlchemy quotes a plain string + # into DEFAULT '', whereas "''" would render DEFAULT '''''' instead. + referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""}) created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @@ -83,6 +94,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): created_by=cls._as_uuid(fields.get("created_by")), experience=(fields.get("experience") or "").strip(), status=(fields.get("status") or "").strip(), + referral_by=(fields.get("referral_by") or "").strip(), ) session.add(row) await session.commit() diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 2a53645..f2f9a28 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -21,6 +21,7 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]: "created_by":str(row.created_by) if row.created_by else None, "experience":row.experience, "status":row.status, + "referral_by":row.referral_by, "created_at":row.created_at.isoformat() if row.created_at else None, "updated_at":row.updated_at.isoformat() if row.updated_at else None, } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 4bf4bb5..1e9a148 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -182,7 +182,7 @@ class CandidateView: def __init__(self,session:AsyncSession): self.session=session - async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,full_text=None,current_user=None): + async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=None,full_text=None,current_user=None): try: email=(candidate_email or "").strip().lower() if not email: @@ -198,8 +198,10 @@ class CandidateView: "platform":(platform or "").strip(), "experience":(experience or "").strip(), "status":(status or "").strip(), + "referral_by":(referral_by or "").strip(), "full_text":full_text or "", "created_by":current_user, + } row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data) return serialize_manual_upload_candidate(row) diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 69756cc..1d6767f 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -48,6 +48,54 @@ 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. diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index 261b2bb..63dfc8a 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -49,16 +49,23 @@ export async function request( } } + // Multipart uploads pass a FormData body. Content-Type is deliberately NOT + // set for those: the browser has to write it itself so the generated boundary + // token ends up in the header, and a hand-set value strips it and the server + // reads an unparseable body. FormData is replayable, so the 401 retry below + // can re-send the same object. + const multipart = typeof FormData !== 'undefined' && body instanceof FormData + const send = async () => { const headers = { Accept: 'application/json' } - if (body != null) headers['Content-Type'] = 'application/json' + if (body != null && !multipart) headers['Content-Type'] = 'application/json' const bearer = token ?? (auth ? getAccessToken() : null) if (bearer) headers.Authorization = `Bearer ${bearer}` return fetch(buildUrl(path, params), { method, headers, signal, - body: body != null ? JSON.stringify(body) : undefined, + body: body == null ? undefined : multipart ? body : JSON.stringify(body), }) } diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index ab4f83c..df68061 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -8,9 +8,9 @@ selection column needs to render against a Set this component owns. ============================================================ */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import { Pagination, useDataTable } from '../ui/DataTable' @@ -19,6 +19,9 @@ import { useToast } from '../ui/Toast' import { useFormState } from '../components/AuthLayout' import CandidateProfile from './CandidateProfile' import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import * as candidatesApi from '../api/candidates' +import * as jobPostsApi from '../api/jobPosts' import { persist, seedQuery, useSeedMutation } from '../data/seedQueries' import { atsRecommendationClass, avatarColor, departments, educationLevels, getJob, @@ -32,6 +35,34 @@ const INTERVIEW_STATES = ['Not Scheduled', 'Scheduled', 'Completed'] const NOTICE = ['Immediate', '2 weeks', '1 month', '2 months', '3 months'] const AVAILABILITY = ['Immediate', '2 weeks', '1 month', 'Passive'] +/* Client-side guard only — the route has no size cap of its own, so this just + stops an obviously wrong file from being read into memory and posted. */ +const MAX_CV_MB = 10 + +/** Live job posts offered by the Add Candidate picker. */ +const JOB_POST_LIMIT = 100 + +/* Referral By must name a colleague, so it is constrained to a company address: + a referral from outside the company is not a referral, and a bare name + ("Sarah") cannot be resolved to a person later. + + This is the ONLY place the rule lives. `referral_by` is a free-text column and + the route does not check it, so anything posted outside this form is stored + as-is — the constraint is a data-entry guard, not an invariant. */ +const REFERRAL_DOMAIN = 'utopiabrands.com' +const REFERRAL_RE = new RegExp( + `^[a-z0-9][a-z0-9._%+-]*@${REFERRAL_DOMAIN.replace(/\./g, '\\.')}$`, + 'i', +) + +/** + * The one reading of the Referral By box: surrounding whitespace is stripped, so + * a field holding only spaces is absent rather than invalid, and the address is + * lower-cased so " Ada@UtopiaBrands.com " and "ada@utopiabrands.com" are stored + * as one referrer rather than two. + */ +const referralValue = (raw) => (raw || '').trim().toLowerCase() + const EMPTY_FILTERS = { job: '', skill: '', dept: '', location: '', exp: '', edu: '', recruiter: '', manager: '', source: '', ats: '', stage: '', interview: '', notice: '', availability: '', @@ -575,41 +606,141 @@ function BulkAssign({ count, recruiters, onClose, onSave }) { ) } +/** + * Add Candidate — the only writer on this screen that reaches the server. + * + * POST /candidate/create/candidate persists the row and, for an unseen email, + * the `users` record behind it. The CV is not optional there: the route requires + * the file and refuses it when no text can be extracted, so the dropzone below + * the fields is part of the contract rather than a convenience. + * + * Applied Job lists LIVE job posts (/job/fetch), not the seed catalogue, because + * job_post_id is a job_posts FK and a seed id would be coerced to NULL without + * an error — the link would look saved and simply not exist. + * + * The row handed to onSave is still seed-shaped. Nothing on this screen reads + * /candidate/fetch — manual rows do not pass through `inbox`, so they surface + * neither here nor in Talent Pool — and dropping the candidate the recruiter + * just created out of the table would read as a failed save. The fabricated + * scoring fields are the pre-existing seed shape, unchanged; only the identity + * fields now carry what was actually posted. + */ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) { - const open = jobs.filter((j) => j.status === 'Open') + const { toast } = useToast() + const qc = useQueryClient() + const fileInput = useRef(null) + const [cv, setCv] = useState(null) + const [dragging, setDragging] = useState(false) + + const postsQuery = useQuery({ + queryKey: qk.jobPosts.list({ top: JOB_POST_LIMIT }), + queryFn: async () => { + const res = await jobPostsApi.list({ top: JOB_POST_LIMIT }) + return Array.isArray(res?.data) ? res.data : [] + }, + }) + const posts = postsQuery.data ?? [] + const form = useFormState({ - name: '', email: '', phone: '', job: open[0]?.title ?? '', + name: '', email: '', phone: '', job: '', experience: '3', company: '', source: sources[0], stage: stages[0], + referral: '', }) + // Defaulting by derivation rather than in an effect: the picker resolves after + // first paint, and useFormState's setters are new every render, so seeding the + // field from an effect would either loop or need a ref to guard it. + const jobPostId = form.values.job || (posts[0] ? String(posts[0].id) : '') + + const create = useMutation({ + mutationFn: (vars) => candidatesApi.createManual(vars), + onError: (err) => toast(friendlyAuthError(err, 'Could not add the candidate.'), 'error'), + onSuccess: (res) => { + // The new user_id lands in /candidate/fetch's join the moment an + // application exists for them, so let the live-backed screens refetch. + qc.invalidateQueries({ queryKey: qk.candidates.all() }) + onSave(buildRow(res?.data)) + }, + }) + + function buildRow(saved) { + const v = form.values + const post = posts.find((p) => String(p.id) === jobPostId) + const title = post?.title || v.job || jobs[0]?.title || 'Unassigned' + // Department, location, recruiter and skills are presentation-only columns + // the endpoint does not return — borrow them from the seed job of the same + // title so the row renders like every other one. + const job = jobs.find((j) => j.title === title) || jobs[0] || {} + const skills = job.skills ?? [] + const score = int(55, 95) + return { + id: `CAN-${5001 + count}`, + userId: saved?.user_id ?? null, + manualUploadId: saved?.id ?? null, + name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name), + email: v.email, phone: v.phone || '+1 (555) 000-0000', + jobId: job.id, jobTitle: title, department: job.department, + experience: Number(v.experience) || 1, currentCompany: v.company || '—', + currentTitle: title, location: job.location, + stage: v.stage, status: v.stage, aiScore: score, source: v.source, + referredBy: referralValue(v.referral) || null, + recruiter: job.recruiter, recruiterId: job.recruiterId, + applied: new Date(TODAY), education: "Bachelor's Degree", + skills: skills.slice(0, 4), rating: '4.0', salary: 120000, + matchedSkills: skills.slice(0, 3), missingSkills: skills.slice(3), + recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match', + subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 }, + noticePeriod: '1 month', availability: '2 weeks', certifications: [], + favorite: false, interviewStatus: 'Not Scheduled', + } + } + + function pickFile(next) { + if (!next) return + setCv(next) + form.setErrors((prev) => { + if (!prev.cv) return prev + const rest = { ...prev } + delete rest.cv + return rest + }) + } + function submit() { + if (create.isPending) return const v = form.values const errors = {} if (!v.name.trim()) errors.name = 'Required' if (!/^\S+@\S+\.\S+$/.test(v.email)) errors.email = 'Valid email required' + // Only enforceable when the picker actually has something to pick — the + // column is nullable server-side. + if (posts.length && !jobPostId) errors.job = 'Required' + if (!cv) errors.cv = 'Attach the candidate’s CV' + else if (!/\.pdf$/i.test(cv.name)) errors.cv = 'Only PDF resumes can be parsed' + else if (cv.size > MAX_CV_MB * 1024 * 1024) errors.cv = `Keep the file under ${MAX_CV_MB} MB` + // Optional: trimmed first, so a field holding only spaces is genuinely empty + // and passes rather than failing the pattern. Anything left must be a + // company address — the pattern rejects interior spaces on its own. + const referral = referralValue(v.referral) + if (referral && !REFERRAL_RE.test(referral)) { + errors.referral = `Must be a @${REFERRAL_DOMAIN} address` + } form.setErrors(errors) if (Object.keys(errors).length) { onInvalid() return } - const job = jobs.find((j) => j.title === v.job) || jobs[0] - const score = int(55, 95) - onSave({ - id: `CAN-${5001 + count}`, - name: v.name, initials: initialsOf(v.name), color: avatarColor(v.name), - email: v.email, phone: v.phone || '+1 (555) 000-0000', - jobId: job.id, jobTitle: job.title, department: job.department, - experience: Number(v.experience) || 1, currentCompany: v.company || '—', - currentTitle: job.title, location: job.location, - stage: v.stage, status: v.stage, aiScore: score, source: v.source, - recruiter: job.recruiter, recruiterId: job.recruiterId, - applied: new Date(TODAY), education: "Bachelor's Degree", - skills: job.skills.slice(0, 4), rating: '4.0', salary: 120000, - matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), - recommendation: score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match', - subScores: { skills: score, experience: 80, education: 80, keywords: score, location: 100, salary: 90 }, - noticePeriod: '1 month', availability: '2 weeks', certifications: [], - favorite: false, interviewStatus: 'Not Scheduled', + create.mutate({ + file: cv, + name: v.name, + email: v.email, + phone: v.phone, + jobPostId, + company: v.company, + source: v.source, + experience: v.experience, + stage: v.stage, + referralBy: referral, }) } @@ -622,8 +753,10 @@ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) { onClose={onClose} footer={ <> - - + + > } > @@ -642,7 +775,21 @@ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
+ PDF only · text-based resumes · up to {MAX_CV_MB} MB +
+