697 lines
30 KiB
Markdown
697 lines
30 KiB
Markdown
# 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](#architecture)
|
||
- [Tech stack](#tech-stack)
|
||
- [Directory layout](#directory-layout)
|
||
- [House style — what lives in which file](#house-style--what-lives-in-which-file)
|
||
- [Domains](#domains)
|
||
- [Data model](#data-model)
|
||
- [API reference](#api-reference)
|
||
- [Authentication and RBAC](#authentication-and-rbac)
|
||
- [Background jobs](#background-jobs)
|
||
- [The matching agent](#the-matching-agent)
|
||
- [External integrations](#external-integrations)
|
||
- [Configuration](#configuration)
|
||
- [Running locally](#running-locally)
|
||
- [Database migrations](#database-migrations)
|
||
- [Docker](#docker)
|
||
- [Response conventions](#response-conventions)
|
||
- [Known gaps and gotchas](#known-gaps-and-gotchas)
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
```mermaid
|
||
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"]
|
||
REDIS --> WCV["Taskiq CV worker\ninbox.cv_tasks"]
|
||
SCHED["Taskiq scheduler\ncron"] --> REDIS
|
||
SCHEDCV["Taskiq CV scheduler\nretries"] --> REDIS
|
||
W --> PG
|
||
WCV --> PG
|
||
W --> AGENT["LangGraph agent\nagent/"]
|
||
WCV --> AGENT
|
||
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](#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_id` → `roles.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_id` → `users.id`, `message_id` → `inbox_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_by` → `users.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; extract email, persist like an emailed CV, enqueue matching on the CV stream |
|
||
| 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 `mode` ∈ `addToQueue` | `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:
|
||
|
||
```json
|
||
{
|
||
"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 matters** — `DeadLetterMiddleware` 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` onto the `inbox` stream | Extract résumé text → run the agent → write match results |
|
||
| `inbox.match_message` (CV broker) | enqueued by `/candidate/cv_upload` onto the `cv_upload` stream | Same matcher as above; isolated so uploads never sit behind `/email/fetch` backlog |
|
||
| `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_URL` — `createPost` 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_CV_QUEUE_NAME` | `cv_upload` |
|
||
| `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` |
|
||
| `MANUAL_UPLOAD_TO_ADDRESS` | `manual-cv-upload@hr-ats.local` — To address stamped on synthetic inbox rows so source resolves to `Manual CV Upload` |
|
||
| `APP_VERSION` | `dev` |
|
||
|
||
---
|
||
|
||
## Running locally
|
||
|
||
**Prerequisites:** Python 3.12, PostgreSQL, Redis.
|
||
|
||
```bash
|
||
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:**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```bash
|
||
taskiq worker taskiq_management.broker_setup:broker \
|
||
inbox.tasks inbox.sync_tasks taskiq_management.tasks
|
||
```
|
||
|
||
**CV-upload worker** (isolated stream for manual uploads):
|
||
|
||
```bash
|
||
taskiq worker taskiq_management.cv_broker_setup:cv_broker inbox.cv_tasks
|
||
```
|
||
|
||
**Scheduler** (cron ticks for `inbox.sync_read_status`):
|
||
|
||
```bash
|
||
taskiq scheduler taskiq_management.broker_setup:scheduler inbox.sync_tasks
|
||
```
|
||
|
||
**CV-upload scheduler** (retries for the CV stream):
|
||
|
||
```bash
|
||
taskiq scheduler taskiq_management.cv_broker_setup:cv_scheduler inbox.cv_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.
|
||
|
||
```bash
|
||
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 four Taskiq processes (inbox
|
||
worker/scheduler and CV-upload worker/scheduler); the API itself is expected to run on the
|
||
host (the compose file points the containers at `host.docker.internal` for the database).
|
||
|
||
```bash
|
||
docker compose up -d # from the repo root
|
||
docker compose logs -f taskiq-worker
|
||
docker compose logs -f taskiq-cv-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:
|
||
|
||
```python
|
||
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
|
||
`duplicate`** — `inbox_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](#background-jobs).
|
||
- **There is no test suite** in `backend/` at present.
|
||
|
||
---
|
||
|
||
## Editing this codebase
|
||
|
||
Before changing anything here, read [`LLM_CONTEXT_PROMPT.md`](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.
|