HR-ATS-Portal/backend
Talha Ahmed a68d4bc2f6 Integrate bulk-ats scoring into backend: candidates table + scoring routes
Embeds the tested bulk-ats engine (pip install -e ., app.services.*) rather
than duplicating it. New Candidates table (auto-migrated) persists one row per
CV per job, deduped on (job_id, content_sha256) so re-scores update in place.
CandidateScoring service extracts (pypdf off-loop), despaces, scores with
bounded concurrency + cache priming via llm_setup's shared client, and
isolates per-file failures as failed rows. Routes: /candidate/score (uploads),
/candidate/score_inbox (decoded attachments), /candidate/fetch,
/candidate/fetch_by_id. Complements the agent inbox-match flow: it routes,
this scores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 21:44:04 +05:00
..
agent agent coide simplifgication 2026-08-10 16:24:47 +05:00
forget_password login confirmation and forget password done 2026-08-04 20:40:42 +05:00
inbox agent coide simplifgication 2026-08-10 16:24:47 +05:00
job Integrate bulk-ats scoring into backend: candidates table + scoring routes 2026-08-10 21:44:04 +05:00
migrations jwt token integrated 2026-08-04 14:33:07 +05:00
notifications login confirmation and forget password done 2026-08-04 20:40:42 +05:00
role permission grandted 2026-08-04 17:14:45 +05:00
taskiq_management Background saving terminated 2026-08-07 18:26:37 +05:00
users LINKEDIN POST CONFIG DONE AND TESTED 2026-08-05 20:09:34 +05:00
.env.example Integrate bulk-ats scoring into backend: candidates table + scoring routes 2026-08-10 21:44:04 +05:00
Dockerfile . 2026-08-06 20:21:48 +05:00
LLM_CONTEXT_PROMPT.md permission grandted 2026-08-04 17:14:45 +05:00
README.md readme file 2026-08-10 17:23:10 +05:00
alembic.ini stashed 2026-08-03 15:42:38 +05:00
alembic_setup.py pdf product converted 2026-08-03 19:55:01 +05:00
db_setup.py pdf product converted 2026-08-03 19:55:01 +05:00
llm_setup.py mapped Candidate CV with job_Post_id 2026-08-06 18:53:18 +05:00
main.py . 2026-08-06 20:21:48 +05:00
requirements.txt Integrate bulk-ats scoring into backend: candidates table + scoring routes 2026-08-10 21:44:04 +05:00

README.md

HR-ATS-Portal — Backend

FastAPI service behind TalentFlow, an HR / Applicant-Tracking portal. It pulls candidate applications out of a mailbox, decodes CV attachments, runs an LLM agent to match each CV against live job posts, publishes job ads to social channels through Buffer, and serves the whole thing to the frontend behind JWT auth and a database-driven RBAC model.

Everything in this document describes backend/ only.


Table of contents


Architecture

flowchart TB
    FE["Frontend (Vite, :5173)"] -->|JWT Bearer| API

    subgraph API["FastAPI — main.py :8000"]
        R1["users / role / forget_password / notifications"]
        R2["inbox"]
        R3["job (job_post + candidate)"]
    end

    API --> PG[("PostgreSQL — schema app")]
    API -->|enqueue| REDIS[("Redis Streams")]

    REDIS --> W["Taskiq worker\ninbox.tasks + inbox.sync_tasks"]
    SCHED["Taskiq scheduler\ncron"] --> REDIS
    W --> PG
    W --> AGENT["LangGraph agent\nagent/"]
    AGENT --> OAI["OpenAI"]

    API -->|GET /emails, /sync/read-status| MAILAPI["Email API (MS Graph proxy)"]
    W --> MAILAPI
    API -->|multipart send| TEAMS["Teams Mail API"]
    API -->|GraphQL| BUF["Buffer"]

The application flow, end to end:

  1. GET /email/fetch pulls messages from the external Email API, decodes PDF/DOC/DOCX attachments to inbox/decoded_attachments/, and upserts them into inbox_messages.
  2. When a message carries an attachment, the sender is linked to a users row — created with the candidate role if new — through the inbox join table.
  3. Any message with a stored attachment and no match result yet is enqueued onto Redis as an inbox.match_message task.
  4. The worker extracts the résumé text, hands it plus the active job posts to the LangGraph agent, and writes suggested_job_post_ids, match_summary, match_reasoning and experience back onto the row.
  5. New candidate accounts land inactive and are mailed a confirmation link; the link is what flips is_active.
  6. A cron task sweeps Outlook read-status deltas back onto inbox_messages.message_read.
  7. Recruiters read all of it through /inbox/all-applications and publish new roles with POST /job/post-job, which renders the ad copy and pushes it to Buffer.

Tech stack

Concern Choice
Web framework FastAPI 0.136 + Uvicorn
ORM / models SQLModel 0.0.38 on SQLAlchemy 2.0 (async, asyncpg)
Database PostgreSQL, application objects in the app schema
Migrations Alembic 1.18, driven by alembic_setup.py
Auth PyJWT (HS256) access / refresh / reset tokens, bcrypt hashing
Task queue Taskiq on Redis Streams, with a smart-retry + dead-letter middleware
LLM OpenAI async client, orchestrated by LangGraph
PDF extraction pypdf
HTTP client httpx
Python 3.12 (see Dockerfile)

Directory layout

backend/
├── main.py                   # FastAPI app, lifespan, CORS, router mounting
├── db_setup.py               # Settings, async engine, sessions, init_db/lifespan
├── alembic_setup.py          # Alembic scaffolding, autogenerate, migrate-on-boot
├── llm_setup.py              # AsyncOpenAI client + llm_call helper
├── requirements.txt
├── Dockerfile                # image for the Taskiq worker / scheduler
├── alembic.ini               # generated by alembic_setup.py, not hand-written
├── migrations/               # generated env.py + versions/
├── LLM_CONTEXT_PROMPT.md     # house-style prompt to paste into an LLM before editing
│
├── users/                    # accounts, login, signup, RBAC enforcement
├── role/                     # roles, permission bundles, permission tags
├── forget_password/          # reset-code request → verify → new password
├── notifications/            # email-confirmation tokens and mail
├── inbox/                    # mailbox sync, attachments, applications
├── job/
│   ├── app.py                # routes for both sub-domains
│   ├── job_post/             # job ads + Buffer publishing
│   └── candidate/            # CV reading, candidate profile
├── agent/                    # LangGraph CV → job-post matching agent
└── taskiq_management/        # broker, scheduler, DLQ middleware, smoke task

There are no __init__.py files. The service is run from backend/, so imports are top-level (from users.app import router, from db_setup import get_session).


House style — what lives in which file

Every domain package follows the same six-file shape. This is enforced by convention, and LLM_CONTEXT_PROMPT.md is the canonical statement of it.

File Owns Must not do
app.py Routes, inline request models, JSONResponse, dependency injection Business rules, SQL
views.py Business checks, calls models, raises HTTPException Build login token envelopes
models.py SQLModel table + @classmethod async def accessors Import FastAPI, raise HTTPException
serializers.py Hand-built dict builders (serialize_*) Touch the DB or Depends
plugins.py Pure helpers — hashing, JWT, HTTP calls to third parties Import FastAPI
permissions.py Bearer schemes and Depends aliases (auth domains only) Hold route handlers

Additional rules that matter when you edit this code:

  • Request bodies are Pydantic models declared inline in app.py, never in serializers.py.
  • There are no Pydantic response models — responses are hand-built dicts.
  • Route paths are verb-in-path (/users/create, /users/fetch), not REST-resource-only, and there is no /api/v1 prefix.
  • Non-DB config is module-level load_dotenv() + os.getenv(...). Only database settings go through db_setup.Settings.

Domains

users/

Signup, login, refresh, CRUD, role assignment, and the RBAC machinery every other domain depends on. users/permissions.py defines the full PermissionTag vocabulary (13 modules × 8 actions = 104 tags) and the require_permission(...) dependency. A startup assertion (_assert_vocabulary_complete) fails loudly if the tag list ever drifts from PermissionModule × PermissionAction.

Signup always assigns the candidate role, creates the account inactive, and sends a confirmation email. Login rejects unconfirmed accounts.

role/

Three-level permission model: permission_tags (atomic module.action rows) → permissions (named bundles holding a JSONB array of tag ids) → roles (holding a JSONB array of bundle ids). Roles.resolve_tags() walks that chain and returns a flat tuple of tag names; dangling or inactive ids simply contribute nothing rather than erroring.

Eight system roles are seeded: system_administrator, hr_administrator, recruiter, hiring_manager, department_head, interviewer, ceo, candidate.

inbox/

The heart of the ingestion pipeline.

  • views.py::Email talks to the external Email API, decodes attachments, upserts messages, and enqueues matching work.
  • models.py holds Inbox_Messages (the mail rows plus all agent output columns), Inbox_Alerts, and Inbox — the join table linking a message to the candidate Users row it came from. _link_sender creates the candidate account on first contact, skipping noreply@-style senders.
  • file_decoder.py turns Graph contentBytes into real PDF / DOCX / DOC files, validating magic bytes for each format and stripping path traversal from filenames.
  • plugins.py resolves attachment paths (handling Windows paths written by the host API but read from a Linux worker), extracts résumé text, and calls the read-status sync endpoints.
  • tasks.py / sync_tasks.py are the two Taskiq tasks.

job/

Two sub-domains behind one router:

  • job_post/ — renders LinkedIn-shaped ad copy from a structured payload, resolves the Buffer channel (by explicit id, by platform alias, or by the configured default), creates the post over Buffer's GraphQL API, and records the mapped status. A queued post is recorded as scheduled, not published; only Buffer reporting sent promotes it.
  • candidate/FileRead extracts text from an uploaded PDF (pypdf), and match_inbox_cv force-requeues an existing inbox message for matching. CandidateView reads the candidate profile through the inbox join.

notifications/ and forget_password/

Two parallel token flows, deliberately kept separate so each owns its own mail copy and env reads:

  • Confirmation — a 32-byte url-safe secret, bcrypt-hashed in email_confirmation_tokens; the link carries <row_id>.<secret> because a bcrypt hash cannot be looked up. Replays (mail scanners, back button) are handled idempotently.
  • Password reset — a short code mailed to the user, bcrypt-hashed in password_reset_codes, with a resend cooldown and a max-attempts cap. Verifying the code mints a type=reset JWT carrying the code row id (crid), which is the only thing that authorises the new-password call.

agent/

LangGraph state machine — see The matching agent.

taskiq_management/

Broker, scheduler, DLQ middleware, and a ping smoke task.


Data model

All tables live in the app schema (DB_DEFAULT_SCHEMA), with a shared naming convention for indexes, constraints and foreign keys. SQLModel.metadata is pointed at Base.metadata in db_setup.py so SQLModel and DeclarativeBase share one registry and Alembic sees everything.

Table Key columns Notes
users id (uuid PK), email (unique), role_idroles.id, password, is_active, is_deleted Soft delete. role is selectin-loaded; lazy loads would raise MissingGreenlet under asyncio
roles id, role_name (unique), permissions (JSONB int[]), is_system
permissions id, name (unique), permission_tags (JSONB int[]) Named bundles
permission_tags id, tag_name (unique), module, action Unique on (module, action)
inbox_messages id (uuid), message_id (upstream id, unique), full_email_response (JSONB), subject/body/from/to/cc/bcc, message_read, attachment, file_name, file_path, application_status, resume_text, experience, suggested_job_post_ids (JSONB), match_summary, match_reasoning, match_status, match_error, matched_at One row per mail; agent output lands here
inbox id, user_idusers.id, message_idinbox_messages.id, alert_id Join table linking a candidate to a message
inbox_alerts id, alert_sender_name, alert_sender_email, is_read
job_posts id (uuid), title, platform, channel_id, post_text, requirements/optional_skills (JSON), status, buffer_post_id, buffer_external_link, buffer_sent_at, buffer_error, created_byusers.id
password_reset_codes id, email, code_hash, expires_at, attempts, is_used, verified_at
email_confirmation_tokens id, user_id, email, token_hash, expires_at, is_used, confirmed_at

application_status is a str enum: PROCESS, PENDING, APPROVED, REJECTED, ONHOLD, CLOSED.

match_status is free-form text written by the worker: processing, matched, skipped, no_text, failed, dlq.


API reference

Base URL: http://localhost:8000. Interactive docs at /docs.

Auth — users/app.py, notifications/app.py, forget_password/app.py

Method Path Guard Purpose
POST /users/signup public Create a candidate account (inactive) and mail a confirmation link
POST /users/login public Email/username + password → token envelope
POST /users/refresh public Refresh token → new token pair
GET /users/me any authenticated user Current user with resolved permissions
POST /users/confirm-email public Consume a confirmation token, activate the account
POST /users/confirm-email/resend public Re-issue a confirmation link (cooldown enforced)
POST /users/forget-password public Mail a reset code
POST /users/forget-password/verify-code public Verify the code → type=reset JWT
POST /users/forget-password/new-password reset JWT Set the new password

Users — users/app.py

Method Path Required tag
GET /users/fetch rbac_users.view
POST /users/create rbac_users.create
PUT /users/update?record_id= rbac_users.edit
PUT /users/assign-role?record_id= rbac_users.edit (+ rbac_users.manage inside the service)
PUT /users/remove-role?record_id= rbac_users.edit (+ rbac_users.manage)
DELETE /users/delete?record_id= rbac_users.delete

Roles and permissions — role/app.py

Method Path Required tag
GET /roles/fetch rbac_users.view
POST /roles/create rbac_users.create
PUT /roles/update?record_id= rbac_users.edit
DELETE /roles/delete?record_id= rbac_users.delete
GET /permissions/fetch rbac_users.view
POST /permissions/create rbac_users.manage
PUT /permissions/update?record_id= rbac_users.manage
GET /permission-tags/fetch rbac_users.view

Inbox — inbox/app.py

Method Path Guard Purpose
GET /email/fetch upstream token only Pull from the Email API, decode attachments, upsert, enqueue matching. test_on=true (default) returns raw payloads and skips the account-setup mails
GET /inbox/fetch none Stored messages, with attachments inlined as base64
GET /inbox/all-applications inbox.view The Applications tab. Filters: application_status, isread, search, record_id, top, skip
POST /inbox/{record_id}/match inbox.edit Force a re-match of one message
POST /inbox/{record_id}/read inbox.edit Mark read locally
GET /inbox/{record_id}/read-status inbox.edit Re-pull read status from upstream for one message

Jobs and candidates — job/app.py

Method Path Required tag Purpose
GET /jobs/alias public Accepted platform shorthands (fb, ig, li, x, …)
POST /job/post-job job_board.create Render the ad, create the Buffer post, persist the result
GET /job/buffer/channels job_board.view Connected Buffer channels across all organizations
POST /candidate/cv_upload candidates.create Upload a PDF, get extracted text back
POST /candidate/inbox-match?inbox_message_id= candidates.edit Queue a forced re-match for a stored message
GET /candidate/fetch?user_id= candidates.view Candidate profile via the inbox join

POST /job/post-job takes modeaddToQueue | shareNow | customScheduled; the customScheduled mode requires scheduler_date (and optionally scheduler_time), which the route combines into a UTC due_at.


Authentication and RBAC

Tokens. PyJWT, HS256, with a type claim that decode_token(..., expected_type=...) rejects on mismatch. Three types:

Type Lifetime (default) Extra claims
access 30 min email, role_id
refresh 7 days
reset 10 min crid (reset-code row id)

iat / exp are always timezone-aware UTC. Every token carries a jti.

Login response puts the OAuth2 fields at the root so Swagger's Authorize button can read them:

{
  "access_token": "...",
  "refresh_token": "...",
  "token_type": "bearer",
  "expires_in": 1800,
  "data": { "id": "...", "email": "...", "role_id": 3, "role_name": "recruiter", "...": "..." },
  "status_code": 200
}

Passwords use bcrypt directly rather than passlib — passlib 1.7.4 reads bcrypt.__about__.__version__, which bcrypt dropped in 4.1, and the failed probe makes it reject every password as over 72 bytes. Input is truncated to 72 bytes on a character boundary before hashing.

Authorization. get_current_user decodes the access token, loads the user by sub, rejects missing / deleted / inactive accounts, resolves the role's tags, and returns a serialized user dict with a permissions list. require_permission(*tags, require_all=True) is the dependency that guards routes; a user with no role assigned gets a 403 before any tag check runs.

Role assignment is additionally guarded in users/views.py: you cannot grant a role holding permissions you do not yourself hold.


Background jobs

Taskiq over Redis Streams, with a result backend and a Redis-backed schedule source.

Middleware order mattersDeadLetterMiddleware sits before SmartRetryMiddleware so a PermanentTaskError can set retry_on_error=False before retry logic runs.

Task Trigger What it does
inbox.match_message enqueued by /email/fetch, /inbox/{id}/match, /candidate/inbox-match Extract résumé text → run the agent → write match results
inbox.sync_read_status cron, EMAIL_SYNC_CRON (default every minute) Pull read-status deltas from the Email API and apply them
ping manual Framework smoke test

Retries. SmartRetryMiddleware with jitter and exponential delay, TASKIQ_MAX_RETRIES attempts, capped at TASKIQ_MAX_DELAY. Raising PermanentTaskError (missing record, no attachment, blank record_id) skips retries entirely.

Dead letter queue. Exhausted or permanently-failed tasks are written to the taskiq:dlq Redis stream as a JSON payload. For inbox.match_message specifically, the middleware also stamps match_status="dlq" on the row so the failure is visible in the UI rather than only in Redis.

The read-status latch. apply_read_status only ever applies false → true. Nothing pushes local reads back to Outlook, so upstream keeps reporting isRead=false; without the latch the every-minute sweep would un-read a mail the user just opened. The trade-off is that un-reading a mail in Outlook no longer propagates here. sync_read_status holds a Redis lock (inbox:sync_read_status:lock, 300s TTL) so overlapping cron ticks cannot double-run, and pages at most 10 rounds per tick.


The matching agent

A small LangGraph StateGraph over an AgentState TypedDict.

START → prepare → (conditional) → match_jobs → END
                └→ END        (when resume_text or job_posts is empty)
  • prepare_context trims the subject and résumé text and normalizes the job posts down to the fields the model needs. Empty résumé text or no active posts short-circuits to status="skipped".
  • match_jobs calls llm_call(prompt(), user_prompt(state), json_mode=True) and parses the response.

The parser (agent/decorators.py::parse_match_response) is deliberately strict: a suggested id must be present in the posts that were actually sent, must be a valid UUID, and duplicates are dropped. The model cannot invent a job post. reasoning returned as a list is joined into a string; non-string fields fall back to "".

The graph is compiled once per process (init_agent() from the FastAPI lifespan, or lazily on first use inside the worker) and the OpenAI client is created lazily and closed on shutdown.

Startup is fault-tolerant: if the broker or the LLM/agent fails to initialize, main.py logs a warning and the API still serves. Only the database is a hard requirement.


External integrations

Service Used by Contract
Email API (a Microsoft Graph proxy) inbox/ GET {EMAIL_URL}/emails, GET {EMAIL_URL}/emails/{id}, GET {EMAIL_URL}/sync/read-status, GET {EMAIL_URL}/sync/read-status/message/{id} — Bearer EMAIL_API_TOKEN
Teams Mail API notifications/, forget_password/ multipart POST to TEAMS_MAIL_API_URL; success is HTTP 202, anything else raises
Buffer job/job_post/ GraphQL against BUFFER_API_URLcreatePost mutation, account { organizations } and channels queries
OpenAI agent/, llm_setup.py Chat Completions with response_format: json_object

Attachments are written to backend/inbox/decoded_attachments/. In Docker this directory is bind-mounted into the worker so both processes see the same files.


Configuration

Copy .env.example to .env and fill it in. .env is git-ignored; .env.example is not. db_setup.Settings reads backend/.env or the repo-root .env; every other module reads its own keys with os.getenv.

Database

Variable Default Notes
DB_USERNAME, DB_PASSWORD, DB_HOST, DB_PORT, DB_NAME Required. DB_PORT must be an integer
DATABASE_URL Full DSN; wins over the parts above
DB_SSLMODE e.g. require on Azure; translated to asyncpg's ssl
DB_SCHEMAS app Comma-separated; created on startup
DB_DEFAULT_SCHEMA app Schema for models that declare none
DB_ECHO false SQL logging
DB_POOL_SIZE / DB_MAX_OVERFLOW / DB_POOL_RECYCLE 5 / 10 / 1800
DB_CONNECT_RETRIES 10 Startup wait-for-Postgres
DB_AUTO_MIGRATE true Run upgrade head on startup
DB_AUTOGENERATE true Write a revision when models drift
APP_NAME hr-ats-portal Postgres application_name

Auth

Variable Default
JWT_SECRET_KEY — (required)
JWT_ALGORITHM HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS 7
JWT_RESET_TOKEN_EXPIRE_MINUTES 10

Email ingestion

Variable Default
EMAIL_URL, EMAIL_API_TOKEN
EMAIL_SYNC_FOLDER inbox
EMAIL_SYNC_SINCE
EMAIL_SYNC_CRON * * * * *
BACKEND_URL http://localhost:8000 (used for the internal confirmation-resend call)
DEFAULT_CANDIDATE_PASSWORD Utopia!@# — placeholder only; the account is inactive until confirmed

Mail out

Variable Default
TEAMS_MAIL_API_URL, TEAMS_API_TOKEN
FRONTEND_URL http://localhost:5173
CONFIRM_EMAIL_PATH /auth/confirm-email
CONFIRM_TOKEN_TTL_SECONDS 86400
CONFIRM_TOKEN_RESEND_SECONDS 60
RESET_CODE_TTL_SECONDS 60
RESET_CODE_RESEND_SECONDS 30
RESET_CODE_MAX_ATTEMPTS 5

Buffer

Variable Default
BUFFER_API — (access token)
BUFFER_API_URL https://api.buffer.com
BUFFER_CHANNEL_ID — (fallback channel)

OpenAI

Variable Default
OPENAI_API_KEY
OPENAI_MODEL gpt-5.4-mini
OPENAI_TEMPERATURE 0 — leave blank to omit the parameter for models that reject it
OPENAI_MAX_OUTPUT_TOKENS 32768 (.env.example ships 4096)
OPENAI_TIMEOUT / OPENAI_MAX_RETRIES / OPENAI_CONNECT_RETRIES 60 / 3 / 3
OPENAI_BASE_URL, OPENAI_ORGANIZATION, OPENAI_PROJECT — (set only for Azure or a gateway)

Taskiq / Redis

Variable Default
REDIS_URL redis://localhost:6379/0
TASKIQ_QUEUE_NAME inbox
TASKIQ_CONSUMER_GROUP taskiq
TASKIQ_MAX_RETRIES 3
TASKIQ_RETRY_DELAY 5
TASKIQ_MAX_DELAY 120
TASKIQ_IDLE_TIMEOUT_MS 600000
TASKIQ_DLQ_STREAM taskiq:dlq
TASKIQ_WORKER_NAME falls back to HOSTNAME
APP_VERSION dev

Running locally

Prerequisites: Python 3.12, PostgreSQL, Redis.

cd backend

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

cp .env.example .env               # then fill it in

All commands must be run from backend/ — the import paths depend on it.

API:

uvicorn main:app --reload --port 8000

Startup connects to Postgres (retrying with backoff), creates the configured schemas, runs migrations to head, then starts the broker, the OpenAI client and the agent graph. Broker and LLM failures are logged and skipped; the API still comes up.

Worker (needs Redis):

taskiq worker taskiq_management.broker_setup:broker \
  inbox.tasks inbox.sync_tasks taskiq_management.tasks

Scheduler (cron ticks for inbox.sync_read_status):

taskiq scheduler taskiq_management.broker_setup:scheduler inbox.sync_tasks

Docs: http://localhost:8000/docs


Database migrations

alembic_setup.py wraps Alembic so the plain alembic CLI and the app's own migrate-on-startup share one configuration. It scaffolds alembic.ini, migrations/env.py and script.py.mako on first use and never overwrites them. Model modules are discovered automatically — every <package>/models.py under backend/ is imported before the metadata is diffed.

python alembic_setup.py migrate            # upgrade to head, then autogenerate any drift
python alembic_setup.py revision -m "add x" # write a revision if the models have drifted
python alembic_setup.py upgrade -r head
python alembic_setup.py downgrade -r -1
python alembic_setup.py current
python alembic_setup.py head

Migrations run under a Postgres advisory lock, so several workers booting at once cannot migrate concurrently. Empty revisions are suppressed. Alembic's own alembic_version table is excluded from autogenerate, as is anything outside the configured schemas.

The module is named alembic_setup rather than alembic because backend/ is on sys.path and a module called alembic.py would shadow the installed package.


Docker

The repo-root docker-compose.yml runs Redis plus the two Taskiq processes; the API itself is expected to run on the host (the compose file points the containers at host.docker.internal for the database).

docker compose up -d          # from the repo root
docker compose logs -f taskiq-worker

backend/Dockerfile builds a python:3.12-slim image whose default command is the Taskiq worker. backend/inbox/decoded_attachments is bind-mounted so the worker can read the attachments the API wrote.


Response conventions

Every handler wraps its body in the same try/except:

try:
    service=Email(session=session)
    data=await service.some_method(...)
    return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
    raise
except Exception as e:
    raise HTTPException(status_code=500,detail=str(e))
Shape Response
List {"data": [...], "total": <n>, "status_code": 200}
Single record {"data": {...}, "total": 1, "status_code": 200}
Login / refresh OAuth2 fields at the root, user under data
Error FastAPI's {"detail": "..."} with the real status code

Serializers always str() UUIDs, .isoformat() datetimes, and never emit password.


Known gaps and gotchas

  • DB_PORT must be set. db_setup.Settings evaluates int(os.getenv("DB_PORT")) at class definition time, so a missing value raises TypeError on import rather than a friendly config error.
  • CORS is fully open (allow_origins=["*"] with credentials). Fine for development, needs tightening before production.
  • /email/fetch and /inbox/fetch carry no permission guard. /email/fetch authenticates only against the upstream Email API token.
  • .doc / .docx résumés are decoded and stored but not parsed. extract_resume_text handles PDFs only and reports no PDF attachment to extract for the rest.
  • serialize_application returns null for ats_score, phone, recruiter and duplicateinbox_messages has no columns for them yet, and processing is derived from message_read alone, so it is only ever "Read" or "Unread".
  • Inbox.get_candidate_profile filters on cls.user.role_id, which is a relationship attribute rather than a joined column; the candidate-profile query needs a join before it behaves as intended.
  • Attachment paths may be Windows absolutes written by the host API but read by a Linux worker. resolve_attachment_path normalizes separators and falls back to the basename under the mounted decoded_attachments directory.
  • Read status is a one-way latch — see Background jobs.
  • There is no test suite in backend/ at present.

Editing this codebase

Before changing anything here, read LLM_CONTEXT_PROMPT.md. It states the house style in full and is the reference used to keep new code indistinguishable from users/ and inbox/. The short version: mirror the neighbouring file, keep the layer duties intact, add no new layers, and do not reformat code you did not otherwise need to touch.

Adding an endpoint, in order:

  1. Model accessor in models.py (if it touches the DB).
  2. Service method in views.py.
  3. serialize_* in serializers.py if the shape is new.
  4. Route in app.py with the standard try/except and JSONResponse.
  5. CurrentUser or Depends(require_permission(...)) if the route is protected.