pull/24/head
ahmed.mujtaba 2026-08-21 13:00:22 +05:00
parent c536def585
commit a8b213be39
18 changed files with 511 additions and 232 deletions

View File

@ -31,6 +31,11 @@ frontend/
# Candidate CVs live on the bind mount, not inside an image. # Candidate CVs live on the bind mount, not inside an image.
backend/inbox/decoded_attachments/ backend/inbox/decoded_attachments/
# Alembic revision scripts stay out of images (gitignored; never ship to prod).
# Schema drift is applied filelessly at API boot when DB_AUTOGENERATE=true.
backend/migrations/versions/*.py
!backend/migrations/versions/.gitkeep
docs/ docs/
tests/ tests/
scripts/ scripts/

View File

@ -1,4 +1,38 @@
# Copy to .env and fill in. Never commit .env. # Copy to `.env` at the repo root for Compose variable substitution.
# Secrets for the app itself still live in `backend/.env` (see backend/.env.example).
# Never commit a filled `.env`.
# --- Compose / production stack ----------------------------------------------------
# Used by docker-compose.yml for the container Postgres and frontend publish port.
DB_USERNAME=postgres
DB_PASSWORD=change-me-strong-password
DB_NAME=hrms
# Public SPA port only (production). Dev overlay defaults FRONTEND_PORT to 5173.
FRONTEND_PORT=80
# API workers behind nginx (production backend-api command).
UVICORN_WORKERS=2
# Schema sync on API startup (docker compose up -d --build).
# true = upgrade (if any on-disk revisions) + apply model drift in-memory.
# Revision .py files stay gitignored and are not written on the server.
DB_AUTO_MIGRATE=true
DB_AUTOGENERATE=true
# Empty = same-origin fetches; frontend nginx proxies to backend-api.
# Set only when the API is intentionally on another origin.
VITE_API_BASE=
# Optional: override mail service URL for containers (else host.docker.internal:5000).
# EMAIL_URL=http://host.docker.internal:5000
# Optional: point containers at host Postgres instead of Compose postgres
# (also set by docker-compose.dev.yml).
# DB_HOST=host.docker.internal
# --- Bulk ATS scoring engine (also read by ats-engine / backend) --------------------
OPENAI_API_KEY= OPENAI_API_KEY=
@ -15,7 +49,6 @@ OPENAI_MODEL=gpt-5.4-mini
OPENAI_MAX_OUTPUT_TOKENS=4000 OPENAI_MAX_OUTPUT_TOKENS=4000
# none | minimal | low | medium | high | xhigh # none | minimal | low | medium | high | xhigh
# Per-model support varies; the API rejects a level the model does not implement.
OPENAI_EFFORT=low OPENAI_EFFORT=low
OPENAI_MAX_RETRIES=3 OPENAI_MAX_RETRIES=3

1
.gitignore vendored
View File

@ -57,4 +57,3 @@ frontend/dist/
**.pdf **.pdf
**_**_**.py **_**_**.py
Utopia-ai-hr-ats-portal 1.pem Utopia-ai-hr-ats-portal 1.pem
db_setup.py

116
DOCKER.md Normal file
View File

@ -0,0 +1,116 @@
# Docker
Production stack is self-contained: Postgres, Redis, API, ATS engine, Taskiq
workers, and the React SPA. Only the frontend is published on the host.
## Production
```bash
# 1. Root compose env (DB password, port, workers) — see .env.example
cp .env.example .env
# Edit DB_PASSWORD (and JWT_SECRET_KEY inside backend/.env)
# 2. App secrets (JWT, OpenAI, email, Buffer, …)
cp backend/.env.example backend/.env
# Fill JWT_SECRET_KEY, OPENAI_API_KEY, DB_* matching the Compose postgres, …
# 3. Build and start
docker compose up -d --build
docker compose ps
```
| Service | Image | Host port |
|---|---|---|
| `frontend` | `hrms-frontend:local` | `${FRONTEND_PORT:-80}` |
| `backend-api` | `hrms-backend:local` | (internal) |
| `ats-engine` | `hrms-ats-engine:local` | (internal) |
| `redis` | `redis:7-alpine` | (internal) |
| `postgres` | `hrms-postgres:local` | (internal) |
| Taskiq workers / schedulers | `hrms-backend:local` | (internal) |
Browser → `http://<host>/` → nginx (same-origin) → `backend-api:8000`.
CV files live in the `attachments-data` named volume (shared by API + workers).
### Schema / migrations (automatic)
On every `backend-api` start (`docker compose up -d --build`):
1. Fresh empty Postgres → create all tables from models and stamp a marker.
2. Otherwise → `alembic upgrade head` if any revision files exist in the image
(they normally do not — versions stay gitignored and are excluded from builds).
3. If `DB_AUTOGENERATE=true` (Compose default) → detect ORM drift (new/changed/removed
columns or tables) and apply DDL **in-memory** — no `versions/*.py` is written
on the server.
4. Apply any pending `backend/migrations/manual/*.sql` (seed/RBAC batches only).
Revision scripts under `backend/migrations/versions/` remain gitignored and are
listed in `.dockerignore` so they never ship in the production image.
Toggle with root `.env`: `DB_AUTO_MIGRATE` / `DB_AUTOGENERATE` (default `true`).
### Verify
```bash
docker compose config
curl -sf http://127.0.0.1/health # nginx → backend /health
curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1/
docker compose logs -f backend-api
```
Open the SPA, sign in, confirm `/jobs` lists requisitions (Network: `/jobs/fetch`
same-origin JSON, not HTML).
### Secrets
- Never bake `.env` into images (`.dockerignore` already excludes them).
- Require a strong `DB_PASSWORD` and `JWT_SECRET_KEY` before any real deploy.
- Root `.env` is for Compose substitution; `backend/.env` is for the app process.
### TLS
This stack serves HTTP on the frontend port. Terminate TLS at a reverse proxy or
cloud load balancer in front of port 80.
## Local development (host Postgres)
Restores published ports, bind-mounted attachments, and `--reload`:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
```
| Override | Value |
|---|---|
| `DB_HOST` | `host.docker.internal` |
| Frontend | `${FRONTEND_PORT:-5173}` |
| API | `${BACKEND_PORT:-8000}` |
| ATS | `${ATS_PORT:-8100}` |
| Redis | `${REDIS_PORT:-6379}` |
| Attachments | `./backend/inbox/decoded_attachments` |
Container Postgres still starts (production default) but is unused while
`DB_HOST=host.docker.internal`. Host Postgres must accept Docker-bridge clients
(`listen_addresses = '*'`, `pg_hba` for the bridge subnet).
For the Vite HMR loop, run `npm run dev` on the host against
`frontend/.env.development` (`VITE_API_BASE=http://127.0.0.1:8000`).
## Data migration
The Compose Postgres volume starts empty. To move an existing host database:
```bash
pg_dump -Fc hrms > hrms.dump
# with postgres published via the dev overlay, or `docker compose exec -T postgres …`
pg_restore -h 127.0.0.1 -p 5433 -U postgres -d hrms --clean --if-exists hrms.dump
```
## Useful commands
```bash
docker compose logs -f backend-api
docker compose logs -f taskiq-worker
docker compose restart backend-api
docker compose down # keep volumes
docker compose down -v # wipe postgres + attachments + redis data
```

View File

@ -115,82 +115,24 @@ docker compose up redis taskiq-worker taskiq-scheduler
## Docker ## Docker
Every service has its own image and its own container, all in one Self-contained production stack (Postgres in Compose; only the SPA is published).
[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run** See **[DOCKER.md](DOCKER.md)** for env checklist, verification, TLS notes, and the
— it sits behind a compose profile, and the stack talks to the PostgreSQL server local host-Postgres overlay.
already running on the host.
```bash ```bash
docker compose build cp .env.example .env # set DB_PASSWORD
docker compose up -d cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, …
docker compose ps docker compose up -d --build
# SPA: http://localhost/ health: http://localhost/health
``` ```
| Service | Image | Host port | Built from | Local day-to-day (host Postgres, exposed API ports, `--reload`):
|---|---|---|---|
| `backend-api` | `hrms-backend:local` | 8000 | [backend/Dockerfile](backend/Dockerfile) |
| `taskiq-worker` · `taskiq-scheduler` · `taskiq-cv-worker` · `taskiq-cv-scheduler` | `hrms-backend:local` (same image, different `command`) | — | same |
| `ats-engine` | `hrms-ats-engine:local` | 8100 | [app/Dockerfile](app/Dockerfile) |
| `frontend` | `hrms-frontend:local` | 5173 | [frontend/Dockerfile](frontend/Dockerfile) |
| `redis` | `redis:7-alpine` | 6379 | — |
| `postgres` *(profile `postgres` — never starts by default)* | `hrms-postgres:local` | 5433 | [docker/postgres/Dockerfile](docker/postgres/Dockerfile) |
The backend image builds from the **repo root**, not `./backend`: `job/candidate`
imports the scoring engine from `app/`, and `inbox.plugins` pulls that in transitively,
so a `./backend` context produces workers that die on `No module named 'app'`.
### The shared file mount
`backend/inbox/decoded_attachments/` on the host is bind-mounted into every container
that touches a CV — `backend-api`, `taskiq-worker`, `taskiq-cv-worker` — at the
identical path `/app/inbox/decoded_attachments`. A PDF written by the API is the same
file the worker opens, and absolute paths stored in the database resolve in either
direction (`inbox.plugins.resolve_attachment_path` also falls back to
basename-under-that-folder for rows written by a host process). Point it elsewhere with
`ATTACHMENTS_DIR=/some/host/path`.
### Talking to the host
`backend/.env` is written for host processes, so compose overrides the three values a
container needs: `DB_HOST=host.docker.internal` (the local Postgres),
`EMAIL_URL=http://host.docker.internal:5000` (the email service on the host), and
`REDIS_URL=redis://redis:6379/0`. The host Postgres must accept connections from the
Docker bridge — `listen_addresses = '*'` plus a `pg_hba.conf` entry for `172.16.0.0/12`.
> **Stop the host `uvicorn` and `npm run dev` first.** Windows lets a host process bind
> `127.0.0.1:8000` while Docker binds `0.0.0.0:8000`, and `localhost` resolves to `::1`
> first — so both listen and requests silently reach whichever won. Same for 5173. Use
> `BACKEND_PORT` / `FRONTEND_PORT` / `ATS_PORT` if both must run.
`VITE_API_BASE` is inlined into the bundle at **build** time (default
`http://localhost:8000`), so changing the API origin means rebuilding the frontend
image, not restarting the container.
### The Postgres profile
The image is defined alongside everything else, but the `postgres` profile keeps it out
of `docker compose build` and `docker compose up` — bringing it up is always explicit:
```bash ```bash
docker compose --profile postgres build postgres docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's
``` ```
Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only ### First run
value that changes — services reach it on 5432 over the compose network) and recreate
the services. Its volume starts empty, so Alembic rebuilds the schema on first boot; it
does not share the host server's data.
### Live-code overlay
[docker-compose.dev.yml](docker-compose.dev.yml) is not a second stack — it defines no
services or images, it only adds source bind mounts and `--reload` to the ones above:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
### 4. First run
1. Sign up / log in (`/auth/login`) — the user needs a role carrying 1. Sign up / log in (`/auth/login`) — the user needs a role carrying
`candidates.create` + `candidates.view` (RBAC screen or seed a role). `candidates.create` + `candidates.view` (RBAC screen or seed a role).

View File

@ -3,10 +3,6 @@
# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec). # Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec).
# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /. # Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /.
# #
# The backend imports this same package as a library; this image is the separate
# service form of it, so it can be scaled, restarted or pointed at a different model
# independently of the portal API.
#
# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there): # THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there):
# #
# docker build -f app/Dockerfile -t hrms-ats-engine:local . # docker build -f app/Dockerfile -t hrms-ats-engine:local .
@ -20,16 +16,17 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /srv WORKDIR /srv
RUN groupadd --system app && useradd --system --gid app --home-dir /srv --shell /usr/sbin/nologin app
COPY pyproject.toml ./ COPY pyproject.toml ./
COPY app/ ./app/ COPY app/ ./app/
# Installs the pinned dependencies from pyproject.toml along with the package. The # Installs the pinned dependencies from pyproject.toml along with the package.
# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's RUN pip install --no-cache-dir . \
# source bind mount is what actually executes. && chown -R app:app /srv
RUN pip install --no-cache-dir .
USER app
EXPOSE 8100 EXPOSE 8100
# No module-level `app` object exists on purpose (app/main.py), so the factory form
# is mandatory here.
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"] CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"]

View File

@ -1,8 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and # Backend image. The FastAPI API and all Taskiq processes run from this one image;
# scheduler, CV worker and scheduler) run from this one image; docker-compose picks # docker-compose picks the process with `command:`.
# the process with `command:`.
# #
# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend: # THE BUILD CONTEXT IS THE REPO ROOT, not ./backend:
# #
@ -22,21 +21,23 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app WORKDIR /app
RUN groupadd --system app && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
COPY backend/requirements.txt ./requirements.txt COPY backend/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf` # Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf`
# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for # resolves under PYTHONPATH=/app.
# this in a host environment — copying it in is the container equivalent, and its
# dependencies (openai, pypdf, pydantic-settings, python-multipart) are already pinned
# above.
COPY backend/ /app/ COPY backend/ /app/
COPY app/ /app/app/ COPY app/ /app/app/
# Decoded CV attachments are read and written here. docker-compose bind-mounts the # Decoded CV attachments are read and written here. Compose mounts a named volume
# host folder over this path so every container shares one set of files; creating it # (prod) or a host bind (dev) over this path; creating it in the image keeps an
# in the image keeps an un-mounted container from failing on first write. # un-mounted container from failing on first write.
RUN mkdir -p /app/inbox/decoded_attachments RUN mkdir -p /app/inbox/decoded_attachments \
&& chown -R app:app /app
USER app
EXPOSE 8000 EXPOSE 8000

View File

@ -1046,19 +1046,23 @@ and a module called `alembic.py` would shadow the installed package.
## Docker ## Docker
The repo-root `docker-compose.yml` runs Redis plus the four Taskiq processes (inbox Production Compose is self-contained (Postgres in Docker; only the SPA is published).
worker/scheduler and CV-upload worker/scheduler); the API itself is expected to run on the Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](../DOCKER.md)**.
host (the compose file points the containers at `host.docker.internal` for the database).
```bash ```bash
docker compose up -d # from the repo root # Production
docker compose up -d --build
# Local (host DB, published ports, --reload)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
docker compose logs -f taskiq-worker docker compose logs -f taskiq-worker
docker compose logs -f taskiq-cv-worker docker compose logs -f taskiq-cv-worker
``` ```
`backend/Dockerfile` builds a `python:3.12-slim` image whose default command is the Taskiq `backend/Dockerfile` builds a `python:3.12-slim` image (non-root `app` user) used by
worker. `backend/inbox/decoded_attachments` is bind-mounted so the worker can read the the API and every Taskiq process. In production, CV attachments live in the
attachments the API wrote. `attachments-data` named volume; the dev overlay bind-mounts
`backend/inbox/decoded_attachments`.
--- ---

View File

@ -23,8 +23,9 @@ from pathlib import Path
from typing import Any, AsyncIterator, Callable, Sequence from typing import Any, AsyncIterator, Callable, Sequence
from alembic import command from alembic import command
from alembic.autogenerate import compare_metadata from alembic.autogenerate import compare_metadata, produce_migrations
from alembic.config import Config from alembic.config import Config
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory from alembic.script import ScriptDirectory
from sqlalchemy import MetaData, text from sqlalchemy import MetaData, text
@ -197,7 +198,10 @@ def context_options() -> dict[str, Any]:
s = get_settings() s = get_settings()
return { return {
"compare_type": True, "compare_type": True,
"compare_server_default": True, # Server-default string forms differ between reflection and models
# (e.g. now() vs CURRENT_TIMESTAMP); comparing them re-applies the same
# ALTER on every boot when we sync filelessly.
"compare_server_default": False,
"include_schemas": bool(s.db_schemas), "include_schemas": bool(s.db_schemas),
"version_table_schema": s.db_default_schema or None, "version_table_schema": s.db_default_schema or None,
"include_object": _include_object, "include_object": _include_object,
@ -233,18 +237,110 @@ async def current() -> str | None:
return await _run(lambda c: MigrationContext.configure(c, opts=opts).get_current_revision()) return await _run(lambda c: MigrationContext.configure(c, opts=opts).get_current_revision())
MODELS_STAMP = "models" # alembic_version marker when no revision files ship in the image
async def upgrade(revision: str = "head") -> None: async def upgrade(revision: str = "head") -> None:
if revision == "head" and not head():
logger.info("no alembic revisions on disk; skipping upgrade")
return
await _run(lambda c: command.upgrade(config(c), revision)) await _run(lambda c: command.upgrade(config(c), revision))
logger.info("upgraded to %s", revision) logger.info("upgraded to %s", revision)
async def stamp(revision: str = "head") -> None:
target = revision
if target == "head" and not head():
target = MODELS_STAMP
await _run(lambda c: command.stamp(config(c), target))
logger.info("stamped database at %s", target)
async def _schema_is_empty() -> bool:
"""True when the app schema has never been populated (fresh Compose volume)."""
schema = get_settings().db_default_schema or "public"
async with get_engine().connect() as conn:
row = (
await conn.execute(
text(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = :schema AND table_name = 'users' LIMIT 1"
),
{"schema": schema},
)
).first()
return row is None
async def bootstrap_empty() -> None:
"""Create every model table and stamp a revision marker.
Several historical revisions assume tables (e.g. job_posts) that were never
given a create_table in the chain they only exist on DBs that grew via
autogenerate. A brand-new Compose Postgres volume therefore cannot
`upgrade head`. Creating from metadata then stamping is the production
bootstrap for that case; existing databases keep the normal upgrade path.
Revision `.py` files are gitignored and excluded from images; stamp uses
`models` when the versions directory is empty.
"""
metadata = target_metadata()
async with get_engine().begin() as conn:
await conn.run_sync(metadata.create_all)
await stamp("head")
logger.info("bootstrapped empty database from models")
async def downgrade(revision: str = "-1") -> None: async def downgrade(revision: str = "-1") -> None:
await _run(lambda c: command.downgrade(config(c), revision)) await _run(lambda c: command.downgrade(config(c), revision))
logger.info("downgraded to %s", revision) logger.info("downgraded to %s", revision)
def _apply_upgrade_ops(connection: Connection) -> int:
"""Apply ORM→DB diffs in-process without writing a revision file."""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
ctx = MigrationContext.configure(connection, opts=opts)
script = produce_migrations(ctx, target_metadata())
if script.upgrade_ops.is_empty():
return 0
operations = Operations(ctx)
applied = 0
stack = [script.upgrade_ops]
while stack:
elem = stack.pop(0)
if hasattr(elem, "ops"):
stack.extend(elem.ops)
else:
operations.invoke(elem)
applied += 1
return applied
async def apply_model_drift() -> bool:
"""Sync the live schema to the ORM without creating migration files.
Used by Docker/prod boots so `versions/*.py` can stay gitignored and out of
the image. Returns True when at least one DDL op was applied.
"""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
diffs = await _run(
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
)
if not diffs:
logger.info("schema matches the models")
return False
logger.info("%s schema difference(s) detected; applying without revision files", len(diffs))
applied = await _run(_apply_upgrade_ops)
logger.info("applied %s schema operation(s)", applied)
return applied > 0
async def autogenerate(message: str = "auto") -> str | None: async def autogenerate(message: str = "auto") -> str | None:
"""Write a revision if the models have drifted; return its id, or None.""" """Write a revision if the models have drifted; return its id, or None.
Local/CLI only. Docker boots use `apply_model_drift` instead so revision
files are never written on the server.
"""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"} opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
diffs = await _run( diffs = await _run(
lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata()) lambda c: compare_metadata(MigrationContext.configure(c, opts=opts), target_metadata())
@ -308,12 +404,21 @@ async def _lock() -> AsyncIterator[None]:
async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None: async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None:
"""Apply pending revisions, fresh model drift, then manual SQL, under the lock.""" """Bring the DB in line with models, then run manual SQL, under the lock.
Empty Compose volumes bootstrap from models (create_all + stamp). Every boot
upgrades any on-disk revisions (skipped when versions are absent from the
image), then when DB_AUTOGENERATE is on applies ORM drift in-memory so
no `versions/*.py` files are written on the server.
"""
should_autogen = get_settings().db_autogenerate if autogen is None else autogen should_autogen = get_settings().db_autogenerate if autogen is None else autogen
async with _lock(): async with _lock():
if await _schema_is_empty():
await bootstrap_empty()
else:
await upgrade() await upgrade()
if should_autogen and await autogenerate(message): if should_autogen:
await upgrade() await apply_model_drift()
await run_manual_sql() await run_manual_sql()
logger.info("database at revision %s", await current()) logger.info("database at revision %s", await current())

View File

@ -0,0 +1,8 @@
{
"account": "",
"client_id": "679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com",
"client_secret": "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",
"refresh_token": "1//038IfgCu3D42fCgYIARAAGAMSNwF-L9IrYAZ_DJUqwC9ETwLtH23D46j61gWMwFQjRWPklFZIiLmv7Q3-TOgcNxTrjO30jgkeYOo",
"type": "authorized_user",
"universe_domain": "googleapis.com"
}

View File

@ -0,0 +1 @@
{"installed":{"client_id":"679334897177-ufal3rogbg8cgm3qcren6pv20phqd7nl.apps.googleusercontent.com","project_id":"hrms-ats-portal","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}

View File

View File

View File

@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import JSONResponse
from db_setup import lifespan as db_lifespan from db_setup import lifespan as db_lifespan
from inbox.app import router as inbox_router from inbox.app import router as inbox_router
from users.app import router as users_router from users.app import router as users_router
@ -88,6 +89,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
@app.get("/health")
async def health():
# Liveness only — no DB. Compose healthchecks and load balancers hit this.
return JSONResponse(content={"status":"ok","status_code":200})
app.include_router(inbox_router) app.include_router(inbox_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(role_router) app.include_router(role_router)

View File

@ -1,28 +1,49 @@
# Live-code overlay. Mounts the source folders into the running containers, so what # Local / host-Postgres overlay. Restores the previous day-to-day workflow on top
# executes is what is on disk on the host — edit, save, uvicorn reloads. # of the production docker-compose.yml:
# #
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up # docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
# #
# The CV attachments mount from the base file still applies: compose merges volumes # - DB_HOST=host.docker.internal (host Postgres; container postgres still starts
# by target path, and /app/inbox/decoded_attachments is nested under the /app mount, # but is unused unless you flip DB_HOST back)
# so the daemon mounts the parent first and the attachments folder on top. # - Publishes API / ATS / Redis / frontend ports for direct host access
# - Bind-mounts CV attachments to ./backend/inbox/decoded_attachments
# - Live source mounts + --reload on API / ATS / workers
# #
# Two mounts per Python service, not one: /app is the backend tree and /app/app is the # Frontend stays as the built nginx image — run `npm run dev` on the host for the
# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over # Vite loop if you need HMR.
# /app would hide the engine baked into the image and every worker would fail on
# import.
#
# The frontend stays as the built nginx image — a Vite dev server wants node_modules
# on the mount, which is slow and fragile across a Windows bind mount. Run
# `npm run dev` on the host for the frontend loop.
services: services:
redis:
ports:
- "${REDIS_PORT:-6379}:6379"
postgres:
ports:
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
backend-api: backend-api:
environment:
PYTHONPATH: /app
DB_HOST: host.docker.internal
REDIS_URL: redis://redis:6379/0
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000
command: command:
["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] [
"uvicorn",
"main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
"--reload",
]
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
ats-engine: ats-engine:
command: command:
@ -36,30 +57,77 @@ services:
"8100", "8100",
"--reload", "--reload",
] ]
ports:
- "${ATS_PORT:-8100}:8100"
volumes: volumes:
- ./app:/srv/app - ./app:/srv/app
frontend:
ports:
- "${FRONTEND_PORT:-5173}:80"
taskiq-worker: taskiq-worker:
environment:
PYTHONPATH: /app
DB_HOST: host.docker.internal
REDIS_URL: redis://redis:6379/0
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000
TASKIQ_QUEUE_NAME: inbox
TASKIQ_WORKER_NAME: worker-01
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
taskiq-scheduler: taskiq-scheduler:
environment:
PYTHONPATH: /app
DB_HOST: host.docker.internal
REDIS_URL: redis://redis:6379/0
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000
TASKIQ_QUEUE_NAME: inbox
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
taskiq-cv-worker: taskiq-cv-worker:
environment:
PYTHONPATH: /app
DB_HOST: host.docker.internal
REDIS_URL: redis://redis:6379/0
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000
TASKIQ_CV_QUEUE_NAME: cv_upload
TASKIQ_WORKER_NAME: cv-worker-01
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
taskiq-cv-scheduler: taskiq-cv-scheduler:
environment:
PYTHONPATH: /app
DB_HOST: host.docker.internal
REDIS_URL: redis://redis:6379/0
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000
TASKIQ_CV_QUEUE_NAME: cv_upload
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
taskiq-mailbox-sync-worker: taskiq-mailbox-sync-worker:
environment:
PYTHONPATH: /app
DB_HOST: host.docker.internal
REDIS_URL: redis://redis:6379/0
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000
TASKIQ_MAILBOX_SYNC_QUEUE_NAME: mailbox_sync
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments

View File

@ -1,32 +1,23 @@
# HR-ATS-Portal — every service and every image, in one file. # HR-ATS-Portal — production Compose stack (self-contained).
# #
# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend # docker compose up -d --build
# docker compose up -d
# docker compose ps # docker compose ps
# docker compose logs -f backend-api # docker compose logs -f backend-api
# #
# ── Postgres ────────────────────────────────────────────────────────────────────── # Public surface: only the frontend (default host port 80). The browser talks
# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined # same-origin to nginx, which proxies API paths to backend-api. Postgres, Redis,
# and buildable here but never starts with a plain `docker compose up`. The stack # the API, ATS engine and Taskiq workers stay on the Compose network.
# talks to the PostgreSQL server already running on the host, via
# DB_HOST=host.docker.internal (backend/.env says localhost — correct for a host
# process, wrong inside a container).
# #
# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*' # Local / current host-Postgres workflow (exposed ports, --reload, bind mounts):
# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet). # docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
# #
# To build/run the containerised database instead, see the comments on that service. # See DOCKER.md for env checklist and verification.
#
# ── Ports ───────────────────────────────────────────────────────────────────────── x-logging: &default-logging
# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or driver: json-file
# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. Windows lets a host process options:
# bind 127.0.0.1:8000 while Docker binds 0.0.0.0:8000, and `localhost` resolves to ::1 max-size: "10m"
# first — both listen, and requests reach whichever won. max-file: "3"
#
# ── Overlay ───────────────────────────────────────────────────────────────────────
# docker-compose.dev.yml adds live source mounts and --reload on top of this file. It
# defines no services or images of its own; it only overrides the ones here:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
x-backend-build: &backend-build x-backend-build: &backend-build
# Root context, not ./backend: backend/job/candidate imports the bulk-ats engine # Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
@ -36,26 +27,28 @@ x-backend-build: &backend-build
x-backend-env: &backend-env x-backend-env: &backend-env
PYTHONPATH: /app PYTHONPATH: /app
# backend/.env is written for host processes; these are the values a container needs. # Production default: container Postgres on the Compose network. The dev overlay
# # overrides DB_HOST to host.docker.internal. Credentials come from root .env so
# DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in # they stay in lockstep with the postgres service (backend/.env is for host runs).
# the shell (or a root .env) to point the whole stack at the container below instead DB_HOST: ${DB_HOST:-postgres}
# — that is the only value that has to change, since services reach it over the DB_PORT: ${DB_PORT:-5432}
# compose network on 5432, not the published host port. DB_USERNAME: ${DB_USERNAME:-postgres}
DB_HOST: ${DB_HOST:-host.docker.internal} DB_PASSWORD: ${DB_PASSWORD:-postgres}
DB_NAME: ${DB_NAME:-hrms}
# On every API boot: upgrade any on-disk revisions (usually none in images),
# then apply ORM drift in-memory (no revision files written — versions stay
# gitignored and out of the image).
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
DB_AUTOGENERATE: ${DB_AUTOGENERATE:-true}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
# Prefer root/.env EMAIL_URL (e.g. http://3.140.173.13:5000). Fall back to the # Prefer root/.env EMAIL_URL. Fall back to host-gateway when mail is on this machine.
# host-gateway alias when the mail service runs on this machine's :5000.
EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000} EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000}
BACKEND_URL: http://backend-api:8000 BACKEND_URL: http://backend-api:8000
# The one shared folder. Every process that decodes, scores or serves a CV reads and # Shared CV storage. Named volume in production so API + workers see the same
# writes the same host directory, so a file written by the API is the same file the # files without a host path. Dev overlay remounts ./backend/inbox/decoded_attachments.
# worker opens. Absolute paths stored in the DB match across services because the
# mount target is identical everywhere; inbox.plugins.resolve_attachment_path also
# falls back to basename-under-this-directory for rows written by a host process.
x-attachments: &attachments x-attachments: &attachments
- ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments - attachments-data:/app/inbox/decoded_attachments
x-backend-service: &backend-service x-backend-service: &backend-service
build: *backend-build build: *backend-build
@ -69,13 +62,10 @@ x-backend-service: &backend-service
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
# required:false — the default stack uses the host's Postgres and never starts
# this one, and that must not be an error. When the profile IS active, startup
# waits for it to pass pg_isready.
postgres: postgres:
condition: service_healthy condition: service_healthy
required: false
restart: unless-stopped restart: unless-stopped
logging: *default-logging
services: services:
# --- Redis (broker + result backend for taskiq) ---------------------------------- # --- Redis (broker + result backend for taskiq) ----------------------------------
@ -83,8 +73,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: hrms-redis container_name: hrms-redis
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes"]
ports: # Not published in production. Dev overlay binds ${REDIS_PORT:-6379}:6379.
- "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis-data:/data - redis-data:/data
healthcheck: healthcheck:
@ -93,24 +82,59 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped restart: unless-stopped
logging: *default-logging
# --- Postgres (always on in production) ------------------------------------------
postgres:
build:
context: ./docker/postgres
image: hrms-postgres:local
container_name: hrms-postgres
environment:
# Interpolated from the shell or root .env, NOT from backend/.env.
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-hrms}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
# Not published in production. Dev overlay can bind 127.0.0.1:${POSTGRES_PORT:-5433}:5432.
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
logging: *default-logging
# --- portal API ------------------------------------------------------------------ # --- portal API ------------------------------------------------------------------
backend-api: backend-api:
<<: *backend-service <<: *backend-service
container_name: hrms-backend-api container_name: hrms-backend-api
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] command:
ports: [
- "${BACKEND_PORT:-8000}:8000" "uvicorn",
"main:app",
"--host",
"0.0.0.0",
"--port",
"8000",
"--workers",
"${UVICORN_WORKERS:-2}",
"--proxy-headers",
"--forwarded-allow-ips=*",
]
# Not published in production — browsers reach the API via frontend nginx.
volumes: *attachments volumes: *attachments
healthcheck: healthcheck:
# python:slim has no curl and the API exposes no /health route, so this is a
# plain TCP check against the uvicorn socket.
test: test:
[ [
"CMD", "CMD",
"python", "python",
"-c", "-c",
"import socket;socket.create_connection(('127.0.0.1',8000),3).close()", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)",
] ]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
@ -125,13 +149,10 @@ services:
image: hrms-ats-engine:local image: hrms-ats-engine:local
container_name: hrms-ats-engine container_name: hrms-ats-engine
env_file: env_file:
# OPENAI_API_KEY currently lives in backend/.env; a root .env (see .env.example)
# overrides it when present, and the stack still comes up when it is not.
- ./backend/.env - ./backend/.env
- path: ./.env - path: ./.env
required: false required: false
ports: # Not published in production.
- "${ATS_PORT:-8100}:8100"
healthcheck: healthcheck:
test: test:
[ [
@ -145,15 +166,14 @@ services:
retries: 5 retries: 5
start_period: 20s start_period: 20s
restart: unless-stopped restart: unless-stopped
logging: *default-logging
# --- React portal ----------------------------------------------------------------- # --- React portal (only published host port) -------------------------------------
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
args: args:
# Empty = same-origin; nginx proxies API paths to backend-api (see nginx.conf). # Empty = same-origin; nginx proxies API paths to backend-api (see nginx.conf).
# A baked http://localhost:8000 / LAN IP sends the browser to a *different*
# listener than the SPA (Cursor steals 127.0.0.1:8000) and empties /jobs.
VITE_API_BASE: ${VITE_API_BASE:-} VITE_API_BASE: ${VITE_API_BASE:-}
image: hrms-frontend:local image: hrms-frontend:local
container_name: hrms-frontend container_name: hrms-frontend
@ -161,13 +181,14 @@ services:
backend-api: backend-api:
condition: service_healthy condition: service_healthy
ports: ports:
- "${FRONTEND_PORT:-5173}:80" - "${FRONTEND_PORT:-80}:80"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped restart: unless-stopped
logging: *default-logging
# --- background processing (same image as backend-api, different command) --------- # --- background processing (same image as backend-api, different command) ---------
taskiq-worker: taskiq-worker:
@ -255,45 +276,7 @@ services:
TASKIQ_WORKER_NAME: mailbox-sync-worker-01 TASKIQ_WORKER_NAME: mailbox-sync-worker-01
volumes: *attachments volumes: *attachments
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
# The profile is what keeps it out of `docker compose build` and `docker compose up`.
# Nothing about the default stack changes by its presence in this file.
#
# docker compose --profile postgres build postgres # build the image
# docker compose --profile postgres up -d postgres # run it, host port 5433
#
# Pointing the app at it is a separate, deliberate step — set DB_HOST=postgres (see
# x-backend-env) and recreate the services. The volume starts empty, so Alembic
# rebuilds the schema on first boot; it does not share the host server's data.
postgres:
profiles: ["postgres"]
build:
context: ./docker/postgres
image: hrms-postgres:local
container_name: hrms-postgres
environment:
# Interpolated from the shell or a root .env, NOT from backend/.env — compose
# variable substitution and container environment are different things.
# Defaults match backend/.env.example.
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-hrms}
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
# 5433: the host's own Postgres server owns 5432. Only for host-side tools —
# containers reach this one on 5432 over the compose network.
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
volumes: volumes:
redis-data: redis-data:
postgres-data: postgres-data:
attachments-data:

View File

@ -1,15 +1,8 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK. # Postgres image used by the production Compose stack (DB_HOST=postgres).
# # The volume starts empty on first boot; Alembic rebuilds the schema. It does
# Every service in docker-compose.yml points at the Postgres already running on the # not share the host server's data — migrate with pg_dump/pg_restore if needed.
# host (DB_HOST=host.docker.internal). This image exists so the database can be
# containerised on demand — a clean machine, a throwaway test run, a second dev — and
# it is kept in its own file (docker-compose.postgres.yml) so bringing it up is always
# a deliberate act:
#
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml build postgres
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d postgres
# #
# Context is ./docker/postgres. # Context is ./docker/postgres.

View File

@ -5,11 +5,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# CV / multipart uploads (MAX_PDF_SIZE_MB is 10; leave headroom for form fields).
client_max_body_size 25m;
# Security headers on every response.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so # Same-origin API proxy. The SPA is built with an empty VITE_API_BASE so
# fetch('/jobs/fetch') stays on this host; without this block nginx would # fetch('/jobs/fetch') stays on this host; without this block nginx would
# serve index.html for those paths (200 HTML) and the Jobs board would # serve index.html for those paths (200 HTML) and the Jobs board would
# parse an empty payload. backend-api is the compose service name. # parse an empty payload. OpenAPI (/docs, /redoc, /openapi.json) is
location ~ ^/(users|roles|permissions|permission-tags|managers|inbox|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|docs|openapi\.json|redoc)(/|$) { # intentionally NOT proxied keep the schema off the public edge.
location ~ ^/(health|users|roles|permissions|permission-tags|managers|inbox|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search)(/|$) {
proxy_pass http://backend-api:8000; proxy_pass http://backend-api:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@ -17,11 +26,12 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Authorization $http_authorization; proxy_set_header Authorization $http_authorization;
proxy_connect_timeout 10s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
} }
# One SPA at `/`. `vite dev` and `vite preview` serve index.html for every # One SPA at `/`. Without this, /auth/confirm-email (a router path, not a
# unmatched path; vite.config.js notes that a static deploy needs the equivalent
# rewrite rule. This is it without it /auth/confirm-email (a router path, not a
# file) 404s when a confirmation email link is opened cold. # file) 404s when a confirmation email link is opened cold.
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
@ -31,11 +41,17 @@ server {
location /assets/ { location /assets/ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
} }
# index.html must never be cached, or a redeploy keeps serving the old asset hashes. # index.html must never be cached, or a redeploy keeps serving the old asset hashes.
location = /index.html { location = /index.html {
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
} }
gzip on; gzip on;