CV bank: production-safe file storage + candidates visibility
Deploy to S3 / deploy (push) Successful in 37s Details

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 <noreply@anthropic.com>
pull/29/head
Talha Ahmed 2026-08-27 19:38:40 +05:00
parent 0b19f3ce08
commit 1ae5249d18
5 changed files with 148 additions and 22 deletions

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@
<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-CVZNz-Ck.js"></script>
<script type="module" crossorigin src="/assets/index-OBhFgWnT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C1VjJy57.css">
</head>
<body>

View File

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