From 1ae5249d18e8bd907660d81535f68d56782bcd1a Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 27 Aug 2026 19:38:40 +0500 Subject: [PATCH] CV bank: production-safe file storage + candidates visibility The banked PDF now lives IN the database (new cv_bank_files table, manual migration 010, auto-applied at startup) instead of the container filesystem, so production redeploys cannot lose a stored CV; upload writes row + bytes in one commit and creates no disk file at all. New GET /candidate/cv-bank/file serves the bytes for both download and the in-app preview. Per user request, banking a CV with a detectable email also creates or reactivates the candidate account (same pattern as manual add, no setup email), so banked people appear on the Candidates screen; a CV without an email still banks fine, account-less. Existing bank rows were backfilled locally. E2E-verified: upload -> bank row + DB bytes + zero disk files, preview and download served from the DB, candidate visible on the Candidates screen. Co-Authored-By: Claude Fable 5 --- backend/job/app.py | 50 +++++++++-- backend/job/candidate/models.py | 85 +++++++++++++++++-- .../migrations/manual/010_cv_bank_files.sql | 21 +++++ frontend/dist/index.html | 2 +- frontend/src/api/candidates.js | 12 +-- 5 files changed, 148 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/manual/010_cv_bank_files.sql diff --git a/backend/job/app.py b/backend/job/app.py index 3c1c49d..ae54afe 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -265,26 +265,31 @@ async def cv_bank_upload( ): """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.""" + recruiter picks it up. Email/name are captured only if the CV contains + them. The PDF bytes go INTO the database (cv_bank_files), never onto the + container filesystem, so production redeploys cannot lose a stored CV.""" + from pathlib import PurePosixPath,PureWindowsPath from job.candidate.models import Manual_UPLOAD_CANDIDATE from job.candidate.plugins import extract_candidate_email - saved_path=None try: content=await file.read() + if len(content)>15*1024*1024: + raise HTTPException(status_code=413,detail="CV must be under 15 MB") 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) + # Basename against both separator styles — a Windows client sends + # C:\Users\x\cv.pdf whose PosixPath name is the whole string. + original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf" 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, + file_name=original, created_by=current_user.get("id"), + pdf_bytes=content, ) return JSONResponse(content={"data":{ "id":str(row.id), @@ -293,10 +298,8 @@ async def cv_bank_upload( "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)) @@ -326,6 +329,36 @@ async def cv_bank_fetch( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/candidate/cv-bank/file") +async def cv_bank_file( + id: str = Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + """The stored CV's bytes, straight from the database. Content-Disposition + carries the original filename so browser saves are named sensibly; the + frontend preview re-types the blob and renders it inline.""" + from urllib.parse import quote + from job.candidate.models import CvBankFiles,Manual_UPLOAD_CANDIDATE + try: + row=await Manual_UPLOAD_CANDIDATE.get_by_id(session,id) + if not row or row.apply_via!="cv_bank": + raise HTTPException(status_code=404,detail="CV not found in the bank") + file_row=await CvBankFiles.get(session,row.id) + if not file_row: + raise HTTPException(status_code=404,detail="CV file is missing") + name=file_row.file_name or row.file_name or "cv.pdf" + return Response( + content=file_row.data, + media_type=file_row.content_type or "application/pdf", + headers={"Content-Disposition":f"attachment; filename*=UTF-8''{quote(name)}"}, + ) + 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(...), @@ -337,6 +370,7 @@ async def cv_bank_delete( 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") + # Legacy rows from before bytes moved into the DB still carry a disk file. FileRead.discard_upload(row.file_path) return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200}) except HTTPException: diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 54aa258..1a2d2f5 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -249,22 +249,66 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): @classmethod async def insert_bank_cv(cls, session: AsyncSession, *, candidate_email, - candidate_name, full_text, file_name, file_path, - created_by): + candidate_name, full_text, file_name, + created_by, pdf_bytes, + content_type="application/pdf"): + """Bank a CV: metadata row + its bytes (cv_bank_files) in one commit — + the PDF lives in the database, never on the container filesystem, so a + redeploy cannot lose a stored CV. file_path stays "" by design. + + When the CV carries an email, the candidate ACCOUNT is created/reused + (same pattern as create_manual_upload_candidate) so the person shows + up on the Candidates screen; unlike an application there is still no + inbox entry, no scoring, and no setup email. A CV with no detectable + email banks fine and simply stays account-less.""" + import os + + from role.models import EnumRoles, Roles + from users.models import Users + from users.plugins import hash_password + + email = (candidate_email or "").strip().lower() + user = None + if email: + user = await Users.get_user_by_email(session, email) + if not user: + role = await Roles.get_role_by_name(session, EnumRoles.CANDIDATE.value) + user = await Users.insert_user(session, { + "name": (candidate_name or "").strip() or email, + "email": email, + "role_id": role.id if role else 8, + "password": hash_password(os.getenv("DEFAULT_CANDIDATE_PASSWORD", "Utopia!@#")), + "is_active": True, + "is_deleted": False, + }) + elif user.is_deleted or not user.is_active: + # get_user_by_email returns soft-deleted accounts too; a fresh + # bank upload means the person is relevant again. + user.is_deleted = False + user.is_active = True + session.add(user) + row = cls( - candidate_email=(candidate_email or "").strip().lower(), - candidate_name=(candidate_name or "").strip(), + candidate_email=email, + candidate_name=(candidate_name or "").strip() or (email or ""), 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, + user_id=user.id if user else None, created_by=cls._as_uuid(created_by), status="BANKED", file_name=(file_name or "").strip(), - file_path=(file_path or "").strip(), + file_path="", ) session.add(row) + await session.flush() + session.add(CvBankFiles( + manual_upload_candidate_id=row.id, + content_type=content_type, + file_name=(file_name or "").strip(), + data=pdf_bytes, + )) await session.commit() await session.refresh(row) return row @@ -287,15 +331,42 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): @classmethod async def delete_bank_cv(cls, session: AsyncSession, record_id): - """Hard delete, bank rows only — never reachable for application rows.""" + """Hard delete, bank rows only — never reachable for application rows. + The cv_bank_files row goes with it via ON DELETE CASCADE.""" row = await cls.get_by_id(session, record_id) if not row or row.apply_via != "cv_bank": return None + file_row = await CvBankFiles.get(session, row.id) + if file_row: + await session.delete(file_row) await session.delete(row) await session.commit() return row +class CvBankFiles(SQLModel, table=True): + """PDF bytes of a CV-bank entry — in the database so production redeploys + (ephemeral container filesystems) can never lose a stored CV. Created in + prod by migrations/manual/010_cv_bank_files.sql.""" + + __tablename__ = "cv_bank_files" + + manual_upload_candidate_id: uuid.UUID = Field( + primary_key=True, foreign_key="manual_upload_candidate.id", + ) + content_type: str = Field(default="application/pdf") + file_name: str | None = Field(default=None) + data: bytes + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @classmethod + async def get(cls, session: AsyncSession, manual_upload_candidate_id): + result = await session.execute( + select(cls).where(cls.manual_upload_candidate_id == manual_upload_candidate_id) + ) + return result.scalars().first() + + class Candidates(SQLModel, table=True): diff --git a/backend/migrations/manual/010_cv_bank_files.sql b/backend/migrations/manual/010_cv_bank_files.sql new file mode 100644 index 0000000..ae09070 --- /dev/null +++ b/backend/migrations/manual/010_cv_bank_files.sql @@ -0,0 +1,21 @@ +-- 010_cv_bank_files.sql +-- PDF bytes of CV-bank entries, stored IN the database. The bank's metadata +-- row lives in app.manual_upload_candidate (apply_via = 'cv_bank'); keeping +-- the file itself on the container filesystem would lose every stored CV on +-- redeploy, so bank uploads write the bytes here instead and no disk file is +-- created at all. PK doubles as the FK: one file per bank row, removed +-- automatically with it. +-- +-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql() +-- and recorded in manual_migrations (prod boots with DB_AUTOGENERATE=false and +-- never autogenerates tables). Matches CvBankFiles in +-- backend/job/candidate/models.py. + +CREATE TABLE IF NOT EXISTS app.cv_bank_files ( + manual_upload_candidate_id uuid PRIMARY KEY + REFERENCES app.manual_upload_candidate(id) ON DELETE CASCADE, + content_type varchar NOT NULL DEFAULT 'application/pdf', + file_name varchar, + data bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW() +); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index a7a1f61..d07a381 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -24,7 +24,7 @@ - + diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index b949343..4ea84a2 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -75,19 +75,19 @@ 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. */ +/** Browser-save a stored CV's PDF — served from the database (cv_bank_files). */ export function downloadCvBankCv(id) { - return downloadFile('/documents/download', { params: { manual_upload_candidate_id: id } }) + return downloadFile('/candidate/cv-bank/file', { params: { id } }) } /** * Object URL of a stored CV for IN-APP preview (no download). The route sends - * octet-stream, so the blob is re-typed to application/pdf for the browser's - * inline viewer. Caller revokes the URL when the preview closes. + * an attachment disposition, so the blob is re-typed to application/pdf for + * the browser's inline viewer. Caller revokes the URL when the preview closes. */ export function viewCvBankCv(id) { - return fetchBlobUrl('/documents/download', { - params: { manual_upload_candidate_id: id }, + return fetchBlobUrl('/candidate/cv-bank/file', { + params: { id }, type: 'application/pdf', }) }