From 1ab52b7292b33114397a0cafb0c0056f53fd4cb7 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 27 Aug 2026 21:03:06 +0500 Subject: [PATCH] canidate flow --- backend/.env.example | 16 ++ backend/job/app.py | 24 +- backend/job/candidate/models.py | 23 ++ backend/job/candidate/views.py | 56 +++- backend/main.py | 2 + .../manual/009_interviews_user_job.sql | 29 ++ backend/requirements.txt | 4 + backend/s3/app.py | 77 +++++ backend/s3/plugins.py | 268 ++++++++++++++++++ backend/s3/serializers.py | 33 +++ backend/s3/views.py | 79 ++++++ frontend/src/screens/Candidates.jsx | 15 + frontend/src/screens/CvImport.jsx | 16 +- 13 files changed, 622 insertions(+), 20 deletions(-) create mode 100644 backend/migrations/manual/009_interviews_user_job.sql create mode 100644 backend/s3/app.py create mode 100644 backend/s3/plugins.py create mode 100644 backend/s3/serializers.py create mode 100644 backend/s3/views.py diff --git a/backend/.env.example b/backend/.env.example index b36bb8e..32c06ab 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -123,5 +123,21 @@ UVICORN_WORKERS=2 # VITE_API_BASE=http://127.0.0.1:8000 (backend is published on BACKEND_PORT). VITE_API_BASE= +# --- AWS S3 (s3/) — permanent public object URLs (not presigned) ------------ +# Bucket from your console, e.g. hr-ats-416818527652-us-east-2-an +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-2 +S3_BUCKET= +# Optional CDN / custom domain. Blank → https://{bucket}.s3.{region}.amazonaws.com/{key} +S3_PUBLIC_BASE_URL= +# Leave blank when ACLs are disabled (Object Ownership = Bucket owner enforced). +# Use public-read only if the bucket still allows ACLs. +S3_OBJECT_ACL= +# CV object keys (after DB row exists): +# Email/{inbox_messages.id}/{user_id}/{file}.pdf +# Manual/{manual_upload_candidate.id}/{user_id}/{file}.pdf +# Form/{form_data.id}/{recruiter_id}/{file}.pdf + LOG_FORMAT=json LOG_LEVEL=INFO diff --git a/backend/job/app.py b/backend/job/app.py index 65c6380..c67c05c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -179,17 +179,22 @@ async def create_manual_candidate( current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), session: AsyncSession = Depends(get_session), ): - saved_path=None try: + # Gate before any parse / DB / S3 work — only PDFs proceed. + from s3.plugins import S3ServiceError,assert_pdf + try: + assert_pdf(file.filename or "resume.pdf",file.content_type) + except S3ServiceError as e: + raise HTTPException(status_code=e.status_code,detail=e.message) from e + file_content = await file.read() logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)") reader=FileRead(session=session,filename=file.filename,file=file_content) - # Parse first: an unreadable PDF is a 400, and doing it before the write - # keeps a file that can never back a row off the disk entirely. + # Parse first: an unreadable PDF is a 400 before any table row exists. parsed=await reader.injest_manual_upload() - saved=await reader.save_manual_upload() - saved_path=saved.get("file_path") service=CandidateView(session=session) + # Atomicity lives in create_candidate: insert row → S3 Manual/{id}/{user_id}/ + # → set file_path; on S3 failure the row is deleted. data=await service.create_candidate( candidate_email=candidate_email, candidate_name=candidate_name, @@ -201,19 +206,16 @@ async def create_manual_candidate( experience=experience, status=status, referral_by=referral_by, - file_name=saved.get("file_name"), - file_path=saved_path, + file_name=file.filename, full_text=parsed.get("text") or "", current_user=current_user.get("id"), + file_bytes=file_content, + content_type=file.content_type, ) return JSONResponse(content={"data":data,"status_code":200}) except HTTPException: - # create_candidate rejects a blank email with a 422 AFTER the file has - # landed, so without this every such attempt would leave an orphan PDF. - 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/fetch/users") diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 9e755c6..915ff00 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -226,6 +226,29 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def delete_by_id(cls, session: AsyncSession, record_id): + """Hard-delete one row — used to roll back when S3 upload fails after insert.""" + row=await cls.get_by_id(session,record_id) + if not row: + return False + session.delete(row) + await session.commit() + return True + + @classmethod + async def set_file_path(cls, session: AsyncSession, record_id, file_path, file_name=None): + row=await cls.get_by_id(session,record_id) + if not row: + return None + row.file_path=(file_path or "").strip() + if file_name is not None: + row.file_name=(file_name or "").strip() + session.add(row) + await session.commit() + await session.refresh(row) + return row + @classmethod async def get_by_user_id(cls, session: AsyncSession, user_id): uid = cls._as_uuid(user_id) diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index c5ecb49..d65cf23 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -540,13 +540,31 @@ class CandidateView: band=(msg.ats_band or "").strip() or None return msg.ats_score,band or CandidateView._recommendation(msg.ats_score) - async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None): + async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None,file_bytes=None,content_type=None): + """Create manual_upload_candidate, then S3 upload under Manual/{id}/{user_id}/. + + Atomicity: if S3 fails after the row insert, the row is deleted (rolled back). + PDF gate runs before any DB write when file_bytes is supplied. + """ + from s3.plugins import S3,S3ServiceError,S3Source,assert_pdf + + row=None try: email=(candidate_email or "").strip().lower() if not email: raise HTTPException(status_code=422,detail="candidate_email is required") if not current_user: raise HTTPException(status_code=400,detail="created_by is required") + + original_name=(file_name or "").strip() or "resume.pdf" + if file_bytes is not None: + try: + original_name=assert_pdf(original_name,content_type) + except S3ServiceError as e: + raise HTTPException(status_code=e.status_code,detail=e.message) from e + if not file_bytes: + raise HTTPException(status_code=422,detail="file is empty") + data={ "candidate_email":email, "candidate_name":(candidate_name or "").strip(), @@ -559,13 +577,36 @@ class CandidateView: "experience":(experience or "").strip(), "status":(status or "").strip(), "referral_by":(referral_by or "").strip(), - "file_name":(file_name or "").strip(), - "file_path":(file_path or "").strip(), + "file_name":original_name, + # path filled after S3 succeeds; never leave a local orphan path here + "file_path":(file_path or "").strip() if file_bytes is None else "", "full_text":full_text or "", "created_by":current_user, - } row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data) + + if file_bytes is not None: + try: + uploaded=S3().upload_for_record( + file_bytes, + original_name, + source=S3Source.MANUAL, + record_id=row.id, + owner_id=row.user_id, + content_type=content_type, + ) + except S3ServiceError as e: + await Manual_UPLOAD_CANDIDATE.delete_by_id(self.session,row.id) + row=None + raise HTTPException(status_code=e.status_code,detail=e.message) from e + except Exception: + await Manual_UPLOAD_CANDIDATE.delete_by_id(self.session,row.id) + row=None + raise + row=await Manual_UPLOAD_CANDIDATE.set_file_path( + self.session,row.id,uploaded["url"],file_name=uploaded.get("filename") or original_name, + ) + await HistoryRecorder(self.session).record( HistoryEvent.CANDIDATE_CREATED.value, actor_id=current_user,user_id=row.user_id, @@ -574,7 +615,7 @@ class CandidateView: to_value=row.candidate_email, description=(row.platform or "").strip() or "manual_upload",commit=True, ) - if (row.file_name or "").strip(): + if (row.file_name or "").strip() and (row.file_path or "").strip(): await HistoryRecorder(self.session).record( HistoryEvent.DOCUMENT_UPLOADED.value, actor_id=current_user,user_id=row.user_id, @@ -586,6 +627,11 @@ class CandidateView: except HTTPException: raise except Exception as e: + if row is not None: + try: + await Manual_UPLOAD_CANDIDATE.delete_by_id(self.session,row.id) + except Exception: + logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None)) raise HTTPException(status_code=500,detail=str(e)) async def get_candidate(self,user_id=None,limit=10,offset=0,search=None): diff --git a/backend/main.py b/backend/main.py index 5cda791..d630463 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,6 +23,7 @@ from interview.app import router as interview_router from talent.app import router as talent_router from candidate_forms.app import router as candidate_forms_router from g_sheet.app import router as g_sheet_router +from s3.app import router as s3_router logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logger=logging.getLogger("main") @@ -129,3 +130,4 @@ app.include_router(interview_router) app.include_router(talent_router) app.include_router(candidate_forms_router) app.include_router(g_sheet_router) +app.include_router(s3_router) diff --git a/backend/migrations/manual/009_interviews_user_job.sql b/backend/migrations/manual/009_interviews_user_job.sql new file mode 100644 index 0000000..e1edb16 --- /dev/null +++ b/backend/migrations/manual/009_interviews_user_job.sql @@ -0,0 +1,29 @@ +-- 009_interviews_user_job.sql +-- Add optional user_id / job_post_id on interviews so scheduling can target a +-- candidate user without requiring an inbox application row. When inbox_id is +-- supplied, create resolves user_id + job_post_id from that application. +-- Applied at startup by alembic_setup.run_manual_sql(). + +ALTER TABLE app.interviews + ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES app.users(id); + +ALTER TABLE app.interviews + ADD COLUMN IF NOT EXISTS job_post_id UUID REFERENCES app.job_posts(id); + +CREATE INDEX IF NOT EXISTS ix_interviews_user_id ON app.interviews (user_id); + +-- Backfill from existing inbox links. +UPDATE app.interviews AS i +SET user_id = inbox.user_id +FROM app.inbox AS inbox +WHERE i.inbox_id = inbox.id + AND i.user_id IS NULL + AND inbox.user_id IS NOT NULL; + +UPDATE app.interviews AS i +SET job_post_id = m.assigned_job_post_id +FROM app.inbox AS inbox +JOIN app.inbox_messages AS m ON m.id = inbox.message_id +WHERE i.inbox_id = inbox.id + AND i.job_post_id IS NULL + AND m.assigned_job_post_id IS NOT NULL; diff --git a/backend/requirements.txt b/backend/requirements.txt index 3cec005..39032c9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,3 +50,7 @@ openpyxl==3.1.5 google-api-python-client==2.198.0 # Sheets v4 client in g_sheet/plugins.py google-auth==2.56.3 # ADC + refresh in g_sheet/plugins.py google-auth-httplib2==0.4.1 # transport used by googleapiclient + +# --- AWS S3 (s3/) ---------------------------------------------------------- +boto3==1.40.49 # S3 PutObject / DeleteObject in s3/plugins.py +botocore==1.40.49 # ClientError mapping; pin matches aiobotocore's range diff --git a/backend/s3/app.py b/backend/s3/app.py new file mode 100644 index 0000000..441dad6 --- /dev/null +++ b/backend/s3/app.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter,Depends,File,Form,HTTPException,Query,UploadFile +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from s3.views import S3Storage +from users.permissions import PermissionTag,require_permission +from dotenv import load_dotenv +load_dotenv() + +router=APIRouter() + + +class DeleteObjectBody(BaseModel): + key: str + + +@router.get("/s3/health") +async def s3_health(): + """Bucket reachability — unauthenticated like GET /sheet/health.""" + try: + service=S3Storage() + data=await service.health_check() + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/s3/upload") +async def upload_s3_file( + file: UploadFile=File(...), + source: str=Form(...), + record_id: str=Form(...), + owner_id: str=Form(...), + current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_CREATE,PermissionTag.SETTINGS_EDIT,require_all=False)), +): + """PDF only. Requires an existing table row — key is {source}/{record_id}/{owner_id}/{file}.pdf.""" + try: + service=S3Storage() + data=await service.upload_for_record(file,source=source,record_id=record_id,owner_id=owner_id) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/s3/url") +async def fetch_s3_url( + key: str=Query(...), + current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW,PermissionTag.SETTINGS_VIEW,require_all=False)), +): + """Recompute the permanent URL for an existing key (no S3 round trip).""" + try: + service=S3Storage() + data=await service.object_url(key) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/s3/delete") +async def delete_s3_file( + payload: DeleteObjectBody, + current_user: dict=Depends(require_permission(PermissionTag.CANDIDATES_DELETE,PermissionTag.SETTINGS_DELETE,require_all=False)), +): + try: + service=S3Storage() + data=await service.delete_file(payload.key) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/s3/plugins.py b/backend/s3/plugins.py new file mode 100644 index 0000000..5257e1d --- /dev/null +++ b/backend/s3/plugins.py @@ -0,0 +1,268 @@ +"""S3 helpers — boto3 client class, upload/delete, permanent object URLs. + +No FastAPI imports (house rule). Raise S3ServiceError; s3/views.py maps to HTTPException. + +Permanent links: we NEVER return expiring presigned URLs. The URL is the virtual-hosted +HTTPS object address, which stays valid until the object is deleted (or the bucket +policy stops public GetObject). + +CV keys are record-scoped (atomicity): DB row is created first, then upload uses that id: + + Email/{table_record_id}/{user_id}/{file_name}.pdf + Manual/{table_record_id}/{user_id}/{file_name}.pdf + Form/{table_record_id}/{recruiter_id}/{file_name}.pdf + +Callers that create the row MUST delete it if upload_for_record fails. +""" + +from __future__ import annotations + +import logging +import mimetypes +import os +import re +from pathlib import Path + +import boto3 +from botocore.client import BaseClient +from botocore.exceptions import BotoCoreError,ClientError +from dotenv import load_dotenv + +load_dotenv() + +logger=logging.getLogger("s3.plugins") + +AWS_ACCESS_KEY_ID=os.getenv("AWS_ACCESS_KEY_ID","").strip() +AWS_SECRET_ACCESS_KEY=os.getenv("AWS_SECRET_ACCESS_KEY","").strip() +AWS_REGION=(os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "us-east-2").strip() +S3_BUCKET=os.getenv("S3_BUCKET","").strip() +# Optional CDN / custom domain. Blank → https://{bucket}.s3.{region}.amazonaws.com/{key} +S3_PUBLIC_BASE_URL=os.getenv("S3_PUBLIC_BASE_URL","").strip().rstrip("/") + +# modern buckets often have ACLs disabled; leave blank and rely on bucket policy. +S3_OBJECT_ACL=os.getenv("S3_OBJECT_ACL","").strip() # e.g. public-read + +_SAFE_NAME=re.compile(r"[^A-Za-z0-9._-]+") +_PDF_MIME=frozenset({"application/pdf","application/x-pdf"}) + + +class S3Source: + """Top-level folder names — keep spelling exact for console browsing.""" + EMAIL="Email" + MANUAL="Manual" + FORM="Form" + ALL=frozenset({EMAIL,MANUAL,FORM}) + + +class S3ServiceError(Exception): + """Raised for config / boto failures — views translate to HTTPException.""" + + def __init__(self,message,status_code=500): + super().__init__(message) + self.message=str(message) + self.status_code=int(status_code) + + +def sanitize_filename(name: str) -> str: + raw=(name or "").strip() or "file" + base=Path(raw).name + cleaned=_SAFE_NAME.sub("_",base).strip("._") or "file" + return cleaned[:180] + + +def guess_content_type(filename: str,fallback: str="application/octet-stream") -> str: + guessed,_=mimetypes.guess_type(filename or "") + return guessed or fallback + + +def assert_pdf(filename: str,content_type: str | None=None) -> str: + """Gate: only .pdf (and PDF MIME when provided). Returns sanitized basename.""" + safe=sanitize_filename(filename) + if not safe.lower().endswith(".pdf"): + raise S3ServiceError("Only PDF files are allowed",status_code=415) + mime=(content_type or "").strip().lower().split(";")[0].strip() + # browsers sometimes send application/octet-stream for PDFs — allow that + # only when the extension already passed; reject every other non-PDF MIME. + if mime and mime not in _PDF_MIME and mime!="application/octet-stream": + raise S3ServiceError(f"Only PDF MIME types are allowed (got {mime})",status_code=415) + return safe + + +def normalize_source(source: str) -> str: + raw=(source or "").strip() + if not raw: + raise S3ServiceError("source is required (Email|Manual|Form)",status_code=422) + # accept case-insensitive input, store canonical folder casing + for name in S3Source.ALL: + if raw.lower()==name.lower(): + return name + raise S3ServiceError( + f"source must be one of {', '.join(sorted(S3Source.ALL))}", + status_code=422, + ) + + +class S3: + """One boto3 client + bucket config — upload / delete / URL / health share this.""" + + def __init__(self,client: BaseClient | None=None): + self._require_config() + self.bucket=S3_BUCKET + self.region=AWS_REGION + self.public_base_url=S3_PUBLIC_BASE_URL + self.object_acl=S3_OBJECT_ACL + self.client=client or boto3.client( + "s3", + region_name=self.region, + aws_access_key_id=AWS_ACCESS_KEY_ID, + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, + ) + + @staticmethod + def _require_config(): + missing=[name for name,val in ( + ("AWS_ACCESS_KEY_ID",AWS_ACCESS_KEY_ID), + ("AWS_SECRET_ACCESS_KEY",AWS_SECRET_ACCESS_KEY), + ("S3_BUCKET",S3_BUCKET), + ) if not val] + if missing: + raise S3ServiceError( + f"S3 is not configured — set {', '.join(missing)} in backend/.env", + status_code=500, + ) + + def _raise_boto(self,exc,action,key=None,status_code=502): + """Map ClientError / BotoCoreError → S3ServiceError (single place).""" + if isinstance(exc,ClientError): + code=(exc.response or {}).get("Error",{}).get("Code") or "" + logger.exception("s3 %s failed key=%s code=%s",action,key,code) + raise S3ServiceError(f"S3 {action} failed: {code or exc}",status_code=status_code) from exc + logger.exception("s3 %s botocore failure key=%s",action,key) + raise S3ServiceError(f"S3 {action} failed: {exc}",status_code=status_code) from exc + + def build_record_object_key( + self, + *, + source: str, + record_id, + owner_id, + filename: str, + ) -> str: + """{Email|Manual|Form}/{table_record_id}/{user_or_recruiter_id}/{file}.pdf""" + folder=normalize_source(source) + rid=str(record_id or "").strip() + oid=str(owner_id or "").strip() + if not rid: + raise S3ServiceError("table_record_id is required before S3 upload",status_code=422) + if not oid: + raise S3ServiceError("owner_id (user_id / recruiter_id) is required before S3 upload",status_code=422) + safe=assert_pdf(filename) + return f"{folder}/{rid}/{oid}/{safe}" + + def permanent_object_url(self,key: str) -> str: + """Stable HTTPS URL for a public object — does not expire.""" + object_key=(key or "").lstrip("/") + if not object_key: + raise S3ServiceError("object key is required",status_code=422) + if self.public_base_url: + return f"{self.public_base_url}/{object_key}" + if not self.bucket: + raise S3ServiceError("S3_BUCKET is not configured",status_code=500) + return f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{object_key}" + + def upload_bytes( + self, + body: bytes, + filename: str, + *, + content_type: str | None=None, + key: str | None=None, + ) -> dict: + """PutObject + permanent URL. Prefer upload_for_record for CV flows.""" + if body is None: + raise S3ServiceError("file body is required",status_code=422) + if not key: + raise S3ServiceError( + "object key is required — use upload_for_record after the DB row exists", + status_code=422, + ) + safe=assert_pdf(filename,content_type) + object_key=key.lstrip("/") + ctype=content_type if (content_type or "").strip().lower().startswith("application/pdf") else "application/pdf" + extra={} + if self.object_acl: + extra["ACL"]=self.object_acl + try: + self.client.put_object( + Bucket=self.bucket, + Key=object_key, + Body=body, + ContentType=ctype, + **extra, + ) + except (ClientError,BotoCoreError) as e: + self._raise_boto(e,"upload",key=object_key) + url=self.permanent_object_url(object_key) + return { + "bucket":self.bucket, + "key":object_key, + "url":url, + "content_type":ctype, + "size":len(body), + "filename":safe, + } + + def upload_for_record( + self, + body: bytes, + filename: str, + *, + source: str, + record_id, + owner_id, + content_type: str | None=None, + ) -> dict: + """Atomic CV path: requires an existing table row id, then PutObject. + + Callers MUST roll back (delete) the table row if this raises. + """ + key=self.build_record_object_key( + source=source, + record_id=record_id, + owner_id=owner_id, + filename=filename, + ) + result=self.upload_bytes(body,filename,content_type=content_type,key=key) + result["source"]=normalize_source(source) + result["record_id"]=str(record_id) + result["owner_id"]=str(owner_id) + return result + + def delete_object(self,key: str) -> dict: + """DeleteObject — after this the permanent URL 404s.""" + object_key=(key or "").lstrip("/") + if not object_key: + raise S3ServiceError("object key is required",status_code=422) + try: + self.client.delete_object(Bucket=self.bucket,Key=object_key) + except (ClientError,BotoCoreError) as e: + self._raise_boto(e,"delete",key=object_key) + return {"bucket":self.bucket,"key":object_key,"deleted":True} + + def head_bucket(self) -> dict: + """Reachability probe — credentials + bucket exist.""" + try: + self.client.head_bucket(Bucket=self.bucket) + except ClientError as e: + code=(e.response or {}).get("Error",{}).get("Code") or "" + status=403 if code in ("403","AccessDenied","AllAccessDisabled") else 502 + self._raise_boto(e,"head_bucket",status_code=status) + except BotoCoreError as e: + self._raise_boto(e,"head_bucket") + base=self.public_base_url or f"https://{self.bucket}.s3.{self.region}.amazonaws.com" + return { + "bucket":self.bucket, + "region":self.region, + "status":"ok", + "public_base_url":base, + } diff --git a/backend/s3/serializers.py b/backend/s3/serializers.py new file mode 100644 index 0000000..6b862bc --- /dev/null +++ b/backend/s3/serializers.py @@ -0,0 +1,33 @@ +"""S3 response shapes. Plain dicts only — no DB, no Depends.""" + + +def serialize_upload(result: dict) -> dict: + """upload result → API dict (permanent url, never a presign).""" + return { + "bucket": result.get("bucket"), + "key": result.get("key"), + "url": result.get("url"), + "content_type": result.get("content_type"), + "size": result.get("size"), + "filename": result.get("filename"), + "source": result.get("source"), + "record_id": result.get("record_id"), + "owner_id": result.get("owner_id"), + } + + +def serialize_delete(result: dict) -> dict: + return { + "bucket": result.get("bucket"), + "key": result.get("key"), + "deleted": bool(result.get("deleted")), + } + + +def serialize_health(result: dict) -> dict: + return { + "status": result.get("status") or "ok", + "bucket": result.get("bucket"), + "region": result.get("region"), + "public_base_url": result.get("public_base_url"), + } diff --git a/backend/s3/views.py b/backend/s3/views.py new file mode 100644 index 0000000..0a8a899 --- /dev/null +++ b/backend/s3/views.py @@ -0,0 +1,79 @@ +"""S3 storage service — upload / delete / health over the plugins S3 class.""" + +from fastapi import HTTPException,UploadFile + +from s3.plugins import S3,S3ServiceError,assert_pdf +from s3.serializers import serialize_delete,serialize_health,serialize_upload + + +class S3Storage: + """No DB session — pure object storage against the configured bucket.""" + + def __init__(self): + self.s3=S3() + + def _map(self,exc:S3ServiceError): + raise HTTPException(status_code=exc.status_code,detail=exc.message) + + async def health_check(self): + try: + return serialize_health(self.s3.head_bucket()) + except S3ServiceError as e: + self._map(e) + + async def upload_for_record(self,file:UploadFile,source,record_id,owner_id): + """PDF gate → PutObject under {source}/{record_id}/{owner_id}/{name}.pdf.""" + if file is None: + raise HTTPException(status_code=422,detail="file is required") + filename=(file.filename or "").strip() or "resume.pdf" + try: + assert_pdf(filename,file.content_type) + except S3ServiceError as e: + self._map(e) + body=await file.read() + if not body: + raise HTTPException(status_code=422,detail="file is empty") + try: + result=self.s3.upload_for_record( + body, + filename, + source=source, + record_id=record_id, + owner_id=owner_id, + content_type=file.content_type, + ) + return serialize_upload(result) + except S3ServiceError as e: + self._map(e) + + async def upload_bytes_for_record(self,body,filename,source,record_id,owner_id,content_type=None): + try: + assert_pdf(filename or "resume.pdf",content_type) + result=self.s3.upload_for_record( + body, + filename or "resume.pdf", + source=source, + record_id=record_id, + owner_id=owner_id, + content_type=content_type, + ) + return serialize_upload(result) + except S3ServiceError as e: + self._map(e) + + async def delete_file(self,key): + if not key or not str(key).strip(): + raise HTTPException(status_code=422,detail="key is required") + try: + return serialize_delete(self.s3.delete_object(str(key).strip())) + except S3ServiceError as e: + self._map(e) + + async def object_url(self,key): + if not key or not str(key).strip(): + raise HTTPException(status_code=422,detail="key is required") + try: + url=self.s3.permanent_object_url(str(key).strip()) + return {"key":str(key).strip(),"url":url} + except S3ServiceError as e: + self._map(e) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index de1904e..8aa609a 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -778,6 +778,21 @@ function AddCandidate({ onClose, onSave, onInvalid }) { function pickFile(next) { if (!next) return + const name = (next.name || '').toLowerCase() + const mime = (next.type || '').toLowerCase() + // Gate at the picker — never hold a non-PDF in state or post it. + if (!name.endsWith('.pdf')) { + setCv(null) + form.setErrors((prev) => ({ ...prev, cv: 'Only PDF resumes are allowed' })) + toast('Only PDF files are allowed', 'error') + return + } + if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') { + setCv(null) + form.setErrors((prev) => ({ ...prev, cv: 'Only PDF MIME types are allowed' })) + toast('Only PDF files are allowed', 'error') + return + } setCv(next) form.setErrors((prev) => { if (!prev.cv) return prev diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index 590939e..0a1a6a4 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -99,10 +99,18 @@ export default function CvImport() { toast('Select a job to score against first', 'warning') return } - const files = all.filter((f) => f.name.toLowerCase().endsWith('.pdf')) - const skipped = all.length - files.length - if (skipped) toast(`Only PDF resumes are supported — ${skipped} file(s) skipped`, 'warning') - if (!files.length) return + const bad = all.filter((f) => { + const name = (f.name || '').toLowerCase() + const mime = (f.type || '').toLowerCase() + if (!name.endsWith('.pdf')) return true + if (mime && mime !== 'application/pdf' && mime !== 'application/x-pdf') return true + return false + }) + if (bad.length) { + toast('Only PDF files are allowed — remove non-PDF uploads and try again', 'error') + return + } + const files = all const items = files.map((f) => ({ id: `UP-${++rowSeq}-${Date.now()}`,