/candidates completed with manual upload

pull/10/head
ahmed.mujtaba 2026-08-12 13:33:35 +05:00
parent 326842ebba
commit 84daea9efe
7 changed files with 320 additions and 28 deletions

View File

@ -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"),
)

View File

@ -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()

View File

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

View File

@ -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)

View File

@ -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.

View File

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

View File

@ -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 candidates 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={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit}><Icon name="check" /> Add Candidate</button>
<button className="btn btn-secondary" onClick={onClose} disabled={create.isPending}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={create.isPending}>
<Icon name="check" /> {create.isPending ? 'Adding…' : 'Add Candidate'}
</button>
</>
}
>
@ -642,7 +775,21 @@ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
<div className="form-field"><label>Phone</label><input {...field('phone')} placeholder="+1 (555) 000-0000" /></div>
<div className="form-field">
<label>Applied Job <span className="req">*</span></label>
<select {...field('job')}>{open.map((j) => <option key={j.id}>{j.title}</option>)}</select>
<select
value={jobPostId}
onChange={(e) => form.setField('job', e.target.value)}
className={form.errors.job ? 'err' : ''}
disabled={postsQuery.isPending || !posts.length}
>
{postsQuery.isPending && <option value="">Loading job posts</option>}
{!postsQuery.isPending && !posts.length && (
<option value="">{postsQuery.isError ? 'Could not load job posts' : 'No active job posts'}</option>
)}
{posts.map((p) => (
<option key={p.id} value={String(p.id)}>{p.title}</option>
))}
</select>
<FieldError>{form.errors.job}</FieldError>
</div>
<div className="form-field"><label>Experience (years)</label><input type="number" {...field('experience')} /></div>
<div className="form-field"><label>Current Company</label><input {...field('company')} placeholder="Acme Inc." /></div>
@ -654,7 +801,80 @@ function AddCandidate({ jobs, count, onClose, onSave, onInvalid }) {
<label>Stage</label>
<select {...field('stage')}>{stages.map((s) => <option key={s}>{s}</option>)}</select>
</div>
{/* Optional, and deliberately not gated on Source === 'Referral':
a referrer is worth recording whenever there is one, and referrals
routinely arrive tagged as LinkedIn or Company Site. */}
<div className="form-field">
<label>Referral By</label>
<input
type="email"
{...field('referral')}
// Normalising on blur means the value the recruiter sees is the
// value that gets posted otherwise a pasted address with a
// trailing space would submit clean while still looking untidy.
onBlur={(e) => form.setField('referral', referralValue(e.target.value))}
className={form.errors.referral ? 'err' : ''}
placeholder={`name@${REFERRAL_DOMAIN}`}
/>
<FieldError>{form.errors.referral}</FieldError>
</div>
</div>
{/* .req is scoped to `.form-field label .req`, so tint it here. */}
<div className="form-section-title">
CV / Resume <span className="req" style={{ color: 'var(--danger)' }}>*</span>
</div>
<input
ref={fileInput}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(e) => { pickFile(e.target.files?.[0]); e.target.value = '' }}
/>
<div
className={`dropzone${dragging ? ' drag' : ''}`}
style={{ padding: '22px 18px', cursor: create.isPending ? 'default' : 'pointer' }}
role="button"
tabIndex={0}
onClick={() => { if (!create.isPending) fileInput.current?.click() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.current?.click() }
}}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
pickFile(e.dataTransfer.files?.[0])
}}
>
<div className="dz-icn" style={{ width: 44, height: 44, borderRadius: 13, marginBottom: 10 }}>
<Icon name="upload" />
</div>
<h3 style={{ fontSize: 15 }}>Drop the CV here or click to browse</h3>
<p className="text-muted text-sm">
PDF only · text-based resumes · up to {MAX_CV_MB} MB
</p>
</div>
{cv && (
<div className="upload-row">
<span className="attach-icn" style={{ width: 34, height: 34 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{cv.name}</div>
<div className="cell-sub">{Math.max(1, Math.round(cv.size / 1024))} KB</div>
</div>
<button
type="button"
className="act-btn"
aria-label="Remove file"
disabled={create.isPending}
onClick={() => setCv(null)}
>
<Icon name="trash" />
</button>
</div>
)}
<FieldError>{form.errors.cv}</FieldError>
</form>
</Modal>
)