Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/core-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=7
REFRESH_COOKIE_SECURE=false
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173

# MinIO / S3 (local dev — matches docker-compose)
MINIO_ENDPOINT=127.0.0.1:9000
MINIO_ACCESS_KEY=minio_admin
MINIO_SECRET_KEY=minio_password
MINIO_SECURE=false
MINIO_BUCKET_RESUMES=resumes

# Internal parsing service (resume extraction)
PARSING_SERVICE_URL=http://127.0.0.1:8002/internal/v1
5 changes: 4 additions & 1 deletion apps/core-api/src/api/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from fastapi import APIRouter

from src.api.routes import auth, jobs, organizations, system
from src.api.routes import applications, auth, jobs, organizations, system

api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(organizations.router)
api_router.include_router(jobs.router)
api_router.include_router(applications.router)
api_router.include_router(applications.applicant_router)
api_router.include_router(applications.detail_router)
api_router.include_router(system.router)
183 changes: 183 additions & 0 deletions apps/core-api/src/api/routes/applications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Bulk resume upload routes under a job."""

from __future__ import annotations

from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query, status

from src.api.deps import DbSession, require_roles
from src.models.enums import UserRole
from src.models.user import User
from src.schemas.application import (
ApplicationDetailResponse,
ApplicantListItem,
BulkCreateRequest,
BulkCreateResponse,
JobFitRunResponse,
UploadUrlsRequest,
UploadUrlsResponse,
)
from src.services import application_service, job_service

router = APIRouter(
prefix="/jobs/{job_id}/applications",
tags=["Candidate Applications"],
)
applicant_router = APIRouter(
prefix="/jobs/{job_id}/applicants",
tags=["Candidate Applications"],
)
detail_router = APIRouter(
prefix="/applications",
tags=["Candidate Applications"],
)

JobActor = Annotated[
User,
Depends(
require_roles(
UserRole.hr, UserRole.organization_admin, UserRole.super_admin
)
),
]


def _assert_job_access(user: User, job) -> None:
if user.role == UserRole.super_admin:
return
if user.organization_id != job.organization_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot access another organization",
)


@router.post(
"/upload-urls",
response_model=UploadUrlsResponse,
summary="Issue pre-signed S3 PUT URLs for HR bulk resume upload",
)
def create_upload_urls(
job_id: UUID,
body: UploadUrlsRequest,
db: DbSession,
current_user: JobActor,
) -> UploadUrlsResponse:
job = job_service.get_job(db, job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
_assert_job_access(current_user, job)
try:
application_service.assert_job_accepts_applications(job)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
try:
return application_service.create_upload_urls(job, body.files)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to create upload URLs: {exc}",
) from exc


@router.post(
"/bulk",
response_model=BulkCreateResponse,
status_code=status.HTTP_202_ACCEPTED,
summary="Register S3-uploaded resumes and process each independently",
)
def bulk_create_applications(
job_id: UUID,
body: BulkCreateRequest,
db: DbSession,
current_user: JobActor,
) -> BulkCreateResponse:
job = job_service.get_job(db, job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
_assert_job_access(current_user, job)
try:
return application_service.enqueue_bulk_resumes(job, body)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc


@applicant_router.get(
"",
response_model=list[ApplicantListItem],
summary="List applicants for a job sorted by score",
)
def list_applicants(
job_id: UUID,
db: DbSession,
current_user: JobActor,
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=50),
) -> list[ApplicantListItem]:
job = job_service.get_job(db, job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
_assert_job_access(current_user, job)
return application_service.list_applicants(db, job_id=job_id, page=page, limit=limit)


@router.post(
"/{application_id}/rerun-fit",
response_model=JobFitRunResponse,
summary="Recalculate job-fit for an application",
)
def rerun_job_fit(
job_id: UUID,
application_id: UUID,
db: DbSession,
current_user: JobActor,
) -> JobFitRunResponse:
job = job_service.get_job(db, job_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
_assert_job_access(current_user, job)

application = application_service.get_application(db, application_id)
if application is None or application.job_description_id != job_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found",
)
try:
return application_service.rerun_job_fit(db, application=application)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc


@detail_router.get(
"/{application_id}",
response_model=ApplicationDetailResponse,
summary="View application details with parsed resume and match data",
)
def get_application(
application_id: UUID,
db: DbSession,
current_user: JobActor,
) -> ApplicationDetailResponse:
application = application_service.get_application(db, application_id)
if application is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found",
)

job = application.job_description
if job is None:
job = job_service.get_job(db, application.job_description_id)
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
_assert_job_access(current_user, job)
return application_service.application_to_detail_response(application)
21 changes: 17 additions & 4 deletions apps/core-api/src/api/routes/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,13 @@ def create_job(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
detail = str(exc)
code = (
status.HTTP_502_BAD_GATEWAY
if "unavailable" in detail.lower()
else status.HTTP_400_BAD_REQUEST
)
raise HTTPException(status_code=code, detail=detail) from exc
return JobResponse.model_validate(job)


Expand Down Expand Up @@ -140,5 +144,14 @@ def update_job(
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
_assert_job_access(current_user, job)
job = job_service.update_job(db, job, body)
try:
job = job_service.update_job(db, job, body)
except ValueError as exc:
detail = str(exc)
code = (
status.HTTP_502_BAD_GATEWAY
if "unavailable" in detail.lower()
else status.HTTP_400_BAD_REQUEST
)
raise HTTPException(status_code=code, detail=detail) from exc
return JobUpdateResponse.model_validate(job)
16 changes: 16 additions & 0 deletions apps/core-api/src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,26 @@ class Settings(BaseSettings):
refresh_cookie_samesite: str = "lax"
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"

# MinIO / S3 (local dev defaults match docker-compose)
minio_endpoint: str = "127.0.0.1:9000"
minio_access_key: str = "minio_admin"
minio_secret_key: str = "minio_password"
minio_secure: bool = False
minio_bucket_resumes: str = "resumes"
s3_presign_expires_seconds: int = 900

# Internal parsing service (resume extraction)
parsing_service_url: str = "http://127.0.0.1:8002/internal/v1"

@property
def cors_origin_list(self) -> list[str]:
origins = [o.strip() for o in self.cors_origins.split(",") if o.strip()]
return origins or ["*"]

@property
def s3_endpoint_url(self) -> str:
scheme = "https" if self.minio_secure else "http"
return f"{scheme}://{self.minio_endpoint}"


settings = Settings()
109 changes: 109 additions & 0 deletions apps/core-api/src/schemas/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Application bulk upload request/response schemas."""

from __future__ import annotations

from datetime import datetime
from decimal import Decimal
from uuid import UUID

from pydantic import BaseModel, Field, field_validator

ALLOWED_RESUME_CONTENT_TYPES = frozenset(
{
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
)


class UploadFileRequest(BaseModel):
file_name: str = Field(min_length=1, max_length=512)
content_type: str = Field(min_length=1, max_length=255)

@field_validator("file_name", mode="before")
@classmethod
def strip_file_name(cls, value: object) -> object:
if isinstance(value, str):
return value.strip()
return value

@field_validator("content_type")
@classmethod
def validate_content_type(cls, value: str) -> str:
normalized = value.strip().lower()
if normalized not in ALLOWED_RESUME_CONTENT_TYPES:
raise ValueError(
"content_type must be application/pdf or "
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
return normalized


class UploadUrlsRequest(BaseModel):
files: list[UploadFileRequest] = Field(min_length=1, max_length=50)


class UploadUrlItem(BaseModel):
file_name: str
s3_key: str
upload_url: str
expires_at: datetime


class UploadUrlsResponse(BaseModel):
uploads: list[UploadUrlItem]


class BulkResumeItem(BaseModel):
s3_key: str = Field(min_length=1, max_length=1024)
file_name: str = Field(min_length=1, max_length=512)

@field_validator("file_name", "s3_key", mode="before")
@classmethod
def strip_strings(cls, value: object) -> object:
if isinstance(value, str):
return value.strip()
return value


class BulkCreateRequest(BaseModel):
resumes: list[BulkResumeItem] = Field(min_length=1, max_length=50)


class BulkCreateResponse(BaseModel):
job_id: UUID
queued: int


class ApplicantListItem(BaseModel):
id: UUID
job_description_id: UUID
candidate_id: UUID
status: str
candidate_yoe: float | None = None
resume_score: Decimal | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
phone: str | None = None


class JobFitRunResponse(BaseModel):
application_id: UUID
status: str
resume_score: Decimal | None = None


class ApplicationDetailResponse(BaseModel):
id: UUID
job_description_id: UUID
candidate_id: UUID
status: str
candidate_yoe: float | None = None
resume_score: Decimal | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
phone: str | None = None
parsed_resume: dict | None = None
job_fit_analysis: dict | None = None
3 changes: 2 additions & 1 deletion apps/core-api/src/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
refresh_access_token,
revoke_refresh_token,
)
from src.services import job_service, organization_service, platform_service, user_service
from src.services import application_service, job_service, organization_service, platform_service, user_service

__all__ = [
"OrgAuthError",
Expand All @@ -21,6 +21,7 @@
"issue_token_pair",
"refresh_access_token",
"revoke_refresh_token",
"application_service",
"job_service",
"organization_service",
"platform_service",
Expand Down
Loading