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()}` : ''} +
+
+
+ + +
+
+ ))} +
+
+ ) +}