diff --git a/.env.example b/.env.example deleted file mode 100644 index afcfa78..0000000 --- a/.env.example +++ /dev/null @@ -1,69 +0,0 @@ -# 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= - -# Must be a structured-outputs model family: gpt-5*, gpt-4.1*, o3*, o4*. -# "-chat-latest" variants are rejected -- they track the ChatGPT product surface and -# do not expose reasoning effort. -# Note gpt-4.1 is allowed but is not a reasoning model, so OPENAI_EFFORT is ignored -# for it (the adapter omits the parameter rather than sending a 400). -OPENAI_MODEL=gpt-5.4-mini - -# Covers reasoning tokens AND the visible response on a reasoning model. Too low and -# the JSON truncates mid-object, failing the candidate with MODEL_RESPONSE_INVALID. -# Enforced floor is 2048. Do not lower this to save cost -- lower OPENAI_EFFORT. -OPENAI_MAX_OUTPUT_TOKENS=4000 - -# none | minimal | low | medium | high | xhigh -OPENAI_EFFORT=low - -OPENAI_MAX_RETRIES=3 -OPENAI_TIMEOUT_SECONDS=120 - -# OpenAI prompt caching is automatic and cannot be turned off. This only controls -# whether a prompt_cache_key routing hint is sent to raise the cache hit rate. -OPENAI_ENABLE_PROMPT_CACHE=true - -SCORING_CONCURRENCY=5 -MAX_RESUMES_PER_REQUEST=50 -MAX_PDF_SIZE_MB=10 -MAX_JD_CHARS=30000 -MAX_RESUME_CHARS=60000 - -# text | json -LOG_FORMAT=json -LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 8700273..a095f84 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ frontend/dist/ **.pdf **_**_**.py Utopia-ai-hr-ats-portal 1.pem + +# Local-only Compose overrides (never deployed) +docker.local.env diff --git a/DOCKER.md b/DOCKER.md index 8c19b33..501bbeb 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,116 +1,145 @@ # 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. +## How the browser reaches the API -## Production +The SPA is on **http://127.0.0.1:5173** (nginx → `backend-api` on the Compose +network). The API is also published on **http://127.0.0.1:8000** for host tools +and `npm run dev` (`VITE_API_BASE=http://127.0.0.1:8000`). -```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 +```env +FRONTEND_PORT=5173 +BACKEND_PORT=8000 +FRONTEND_URL=http://127.0.0.1:5173 ``` -| 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) | +Sole env file: **`backend/.env`** (no repo-root `.env`). Always pass it for +Compose variable substitution: + +```bash +docker compose --env-file ./backend/.env up -d --build +``` + +## Local (host Postgres) + +In `backend/.env`: + +```env +PROD_ENV=false +DB_USERNAME=... +DB_PASSWORD=... +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=hrms +DB_SSLMODE= +FRONTEND_PORT=8080 +``` + +Containers set `IN_DOCKER=1`. With `PROD_ENV=false`, `db_setup` rewrites +`localhost` / `127.0.0.1` → `host.docker.internal` for the connection URL only +(SSL off unless `DB_SSLMODE` is set). Host Postgres must accept Docker-bridge +clients (`listen_addresses`, `pg_hba`). + +```bash +cp backend/.env.example backend/.env # set JWT, OpenAI, DB_*, PROD_ENV=false +docker compose --env-file ./backend/.env up -d --build +``` + +| Service | Host access | +|---|---| +| `frontend` | `${FRONTEND_PORT:-80}` (all interfaces) | +| `backend-api` | Compose network only (`backend-api:8000`); nginx proxies | +| `ats-engine` | Compose network only (`ats-engine:8100`) | +| `redis` | Compose network only (`redis:6379`) | +| `postgres` | not started (optional `--profile postgres`) | + +Optional loopback publishes for host tools (curl / redis-cli / Postman): + +```bash +docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d +``` + +If bind fails on Windows because Cursor/VS Code still holds `:80` / `:8100` / +`:6379` after a previous run, clear **Ports** in the IDE or set free values in +`backend/.env` (`FRONTEND_PORT`, and with the overlay `ATS_PORT` / `REDIS_PORT` / +`BACKEND_PORT`). + +Optional live-reload / bind mounts: + +```bash +docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.dev.yml up -d --build +``` + +Optional Compose Postgres (empty volume — not host data): + +```bash +docker compose --env-file ./backend/.env --profile postgres up -d postgres +# set DB_HOST=postgres in backend/.env, then recreate backend services +``` + +## Production (RDS) + +In `backend/.env`, set `PROD_ENV=true` and point plain `DB_*` at RDS (no +prefixed credential sets). Blank `DB_SSLMODE` → SSL `require`. Host is never +rewritten. + +```bash +cp backend/.env.example backend/.env +# Edit backend/.env: PROD_ENV=true, DB_* = RDS, JWT_SECRET_KEY, FRONTEND_PORT=80, … +docker compose --env-file ./backend/.env up -d --build +docker compose --env-file ./backend/.env ps +``` 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`): +On every `backend-api` start: 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. +3. If `DB_AUTOGENERATE=true` → detect ORM drift and apply DDL **in-memory**. 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`). +Toggle in `backend/.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 +docker compose --env-file ./backend/.env config +curl -sf http://127.0.0.1:${FRONTEND_PORT:-8080}/health +curl -sf -o /dev/null -w "%{http_code}\n" http://127.0.0.1:${FRONTEND_PORT:-8080}/ +docker compose --env-file ./backend/.env 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. +- Only `backend/.env` holds app + Compose substitution values. +- Do not put `DB_HOST` under Compose `environment:` (empty override blanks RDS). ### 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`). +cloud load balancer in front of that port. ## Data migration -The Compose Postgres volume starts empty. To move an existing host database: +The optional 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 +docker compose --env-file ./backend/.env logs -f backend-api +docker compose --env-file ./backend/.env logs -f taskiq-worker +docker compose --env-file ./backend/.env restart backend-api +docker compose --env-file ./backend/.env down # keep volumes +docker compose --env-file ./backend/.env down -v # wipe volumes ``` diff --git a/README.md b/README.md index 781e108..e5c48a3 100644 --- a/README.md +++ b/README.md @@ -45,29 +45,19 @@ job and persists an evidence-based score. **Prerequisites:** Python 3.11+, Node 18+, PostgreSQL, an OpenAI API key. Optional: Redis + Docker (only for background inbox sync / taskiq workers). -### 1. Environment files +### 1. Environment file -Root `.env` (engine + scoring settings — see [.env.example](.env.example)): - -``` -OPENAI_API_KEY=sk-... -OPENAI_MODEL=gpt-5.4-mini -OPENAI_MAX_OUTPUT_TOKENS=4000 -OPENAI_EFFORT=low -SCORING_CONCURRENCY=5 -MAX_RESUMES_PER_REQUEST=50 -MAX_PDF_SIZE_MB=10 -``` - -`backend/.env` (everything in `backend/.env.example`; the must-haves): +Sole file: `backend/.env` (see [backend/.env.example](backend/.env.example)): ``` +PROD_ENV=false DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms JWT_SECRET_KEY=... -OPENAI_API_KEY=sk-... # shared names with the root .env +OPENAI_API_KEY=sk-... +FRONTEND_PORT=8080 ``` -> Windows note: write `.env` files as UTF-8 **without** BOM, and don't leave stray +> Windows note: write `.env` as UTF-8 **without** BOM, and don't leave stray > non `KEY=VALUE` lines — python-dotenv warns on every load. ### 2. Fresh database — one manual step @@ -120,17 +110,13 @@ See **[DOCKER.md](DOCKER.md)** for env checklist, verification, TLS notes, and t local host-Postgres overlay. ```bash -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 +cp backend/.env.example backend/.env # set JWT_SECRET_KEY, OPENAI_API_KEY, DB_*, … +docker compose --env-file ./backend/.env up -d --build +# SPA: http://localhost:8080/ health: http://localhost:8080/health ``` -Local day-to-day (host Postgres, exposed API ports, `--reload`): - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -``` +Local and prod use the same command (`PROD_ENV` + `DB_*` in `backend/.env`). +Optional `--reload` / bind mounts: add `-f docker-compose.dev.yml`. See `DOCKER.md`. ### First run diff --git a/app/core/config.py b/app/core/config.py index f36cb23..919ae69 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -10,10 +10,14 @@ Deliberate omissions: from __future__ import annotations from functools import lru_cache +from pathlib import Path from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# Sole secrets file: backend/.env (repo root .env is not used). +_BACKEND_ENV = Path(__file__).resolve().parents[2] / "backend" / ".env" + # Model families that support structured outputs (``responses.parse``) and a reasoning # effort setting. A prefix check rather than an exact allowlist: OpenAI ships point # releases faster than this file can be updated, and rejecting a brand-new gpt-5.x @@ -44,7 +48,7 @@ class Settings(BaseSettings): """Runtime configuration. Immutable once constructed.""" model_config = SettingsConfigDict( - env_file=".env", + env_file=_BACKEND_ENV, # utf-8-sig, not utf-8: Windows editors and PowerShell's `-Encoding utf8` # write a BOM, which would otherwise become part of the first variable's # name and silently blank out that setting. diff --git a/backend/.env.example b/backend/.env.example index 6320fd0..2bc7c41 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,22 @@ -DB_USERNAME= +# Sole secrets / config file for the whole monorepo (app + backend + Compose). +# Copy to backend/.env and fill in. Never commit a filled .env. +# +# Compose: docker compose --env-file ./backend/.env up -d --build + +# true → RDS over SSL (asyncpg). false → local Postgres over asyncpg (no SSH). +PROD_ENV=false + +DB_USERNAME=postgres DB_PASSWORD= -DB_HOST= -DB_PORT= -DB_NAME= +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=hrms +# Blank: require when PROD_ENV=true, off when local. Override only if needed. +DB_SSLMODE= + +DB_AUTO_MIGRATE=true +DB_AUTOGENERATE=true + EMAIL_URL= EMAIL_API_TOKEN= # Optional overrides; blank falls back to EMAIL_URL / EMAIL_API_TOKEN. @@ -25,7 +39,7 @@ RESET_CODE_TTL_SECONDS=60 RESET_CODE_RESEND_SECONDS=30 RESET_CODE_MAX_ATTEMPTS=5 -FRONTEND_URL=http://localhost:5173 +FRONTEND_URL=http://127.0.0.1:5173 CONFIRM_EMAIL_PATH=/auth/confirm-email CONFIRM_TOKEN_TTL_SECONDS=86400 CONFIRM_TOKEN_RESEND_SECONDS=60 @@ -47,32 +61,27 @@ OPENAI_BASE_URL= OPENAI_ORGANIZATION= OPENAI_PROJECT= -# ATS scoring (bulk-ats engine embedded via `pip install -e ..`). -# OPENAI_API_KEY / OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS above are shared. +# ATS scoring (bulk-ats engine). Shared OPENAI_* names above. OPENAI_EFFORT=low OPENAI_ENABLE_PROMPT_CACHE=true +OPENAI_TIMEOUT_SECONDS=120 SCORING_CONCURRENCY=5 MAX_RESUMES_PER_REQUEST=50 MAX_PDF_SIZE_MB=10 MAX_JD_CHARS=30000 MAX_RESUME_CHARS=60000 -# Inbox intake gate (inbox_classifier/): only mail judged to be a job application -# gets an inbox_messages row; every verdict is logged to inbox_message_triage. -# Model / token / effort / cache knobs are the OPENAI_* ones above. -# false restores the pre-gate behaviour exactly — the rollback lever. +# Inbox intake gate (inbox_classifier/). INBOX_TRIAGE_ENABLED=true -# true: a provider outage or missing key ingests the mail and marks the verdict -# unclassified. false: skip it and leave it for a later /email/fetch. INBOX_TRIAGE_FAIL_OPEN=true INBOX_TRIAGE_CONCURRENCY=5 INBOX_TRIAGE_MAX_SUBJECT_CHARS=300 INBOX_TRIAGE_MAX_BODY_CHARS=4000 -# 0 disables the uncertainty branch; >0 routes low-confidence verdicts to the -# INBOX_TRIAGE_FAIL_OPEN policy. INBOX_TRIAGE_MIN_CONFIDENCE=0 -REDIS_URL=redis://localhost:6379/0 +# Compose overrides these on the network; keep docker DNS names for containers. +REDIS_URL=redis://redis:6379/0 +BACKEND_URL=http://backend-api:8000 TASKIQ_QUEUE_NAME=inbox TASKIQ_CV_QUEUE_NAME=cv_upload TASKIQ_MAX_RETRIES=3 @@ -82,3 +91,17 @@ TASKIQ_DLQ_STREAM=taskiq:dlq TASKIQ_IDLE_TIMEOUT_MS=600000 MANUAL_UPLOAD_TO_ADDRESS=manual-cv-upload@hr-ats.local APP_VERSION=dev + +# Compose host ports (docker compose --env-file ./backend/.env …). +FRONTEND_PORT=5173 +BACKEND_PORT=8000 +ATS_PORT=8100 +REDIS_PORT=6379 +POSTGRES_PORT=5433 +UVICORN_WORKERS=2 +# Empty = same-origin via nginx on :5173. For Vite on the host, use +# VITE_API_BASE=http://127.0.0.1:8000 (backend is published on BACKEND_PORT). +VITE_API_BASE= + +LOG_FORMAT=json +LOG_LEVEL=INFO diff --git a/backend/README.md b/backend/README.md index 6807e88..c023607 100644 --- a/backend/README.md +++ b/backend/README.md @@ -837,8 +837,8 @@ 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`. +`db_setup.Settings` reads `backend/.env` only (no repo-root `.env`); every other module reads its +own keys with `os.getenv` from the same file. ### Database @@ -1053,7 +1053,9 @@ Local host-Postgres + reload uses the dev overlay. See repo-root **[DOCKER.md](. # Production docker compose up -d --build -# Local (host DB, published ports, --reload) +# Default (host Postgres or RDS via backend/.env PROD_ENV + DB_*) +docker compose up -d --build +# Optional live-reload / bind mounts 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 diff --git a/backend/db_setup.py b/backend/db_setup.py index c57371e..3c09091 100644 --- a/backend/db_setup.py +++ b/backend/db_setup.py @@ -1,9 +1,9 @@ """PostgreSQL connection, async SQLAlchemy ORM and session management. -Configuration comes from the environment, with `.env` read from the repo root or -from `backend/` (`Db_USERNAME`, `Db_PASSWORD`, `Db_HOST`, `Db_PORT`, `Db_NAME`, and -the `DB_*` tuning fields below). Alembic lives in `alembic_setup.py`; `init_db()` -calls into it. +Configuration comes from the environment, with `.env` read from `backend/` +(`DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `PROD_ENV`, and +the `DB_*` tuning fields below). There is no repo-root `.env`. Alembic lives in +`alembic_setup.py`; `init_db()` calls into it. app = FastAPI(lifespan=lifespan) # migrate on startup async def endpoint(db: AsyncSession = Depends(get_session)): ... @@ -13,13 +13,14 @@ calls into it. from __future__ import annotations import asyncio -import os import logging +import os from contextlib import asynccontextmanager from functools import lru_cache from pathlib import Path from typing import Annotated, Any, AsyncIterator, Sequence +from dotenv import load_dotenv from pydantic import field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from sqlalchemy import MetaData, text @@ -32,40 +33,48 @@ from sqlalchemy.ext.asyncio import ( ) from sqlalchemy.orm import DeclarativeBase from sqlmodel import SQLModel -from dotenv import load_dotenv load_dotenv() logger = logging.getLogger("db") BASE_DIR = Path(__file__).resolve().parent +_TRUE = {"1", "true", "yes", "on"} +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1"}) + + +def _running_in_docker() -> bool: + """True inside a container (/.dockerenv) or when Compose sets IN_DOCKER=1.""" + return Path("/.dockerenv").exists() or os.environ.get("IN_DOCKER", "").strip().lower() in _TRUE + class Settings(BaseSettings): """Every field is overridden by an environment variable of the same name.""" model_config = SettingsConfigDict( - env_file=(BASE_DIR.parent / ".env", BASE_DIR / ".env"), extra="ignore" + env_file=BASE_DIR / ".env", + extra="ignore", ) - database_url: str = "" # full DSN; wins over the Db_* parts below - db_username: str = os.getenv("DB_USERNAME") - db_password: str = os.getenv("DB_PASSWORD") - db_host: str = os.getenv("DB_HOST") - db_port: int = int(os.getenv("DB_PORT")) - db_name: str = os.getenv("DB_NAME") - db_sslmode: str = "" # e.g. "require" on Azure - + database_url: str = "" # full DSN; wins over the DB_* parts below + db_username: str = "" + db_password: str = "" + db_host: str = "localhost" + db_port: int = 5432 + db_name: str = "" + db_sslmode: str = "" # blank = derive from PROD_ENV (require on RDS, off locally) + prod_env: bool = False # true → RDS (SSL); false → local psql over asyncpg db_schemas: Annotated[list[str], NoDecode] = "app" - db_default_schema: str = "app" # schema for models that declare none + db_default_schema: str = "app" db_echo: bool = False db_pool_size: int = 5 db_max_overflow: int = 10 db_pool_recycle: int = 1800 db_connect_retries: int = 10 - db_auto_migrate: bool = True # run `upgrade head` on startup - db_autogenerate: bool = True # write a revision when models drift from the schema - db_model_modules: Annotated[list[str], NoDecode] = [] # empty means auto-discover + db_auto_migrate: bool = True + db_autogenerate: bool = True + db_model_modules: Annotated[list[str], NoDecode] = [] app_name: str = "hr-ats-portal" @field_validator("db_schemas", "db_model_modules", mode="before") @@ -75,8 +84,20 @@ class Settings(BaseSettings): return [item.strip() for item in value.split(",") if item.strip()] return value + @field_validator("prod_env", mode="before") + @classmethod + def _bool(cls, value: Any) -> Any: + if isinstance(value, str): + return value.strip().lower() in _TRUE + return value + def url(self, *, async_driver: bool = True) -> URL: - """DSN with the driver forced; `sslmode` is mapped to asyncpg's `ssl` mode name.""" + """DSN with the driver forced; `sslmode` is mapped to asyncpg's `ssl` mode name. + + Local Docker: `DB_HOST=localhost` means the container itself, so rewrite to + `host.docker.internal` for the connection URL only (Settings.db_host unchanged). + Prod never rewrites — RDS hostname is used as-is. + """ url = ( make_url(self.database_url) if self.database_url @@ -89,10 +110,22 @@ class Settings(BaseSettings): self.db_name, ) ) + if ( + not self.prod_env + and _running_in_docker() + and (url.host or "").lower() in _LOOPBACK_HOSTS + ): + url = url.set(host="host.docker.internal") + query = dict(url.query) - if self.db_sslmode: - query.setdefault("sslmode", self.db_sslmode) - # asyncpg accepts ssl as an SSLMode name (require, verify-full, …), not "true". + + # PROD_ENV=true → RDS needs SSL. Local psql talks plain asyncpg (no SSL). + sslmode = self.db_sslmode.strip() if self.db_sslmode else ("require" if self.prod_env else "") + if sslmode: + query.setdefault("sslmode", sslmode) + else: + query.pop("sslmode", None) + if async_driver and (mode := query.pop("sslmode", None)) is not None: query["ssl"] = mode driver = "asyncpg" if async_driver else "psycopg2" @@ -199,11 +232,16 @@ async def close_db() -> None: async def check_connection(retries: int | None = None, delay: float = 1.0) -> None: """Wait for Postgres to answer `SELECT 1`, retrying with a capped backoff.""" attempts = get_settings().db_connect_retries if retries is None else retries + s = get_settings() for attempt in range(1, max(attempts, 1) + 1): try: async with get_engine().connect() as conn: await conn.execute(text("SELECT 1")) - logger.info("connected to %s", database_url(hide_password=True)) + logger.info( + "connected to %s [PROD_ENV=%s]", + database_url(hide_password=True), + s.prod_env, + ) return except Exception as exc: if attempt >= attempts: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 63eb9f1..19e319c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,33 +1,16 @@ -# Local / host-Postgres overlay. Restores the previous day-to-day workflow on top -# of the production docker-compose.yml: +# Optional local overlay — NOT required for day-to-day use. # +# Default workflow (host Postgres + loopback API ports) is in docker-compose.yml: +# docker compose up -d --build +# +# Use this file only when you want --reload and bind-mounted source: # docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build # -# - 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 -# -# Frontend stays as the built nginx image — run `npm run dev` on the host for the -# Vite loop if you need HMR. +# Do not set DB_HOST here. Keep PROD_ENV=false and DB_HOST=localhost in +# backend/.env; db_setup rewrites localhost → host.docker.internal in Docker. 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", @@ -38,8 +21,6 @@ services: "8000", "--reload", ] - ports: - - "${BACKEND_PORT:-8000}:8000" volumes: - ./backend:/app - ./app:/app/app @@ -57,8 +38,6 @@ services: "8100", "--reload", ] - ports: - - "${ATS_PORT:-8100}:8100" volumes: - ./app:/srv/app @@ -67,66 +46,28 @@ services: - "${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 diff --git a/docker-compose.host-ports.yml b/docker-compose.host-ports.yml new file mode 100644 index 0000000..6a51f26 --- /dev/null +++ b/docker-compose.host-ports.yml @@ -0,0 +1,24 @@ +# Optional overlay: publish API / ATS / Redis on loopback for host tools +# (curl, redis-cli, Postman). Not required for the SPA - nginx proxies +# to backend-api on the Compose network. +# +# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d +# +# If bind fails because an IDE (Cursor/VS Code) still holds the port after a +# previous run, clear Port Forwarding in the IDE or override in backend/.env: +# BACKEND_PORT=8001 +# ATS_PORT=8101 +# REDIS_PORT=6380 + +services: + redis: + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + + backend-api: + ports: + - "127.0.0.1:${BACKEND_PORT:-8000}:8000" + + ats-engine: + ports: + - "127.0.0.1:${ATS_PORT:-8100}:8100" diff --git a/docker-compose.yml b/docker-compose.yml index 884bc7d..9d0a3d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,26 @@ -# HR-ATS-Portal — production Compose stack (self-contained). +# HR-ATS-Portal — single Compose file for local and production. # -# docker compose up -d --build -# docker compose ps -# docker compose logs -f backend-api +# Sole env file: backend/.env (no repo-root .env). Pass it for Compose +# variable substitution (${FRONTEND_PORT}, ${DB_*}, …): # -# 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. +# docker compose --env-file ./backend/.env up -d --build +# docker compose --env-file ./backend/.env ps +# docker compose --env-file ./backend/.env logs -f backend-api # -# Local / current host-Postgres workflow (exposed ports, --reload, bind mounts): -# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build +# Local (PROD_ENV=false, DB_HOST=localhost in backend/.env): +# Containers reach host Postgres via host.docker.internal (db_setup rewrite +# when IN_DOCKER=1). API / ATS / Redis stay on the Compose network by default +# (avoids IDE/Cursor stale port-forwards fighting Docker on Windows). Opt in: +# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d +# +# Prod (PROD_ENV=true, DB_* = RDS in backend/.env — edit manually): +# Same command. No host rewrite; SSL require when DB_SSLMODE is blank. +# +# Optional Compose Postgres (empty volume, not host/RDS data): +# docker compose --env-file ./backend/.env --profile postgres up -d postgres +# +# Optional live-reload / bind mounts (not required day-to-day): +# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.dev.yml up -d --build # # See DOCKER.md for env checklist and verification. @@ -27,26 +38,21 @@ x-backend-build: &backend-build x-backend-env: &backend-env PYTHONPATH: /app - # 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} + # Lets db_setup rewrite DB_HOST=localhost → host.docker.internal (local only). + IN_DOCKER: "1" + # Credentials and endpoints come from backend/.env via `env_file` below. + # DB_HOST is deliberately ABSENT here: an empty `environment:` override would + # blank env_file / RDS. Do not name credential keys under environment. + # + # REDIS_URL and BACKEND_URL are compose-network DNS names, correct everywhere. REDIS_URL: redis://redis:6379/0 - # 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 + # Apify token, if kept at repo root rather than in backend/.env. Harmless when + # unset: the app treats empty as absent. + APIFY_API_TOKEN: ${APIFY_API_TOKEN:-${APIFY_TOKEN:-}} -# 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. +# Shared CV storage. Named volume so API + workers see the same files. +# Optional docker-compose.dev.yml remounts ./backend/inbox/decoded_attachments. x-attachments: &attachments - attachments-data:/app/inbox/decoded_attachments @@ -55,15 +61,22 @@ x-backend-service: &backend-service image: hrms-backend:local working_dir: /app env_file: + # backend/.env is the source of truth (plain DB_* + PROD_ENV). - ./backend/.env + # Optional local overrides (required:false). Do not set DB_HOST=postgres + # here unless you intentionally start the postgres profile. + - path: ./docker.local.env + required: false environment: *backend-env extra_hosts: - "host.docker.internal:host-gateway" depends_on: redis: condition: service_healthy + # required:false — default stack never starts postgres (host Postgres or RDS). postgres: condition: service_healthy + required: false restart: unless-stopped logging: *default-logging @@ -73,7 +86,8 @@ services: image: redis:7-alpine container_name: hrms-redis command: ["redis-server", "--appendonly", "yes"] - # Not published in production. Dev overlay binds ${REDIS_PORT:-6379}:6379. + # No host publish by default (Compose DNS redis:6379). Optional loopback: + # docker-compose.host-ports.yml volumes: - redis-data:/data healthcheck: @@ -84,19 +98,29 @@ services: restart: unless-stopped logging: *default-logging - # --- Postgres (always on in production) ------------------------------------------ + # --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT ------------------------------- + # Default local path is host Postgres (DB_HOST=localhost → host.docker.internal). + # Prod uses AWS RDS. Opt in only when you want a disposable Compose DB: + # + # docker compose --profile postgres up -d postgres + # # then set DB_HOST=postgres in backend/.env and recreate backend services + # postgres: + profiles: ["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. + # Compose substitution needs: docker compose --env-file ./backend/.env … + # (there is no repo-root .env). Defaults apply if the flag is omitted. 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. + ports: + # 5433: the host's own Postgres owns 5432. + - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" volumes: - postgres-data:/var/lib/postgresql/data healthcheck: @@ -126,7 +150,9 @@ services: "--proxy-headers", "--forwarded-allow-ips=*", ] - # Not published in production — browsers reach the API via frontend nginx. + # Published for host tools / Vite (`VITE_API_BASE=http://127.0.0.1:8000`). + ports: + - "${BACKEND_PORT:-8000}:8000" volumes: *attachments healthcheck: test: @@ -150,9 +176,12 @@ services: container_name: hrms-ats-engine env_file: - ./backend/.env - - path: ./.env + - path: ./docker.local.env required: false - # Not published in production. + environment: + IN_DOCKER: "1" + # No host publish by default (backend uses ats-engine:8100). Optional: + # docker-compose.host-ports.yml healthcheck: test: [ @@ -168,7 +197,7 @@ services: restart: unless-stopped logging: *default-logging - # --- React portal (only published host port) ------------------------------------- + # Portal on FRONTEND_PORT (default 5173). API also on BACKEND_PORT (8000). frontend: build: context: ./frontend @@ -181,7 +210,7 @@ services: backend-api: condition: service_healthy ports: - - "${FRONTEND_PORT:-80}:80" + - "${FRONTEND_PORT:-5173}:80" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] interval: 15s diff --git a/frontend/.env.development b/frontend/.env.development index de07be0..0426bbe 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,10 @@ -# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and hits a -# different listener (WSL/Docker on :8000) instead of the Windows uvicorn on 127.0.0.1. +# Use 127.0.0.1, not localhost. On this machine localhost prefers ::1 and can +# hit a different listener than the Windows uvicorn / Docker publish. +# +# Docker Compose (prod-like): leave empty and run `npm run dev` with the +# host-ports overlay so Vite can proxy — or open the built SPA on :8080 +# (nginx same-origin proxy; no VITE_API_BASE needed). +# +# Hybrid (Vite on host + API in Docker): publish API first, then: +# docker compose --env-file ./backend/.env -f docker-compose.yml -f docker-compose.host-ports.yml up -d VITE_API_BASE=http://127.0.0.1:8000 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9ebb5f2..139069d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -14,11 +14,15 @@ server { 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. 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)(/|$) { + # fetch('/jobs/fetch') stays on this host. OpenAPI (/docs, /redoc, + # /openapi.json) is intentionally NOT proxied. + # + # Paths that are BOTH React routes (/jobs, /inbox, …) and API prefixes must + # require a sub-path: otherwise a cold open / refresh of /jobs is stolen by + # the proxy and returns a FastAPI 404 instead of index.html. + + # SPA page roots that also prefix API calls — sub-path required. + location ~ ^/(jobs|inbox|pipeline|tasks|assessments|offers|managers|analytics|notifications)/ { proxy_pass http://backend-api:8000; proxy_http_version 1.1; proxy_set_header Host $host; @@ -29,6 +33,22 @@ server { proxy_connect_timeout 10s; proxy_send_timeout 120s; proxy_read_timeout 120s; + proxy_request_buffering off; + } + + # API-only prefixes (no SPA page at the bare path). + location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet)(/|$) { + proxy_pass http://backend-api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + 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; + proxy_request_buffering off; } # One SPA at `/`. Without this, /auth/confirm-email (a router path, not a diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 99b4120..2f34a4e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -16,6 +16,16 @@ export default defineConfig({ base: '/', build: { outDir: 'dist', emptyOutDir: true, sourcemap: true }, resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, - server: { port: 5173 }, + server: { + port: 5173, + // Same-origin style for local Vite when VITE_API_BASE is empty. + // Requires API published on the host (docker-compose.host-ports.yml). + proxy: { + '^/(health|users|roles|permissions|permission-tags|email|job|jobs|candidate|notes|interview|feedback|activity|pipeline|notifications|analytics|offers|tasks|assessments|org-settings|saved-searches|search|documents|sheet|managers|inbox)(/|$)': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, preview: { port: 4173 }, })