From 9f610a9078cccb8e98f0fb134e973fd8fd89e39a Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 27 Aug 2026 19:20:38 +0500 Subject: [PATCH] CV bank: private storage for No-Job CV imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/job/app.py | 88 ++++++++++++++++++ backend/job/candidate/models.py | 55 ++++++++++++ frontend/dist/index.html | 4 +- frontend/src/api/candidates.js | 28 ++++-- frontend/src/lib/queryKeys.js | 4 + frontend/src/screens/CvImport.jsx | 145 +++++++++++++++++++++--------- 6 files changed, 274 insertions(+), 50 deletions(-) diff --git a/backend/job/app.py b/backend/job/app.py index 034c28a..3c1c49d 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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=.""" + 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(...), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 09a72e6..54aa258 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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): diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 351c9de..cbca26d 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -24,8 +24,8 @@ - - + +
diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index d1c854b..1afa119 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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 } }) } /** diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 1c750f2..be22737 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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], diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 9ad848a..d544420 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -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 && (
{i.critique}
)} - {i.status === 'Stored' && i.email && ( -
Candidate email: {i.email}
+ {i.status === 'Stored' && ( +
+ {i.email ? `Candidate email: ${i.email}` : 'No email in the CV — stored anyway'} +
)} {i.status === 'Failed' && ( -
- {i.error === 'NO_EMAIL' - ? 'No email found in this CV — add the candidate manually with an email instead' - : 'Could not be processed'} -
+
Could not be processed
)}
@@ -331,26 +327,9 @@ export default function CvImport() {
- {/* 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 ? ( -
-
- - - -
-
Stored CVs live in the CV bank
-
- 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. -
-
-
- Job Matching - Inbox -
-
-
+ ) : ( /* 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() { ) } + +/* 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 ( +
+
+
+

CV Bank

+ + {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'} + +
+
+
+ {bankQuery.isLoading &&

Loading stored CVs…

} + {bankQuery.isError && ( +

{friendlyAuthError(bankQuery.error, 'Could not load the CV bank')}

+ )} + {bankQuery.isSuccess && rows.length === 0 && ( + + Drop CVs above with “No job — store in CV bank” selected and they will be kept here. + + )} + {rows.map((r) => ( +
+ +
+
{r.file_name || 'CV'}
+
+ {r.candidate_email || 'No email detected'} + {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''} +
+
+
+ + +
+
+ ))} +
+
+ ) +}