CV bank: private storage for No-Job CV imports
Deploy to S3 / deploy (push) Successful in 36s Details

Storing a CV without a job no longer rides the cv_upload pipeline, which
created a candidate user account and an inbox row — stored CVs were
leaking into the Candidates screen. New cv-bank endpoints instead write
apply_via=cv_bank rows in manual_upload_candidate (nullable user/job
FKs): file + parsed text only, no account, no inbox entry, no scoring,
and email is optional (captured when the CV contains one). The CV Import
screen now shows the bank itself below the dropzone — browse, download
(existing /documents/download route) and delete.

E2E-verified: uploads land as BANKED rows with user_id NULL, zero new
accounts or inbox rows, UI delete works, Candidates screen unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/29/head
Talha Ahmed 2026-08-27 19:20:38 +05:00
parent d1ca1e933d
commit 9f610a9078
6 changed files with 274 additions and 50 deletions

View File

@ -257,6 +257,94 @@ async def cv_upload(
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv-bank/upload")
async def cv_bank_upload(
file: UploadFile = File(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Store a CV in the bank: the file plus its parsed text, nothing else.
No job, no user account, no inbox entry, no scoring the CV waits until a
recruiter picks it up. Email/name are captured only if the CV contains them."""
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.candidate.plugins import extract_candidate_email
saved_path=None
try:
content=await file.read()
reader=FileRead(session=session,filename=file.filename,file=content)
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
saved=await reader.save_manual_upload()
saved_path=saved.get("file_path")
text=parsed.get("text") or ""
detected,_=extract_candidate_email(text)
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
session,
candidate_email=detected or "",
candidate_name="",
full_text=text,
file_name=saved.get("file_name"),
file_path=saved_path,
created_by=current_user.get("id"),
)
return JSONResponse(content={"data":{
"id":str(row.id),
"file_name":row.file_name,
"candidate_email":row.candidate_email or None,
"created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200})
except HTTPException:
FileRead.discard_upload(saved_path)
raise
except Exception as e:
FileRead.discard_upload(saved_path)
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/cv-bank/fetch")
async def cv_bank_fetch(
top: int = Query(100, ge=1, le=500),
skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""The stored-CV bank, newest first. Download the file via
GET /documents/download?manual_upload_candidate_id=<id>."""
from job.candidate.models import Manual_UPLOAD_CANDIDATE
try:
rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip)
data=[{
"id":str(r.id),
"file_name":r.file_name,
"candidate_email":r.candidate_email or None,
"candidate_name":r.candidate_name or None,
"created_at":r.created_at.isoformat() if r.created_at else None,
} for r in rows]
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/candidate/cv-bank/delete")
async def cv_bank_delete(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
session: AsyncSession = Depends(get_session),
):
from job.candidate.models import Manual_UPLOAD_CANDIDATE
try:
row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id)
if not row:
raise HTTPException(status_code=404,detail="CV not found in the bank")
FileRead.discard_upload(row.file_path)
return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/inbox-match")
async def candidate_inbox_match(
inbox_message_id: str = Query(...),

View File

@ -240,6 +240,61 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
# ---- CV bank -----------------------------------------------------------
# apply_via="cv_bank" rows are a private store of CVs with NO job, NO user
# account and NO inbox entry — deliberately invisible to Candidates,
# Pipeline (whose list inner-joins Users/JobPosts) and the Inbox. They wait
# until a recruiter picks them up; email is captured only when the CV
# contains one.
@classmethod
async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email,
candidate_name, full_text, file_name, file_path,
created_by):
row = cls(
candidate_email=(candidate_email or "").strip().lower(),
candidate_name=(candidate_name or "").strip(),
job_post_id=None,
full_text=full_text or "",
linkedin_slug=primary_slug_from_text(full_text or ""),
apply_via="cv_bank",
user_id=None,
created_by=cls._as_uuid(created_by),
status="BANKED",
file_name=(file_name or "").strip(),
file_path=(file_path or "").strip(),
)
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
total = (
await session.execute(
select(func.count()).select_from(cls).where(cls.apply_via == "cv_bank")
)
).scalar() or 0
result = await session.execute(
select(cls)
.where(cls.apply_via == "cv_bank")
.order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all()), total
@classmethod
async def delete_bank_cv(cls, session: AsyncSession, record_id):
"""Hard delete, bank rows only — never reachable for application rows."""
row = await cls.get_by_id(session, record_id)
if not row or row.apply_via != "cv_bank":
return None
await session.delete(row)
await session.commit()
return row
class Candidates(SQLModel, table=True):

View File

@ -24,8 +24,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-BkPc2-Gs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwYjpKNo.css">
<script type="module" crossorigin src="/assets/index-_PGQLp_7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C1VjJy57.css">
</head>
<body>
<div id="root"></div>

View File

@ -54,16 +54,30 @@ export function scoreUploads(jobId, files) {
}
/**
* Store one CV in the bank with NO job attached POST /candidate/cv_upload.
* Needs candidates.create. The backend saves the PDF, detects the candidate's
* email from the CV text (422 CANDIDATE_EMAIL_REQUIRED when none is found),
* and lands it as an UNASSIGNED inbox item; the async matcher only fills job
* suggestions. One file per request.
* 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 uploadCv(file) {
export function uploadToCvBank(file) {
const form = new FormData()
form.append('file', file, file.name)
return request('/candidate/cv_upload', { method: 'POST', body: form })
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 via the existing documents route. */
export function downloadCvBankCv(id) {
return downloadFile('/documents/download', { params: { manual_upload_candidate_id: id } })
}
/**

View File

@ -34,6 +34,10 @@ export const qk = {
list: (p = {}) => ['assessments', 'list', p],
counts: () => ['assessments', 'counts'],
},
cvBank: {
all: () => ['cvBank'],
list: () => ['cvBank', 'list'],
},
notifications: {
all: () => ['notifications'],
list: (p = {}) => ['notifications', 'list', p],

View File

@ -7,14 +7,13 @@
re-uploading the same bytes updates the existing record.
No-Job mode ("store in CV bank"): each file goes to POST
/candidate/cv_upload individually parsed, the candidate email detected
from the CV text, and stored as an UNASSIGNED inbox item. Nothing is
scored; the async matcher only suggests jobs. Stored CVs are viewed in
Job Matching and the Recruitment Inbox.
/candidate/cv-bank/upload individually parsed and stored as a private
bank row: no job, no user account, no inbox entry, no scoring. The bank
is listed right below the dropzone and is where stored CVs are browsed,
downloaded and (later) picked up for a job.
============================================================ */
import { useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
@ -37,9 +36,9 @@ const SCORE_STEPS = [
const STORE_STEPS = [
{ i: 'file', t: 'Resume parsing', d: 'PDF text extraction with layout cleanup' },
{ i: 'users', t: 'Candidate identified', d: 'Email auto-detected from the CV — a CV without one is rejected' },
{ i: 'target', t: 'Job suggestions', d: 'Fitting jobs are suggested in the background — nothing is scored or assigned' },
{ i: 'user-plus', t: 'Saved to CV bank', d: 'Stored unassigned — view in Job Matching or the Recruitment Inbox' },
{ i: 'users', t: 'Details captured', d: 'Candidate email is picked up when the CV contains one' },
{ i: 'target', t: 'Nothing else happens', d: 'No scoring, no candidate account, no inbox entry — just stored' },
{ i: 'user-plus', t: 'Saved to CV bank', d: 'Browse, download or remove stored CVs in the bank below' },
]
async function fetchJobs() {
@ -108,18 +107,17 @@ export default function CvImport() {
},
})
/* No-Job mode: one request per file, so one unreadable CV (or one with no
detectable email) fails alone and the rest of the batch still lands. */
/* No-Job mode: one request per file, so one unreadable CV fails alone and
the rest of the batch still lands in the bank. */
const storing = useMutation({
mutationFn: async ({ files, rowIds }) => {
const results = []
for (let k = 0; k < files.length; k++) {
try {
const res = await candidatesApi.uploadCv(files[k])
const res = await candidatesApi.uploadToCvBank(files[k])
results.push({ rowId: rowIds[k], ok: true, email: res?.data?.candidate_email ?? null })
} catch (err) {
const code = err?.data?.detail?.error_code
results.push({ rowId: rowIds[k], ok: false, error: code === 'CANDIDATE_EMAIL_REQUIRED' ? 'NO_EMAIL' : 'FAILED' })
} catch {
results.push({ rowId: rowIds[k], ok: false, error: 'FAILED' })
}
}
return results
@ -134,7 +132,7 @@ export default function CvImport() {
: { ...item, status: 'Failed', error: r.error }
}),
)
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
const ok = results.filter((r) => r.ok).length
const bad = results.length - ok
toast(
@ -281,15 +279,13 @@ export default function CvImport() {
{i.status === 'Ready' && i.critique && (
<div className="cell-sub" style={{ marginTop: 4 }}>{i.critique}</div>
)}
{i.status === 'Stored' && i.email && (
<div className="cell-sub" style={{ marginTop: 4 }}>Candidate email: {i.email}</div>
{i.status === 'Stored' && (
<div className="cell-sub" style={{ marginTop: 4 }}>
{i.email ? `Candidate email: ${i.email}` : 'No email in the CV — stored anyway'}
</div>
)}
{i.status === 'Failed' && (
<div className="cell-sub" style={{ marginTop: 4 }}>
{i.error === 'NO_EMAIL'
? 'No email found in this CV — add the candidate manually with an email instead'
: 'Could not be processed'}
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>Could not be processed</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
@ -331,26 +327,9 @@ export default function CvImport() {
</div>
</div>
{/* No-Job mode has no scored grid — point at where the bank is browsed. */}
{/* No-Job mode swaps the scored grid for the bank itself. */}
{noJobMode ? (
<div className="card mt-18">
<div className="card-body flex items-center gap-16 flex-wrap">
<span className="kpi-icn i-teal" style={{ width: 44, height: 44, borderRadius: 12, flexShrink: 0 }}>
<Icon name="talent" />
</span>
<div style={{ flex: 1, minWidth: 220 }}>
<div className="fw-600">Stored CVs live in the CV bank</div>
<div className="text-muted text-sm">
They stay unassigned until you attach them to a job review them with suggested
matches in Job Matching, or browse them in the Recruitment Inbox.
</div>
</div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<Link className="btn btn-secondary" to="/matching"><Icon name="target" /> Job Matching</Link>
<Link className="btn btn-secondary" to="/inbox"><Icon name="inbox" /> Inbox</Link>
</div>
</div>
</div>
<CvBank />
) : (
/* Everything ever scored against the selected job this batch, earlier
uploads and synced inbox CVs alike. The scoring mutation invalidates
@ -360,3 +339,87 @@ export default function CvImport() {
</div>
)
}
/* The stored-CV bank a private store with no job, account or inbox entry.
This list is the bank's home: browse, download, or remove; picking a CV up
for a job later is a future action. */
function CvBank() {
const { toast } = useToast()
const qc = useQueryClient()
const bankQuery = useQuery({
queryKey: qk.cvBank.list(),
queryFn: () => candidatesApi.listCvBank({ top: 200 }),
})
const rows = Array.isArray(bankQuery.data?.data) ? bankQuery.data.data : []
const removing = useMutation({
mutationFn: (id) => candidatesApi.deleteCvBankCv(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
toast('CV removed from the bank', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
})
async function download(row) {
try {
await candidatesApi.downloadCvBankCv(row.id)
} catch (err) {
toast(friendlyAuthError(err, 'Could not download the CV'), 'error')
}
}
return (
<div className="card mt-18">
<div className="card-head">
<div>
<h3>CV Bank</h3>
<span className="ch-sub">
{bankQuery.isSuccess ? `${bankQuery.data?.total ?? rows.length} stored CV${(bankQuery.data?.total ?? rows.length) === 1 ? '' : 's'} · no job attached` : 'Stored CVs with no job attached'}
</span>
</div>
</div>
<div className="card-body">
{bankQuery.isLoading && <p className="text-muted text-sm">Loading stored CVs</p>}
{bankQuery.isError && (
<p className="text-muted text-sm">{friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}</p>
)}
{bankQuery.isSuccess && rows.length === 0 && (
<EmptyState icon="file" title="The CV bank is empty">
Drop CVs above with No job store in CV bank selected and they will be kept here.
</EmptyState>
)}
{rows.map((r) => (
<div className="upload-row" key={r.id}>
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
<div className="cell-sub">
{r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
</div>
</div>
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
<Icon name="download" />
</button>
<button
className="act-btn"
data-tip="Remove"
aria-label="Remove CV from bank"
disabled={removing.isPending}
onClick={() => {
if (window.confirm(`Remove “${r.file_name}” from the CV bank? The file is deleted permanently.`)) {
removing.mutate(r.id)
}
}}
>
<Icon name="trash" />
</button>
</div>
</div>
))}
</div>
</div>
)
}