diff --git a/.dockerignore b/.dockerignore index fa8125f..bd2cfe6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,6 +31,11 @@ frontend/ # Candidate CVs live on the bind mount, not inside an image. 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/ tests/ scripts/ diff --git a/.env.example b/.env.example index 8950763..afcfa78 100644 --- a/.env.example +++ b/.env.example @@ -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= @@ -15,7 +49,6 @@ OPENAI_MODEL=gpt-5.4-mini OPENAI_MAX_OUTPUT_TOKENS=4000 # none | minimal | low | medium | high | xhigh -# Per-model support varies; the API rejects a level the model does not implement. OPENAI_EFFORT=low OPENAI_MAX_RETRIES=3 diff --git a/.gitignore b/.gitignore index c30fcdb..8700273 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,3 @@ frontend/dist/ **.pdf **_**_**.py Utopia-ai-hr-ats-portal 1.pem -db_setup.py \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..8c19b33 --- /dev/null +++ b/DOCKER.md @@ -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:///` → 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 +``` diff --git a/README.md b/README.md index 67d821b..781e108 100644 --- a/README.md +++ b/README.md @@ -115,82 +115,24 @@ docker compose up redis taskiq-worker taskiq-scheduler ## Docker -Every service has its own image and its own container, all in one -[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run** -— it sits behind a compose profile, and the stack talks to the PostgreSQL server -already running on the host. +Self-contained production stack (Postgres in Compose; only the SPA is published). +See **[DOCKER.md](DOCKER.md)** for env checklist, verification, TLS notes, and the +local host-Postgres overlay. ```bash -docker compose build -docker compose up -d -docker compose ps +cp .env.example .env # set DB_PASSWORD +cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, … +docker compose up -d --build +# SPA: http://localhost/ health: http://localhost/health ``` -| Service | Image | Host port | Built from | -|---|---|---|---| -| `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: +Local day-to-day (host Postgres, exposed API ports, `--reload`): ```bash -docker compose --profile postgres build postgres -docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ``` -Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only -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 +### First run 1. Sign up / log in (`/auth/login`) — the user needs a role carrying `candidates.create` + `candidates.view` (RBAC screen or seed a role). diff --git a/app/Dockerfile b/app/Dockerfile index 99613fb..58b7cc0 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -3,10 +3,6 @@ # 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 /. # -# 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): # # docker build -f app/Dockerfile -t hrms-ats-engine:local . @@ -20,16 +16,17 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /srv +RUN groupadd --system app && useradd --system --gid app --home-dir /srv --shell /usr/sbin/nologin app + COPY pyproject.toml ./ COPY app/ ./app/ -# Installs the pinned dependencies from pyproject.toml along with the package. The -# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's -# source bind mount is what actually executes. -RUN pip install --no-cache-dir . +# Installs the pinned dependencies from pyproject.toml along with the package. +RUN pip install --no-cache-dir . \ + && chown -R app:app /srv + +USER app 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"] diff --git a/backend/Dockerfile b/backend/Dockerfile index 1da523b..58ec160 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,8 +1,7 @@ # syntax=docker/dockerfile:1 # -# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and -# scheduler, CV worker and scheduler) run from this one image; docker-compose picks -# the process with `command:`. +# Backend image. The FastAPI API and all Taskiq processes run from this one image; +# docker-compose picks the process with `command:`. # # THE BUILD CONTEXT IS THE REPO ROOT, not ./backend: # @@ -22,21 +21,23 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app +RUN groupadd --system app && useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app + COPY backend/requirements.txt ./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` -# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for -# 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. +# resolves under PYTHONPATH=/app. COPY backend/ /app/ COPY app/ /app/app/ -# Decoded CV attachments are read and written here. docker-compose bind-mounts the -# host folder over this path so every container shares one set of files; creating it -# in the image keeps an un-mounted container from failing on first write. -RUN mkdir -p /app/inbox/decoded_attachments +# Decoded CV attachments are read and written here. Compose mounts a named volume +# (prod) or a host bind (dev) over this path; creating it in the image keeps an +# un-mounted container from failing on first write. +RUN mkdir -p /app/inbox/decoded_attachments \ + && chown -R app:app /app + +USER app EXPOSE 8000 diff --git a/backend/README.md b/backend/README.md index bc4307f..6807e88 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1046,19 +1046,23 @@ 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). +Production Compose is self-contained (Postgres in Docker; only the SPA is published). +Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](../DOCKER.md)**. ```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-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. +`backend/Dockerfile` builds a `python:3.12-slim` image (non-root `app` user) used by +the API and every Taskiq process. In production, CV attachments live in the +`attachments-data` named volume; the dev overlay bind-mounts +`backend/inbox/decoded_attachments`. --- diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index 8823770..4063f48 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -23,8 +23,9 @@ from pathlib import Path from typing import Any, AsyncIterator, Callable, Sequence 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.operations import Operations from alembic.runtime.migration import MigrationContext from alembic.script import ScriptDirectory from sqlalchemy import MetaData, text @@ -197,7 +198,10 @@ def context_options() -> dict[str, Any]: s = get_settings() return { "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), "version_table_schema": s.db_default_schema or None, "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()) +MODELS_STAMP = "models" # alembic_version marker when no revision files ship in the image + + 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)) 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: await _run(lambda c: command.downgrade(config(c), 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: - """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"} diffs = await _run( 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: - """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 async with _lock(): - await upgrade() - if should_autogen and await autogenerate(message): + if await _schema_is_empty(): + await bootstrap_empty() + else: await upgrade() + if should_autogen: + await apply_model_drift() await run_manual_sql() logger.info("database at revision %s", await current()) diff --git a/backend/credentials/application_default_credentials.json b/backend/credentials/application_default_credentials.json new file mode 100644 index 0000000..7c33d3a --- /dev/null +++ b/backend/credentials/application_default_credentials.json @@ -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" +} \ No newline at end of file diff --git a/backend/credentials/client_secret.json b/backend/credentials/client_secret.json new file mode 100644 index 0000000..ffd8c9c --- /dev/null +++ b/backend/credentials/client_secret.json @@ -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"]}} \ No newline at end of file diff --git a/backend/g_sheet/export_json.py b/backend/g_sheet/export_json.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/g_sheet/read_sheet.py b/backend/g_sheet/read_sheet.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/main.py b/backend/main.py index b171a05..ead81b9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware from fastapi import FastAPI +from fastapi.responses import JSONResponse from db_setup import lifespan as db_lifespan from inbox.app import router as inbox_router from users.app import router as users_router @@ -88,6 +89,13 @@ app.add_middleware( 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(users_router) app.include_router(role_router) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 64730d3..63eb9f1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,28 +1,49 @@ -# Live-code overlay. Mounts the source folders into the running containers, so what -# executes is what is on disk on the host — edit, save, uvicorn reloads. +# Local / host-Postgres overlay. Restores the previous day-to-day workflow on top +# 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 -# by target path, and /app/inbox/decoded_attachments is nested under the /app mount, -# so the daemon mounts the parent first and the attachments folder on top. +# - DB_HOST=host.docker.internal (host Postgres; container postgres still starts +# but is unused unless you flip DB_HOST back) +# - 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 -# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over -# /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. +# Frontend stays as the built nginx image — run `npm run dev` on the host for the +# Vite loop if you need HMR. services: + redis: + ports: + - "${REDIS_PORT:-6379}:6379" + + postgres: + ports: + - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" + 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: - ["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: - ./backend:/app - ./app:/app/app + - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments ats-engine: command: @@ -36,30 +57,77 @@ services: "8100", "--reload", ] + ports: + - "${ATS_PORT:-8100}:8100" volumes: - ./app:/srv/app + frontend: + ports: + - "${FRONTEND_PORT:-5173}:80" + 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: - ./backend:/app - ./app:/app/app + - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments 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: - ./backend:/app - ./app:/app/app 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: - ./backend:/app - ./app:/app/app + - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments 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: - ./backend:/app - ./app:/app/app 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: - ./backend:/app - ./app:/app/app + - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments diff --git a/docker-compose.yml b/docker-compose.yml index 3d33432..884bc7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 +# docker compose up -d --build # docker compose ps # docker compose logs -f backend-api # -# ── Postgres ────────────────────────────────────────────────────────────────────── -# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined -# and buildable here but never starts with a plain `docker compose up`. The stack -# 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). +# Public surface: only the frontend (default host port 80). The browser talks +# same-origin to nginx, which proxies API paths to backend-api. Postgres, Redis, +# the API, ATS engine and Taskiq workers stay on the Compose network. # -# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*' -# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet). +# Local / current host-Postgres workflow (exposed ports, --reload, bind mounts): +# 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. -# -# ── Ports ───────────────────────────────────────────────────────────────────────── -# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or -# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. 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 — both listen, and requests reach whichever won. -# -# ── 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 +# See DOCKER.md for env checklist and verification. + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" x-backend-build: &backend-build # 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 PYTHONPATH: /app - # backend/.env is written for host processes; these are the values a container needs. - # - # DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in - # the shell (or a root .env) to point the whole stack at the container below instead - # — that is the only value that has to change, since services reach it over the - # compose network on 5432, not the published host port. - DB_HOST: ${DB_HOST:-host.docker.internal} + # Production default: container Postgres on the Compose network. The dev overlay + # overrides DB_HOST to host.docker.internal. Credentials come from root .env so + # they stay in lockstep with the postgres service (backend/.env is for host runs). + DB_HOST: ${DB_HOST:-postgres} + DB_PORT: ${DB_PORT:-5432} + DB_USERNAME: ${DB_USERNAME:-postgres} + 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 - # Prefer root/.env EMAIL_URL (e.g. http://3.140.173.13:5000). Fall back to the - # host-gateway alias when the mail service runs on this machine's :5000. + # Prefer root/.env EMAIL_URL. Fall back to host-gateway when mail is on this machine. EMAIL_URL: ${EMAIL_URL:-http://host.docker.internal:5000} BACKEND_URL: http://backend-api:8000 -# The one shared folder. Every process that decodes, scores or serves a CV reads and -# writes the same host directory, so a file written by the API is the same file the -# 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. +# Shared CV storage. Named volume in production so API + workers see the same +# files without a host path. Dev overlay remounts ./backend/inbox/decoded_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 build: *backend-build @@ -69,13 +62,10 @@ x-backend-service: &backend-service depends_on: redis: 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: condition: service_healthy - required: false restart: unless-stopped + logging: *default-logging services: # --- Redis (broker + result backend for taskiq) ---------------------------------- @@ -83,8 +73,7 @@ services: image: redis:7-alpine container_name: hrms-redis command: ["redis-server", "--appendonly", "yes"] - ports: - - "${REDIS_PORT:-6379}:6379" + # Not published in production. Dev overlay binds ${REDIS_PORT:-6379}:6379. volumes: - redis-data:/data healthcheck: @@ -93,24 +82,59 @@ services: timeout: 5s retries: 5 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 ------------------------------------------------------------------ backend-api: <<: *backend-service container_name: hrms-backend-api - command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] - ports: - - "${BACKEND_PORT:-8000}:8000" + command: + [ + "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 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: [ "CMD", "python", "-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 timeout: 5s @@ -125,13 +149,10 @@ services: image: hrms-ats-engine:local container_name: hrms-ats-engine 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 - path: ./.env required: false - ports: - - "${ATS_PORT:-8100}:8100" + # Not published in production. healthcheck: test: [ @@ -145,15 +166,14 @@ services: retries: 5 start_period: 20s restart: unless-stopped + logging: *default-logging - # --- React portal ----------------------------------------------------------------- + # --- React portal (only published host port) ------------------------------------- frontend: build: context: ./frontend args: # 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:-} image: hrms-frontend:local container_name: hrms-frontend @@ -161,13 +181,14 @@ services: backend-api: condition: service_healthy ports: - - "${FRONTEND_PORT:-5173}:80" + - "${FRONTEND_PORT:-80}:80" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] interval: 15s timeout: 5s retries: 5 restart: unless-stopped + logging: *default-logging # --- background processing (same image as backend-api, different command) --------- taskiq-worker: @@ -255,45 +276,7 @@ services: TASKIQ_WORKER_NAME: mailbox-sync-worker-01 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: redis-data: postgres-data: + attachments-data: diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile index ac681d2..473bf50 100644 --- a/docker/postgres/Dockerfile +++ b/docker/postgres/Dockerfile @@ -1,15 +1,8 @@ # syntax=docker/dockerfile:1 # -# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK. -# -# Every service in docker-compose.yml points at the Postgres already running on the -# 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 +# Postgres image used by the production Compose stack (DB_HOST=postgres). +# The volume starts empty on first boot; Alembic rebuilds the schema. It does +# not share the host server's data — migrate with pg_dump/pg_restore if needed. # # Context is ./docker/postgres. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7a9def0..9ebb5f2 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -5,11 +5,20 @@ server { root /usr/share/nginx/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 # 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 - # parse an empty payload. backend-api is the compose service name. - 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)(/|$) { + # parse an empty payload. OpenAPI (/docs, /redoc, /openapi.json) is + # 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_http_version 1.1; 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-Proto $scheme; 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 - # 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 + # One SPA at `/`. Without this, /auth/confirm-email (a router path, not a # file) 404s when a confirmation email link is opened cold. location / { try_files $uri $uri/ /index.html; @@ -31,11 +41,17 @@ server { location /assets/ { expires 1y; 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. location = /index.html { 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;