From f7778538dd222c7004cd4c9a237f5f04bedf09ad Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 17 Aug 2026 14:31:30 +0500 Subject: [PATCH] push the scoring connect --- .dockerignore | 41 +++ README.md | 77 +++++ app/Dockerfile | 35 +++ backend/Dockerfile | 44 ++- docker-compose.dev.yml | 60 ++++ docker-compose.yml | 274 +++++++++++++----- docker/postgres/Dockerfile | 24 ++ docker/postgres/initdb/01-init.sql | 17 ++ frontend/.dockerignore | 9 + frontend/Dockerfile | 30 ++ frontend/dist/index.html | 2 +- frontend/nginx.conf | 30 ++ .../src/screens/ScoredCandidateProfile.jsx | 198 +++++++++---- 13 files changed, 715 insertions(+), 126 deletions(-) create mode 100644 .dockerignore create mode 100644 app/Dockerfile create mode 100644 docker-compose.dev.yml create mode 100644 docker/postgres/Dockerfile create mode 100644 docker/postgres/initdb/01-init.sql create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fa8125f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Build context for backend/Dockerfile and app/Dockerfile is the repo root, so this +# file decides what is even eligible to be copied into those images. + +.git/ +.gitignore +.gitignore.local +.claude/ +.cursor/ +.vscode/ +.idea/ + +# Secrets are passed at runtime via compose `env_file` — never baked into a layer. +**/.env +**/.env.* +!**/.env.example + +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.venv/ +venv/ +env/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Frontend has its own context (./frontend) and its own .dockerignore; nothing of it +# belongs in a Python image, and node_modules would dominate the context transfer. +frontend/ + +# Candidate CVs live on the bind mount, not inside an image. +backend/inbox/decoded_attachments/ + +docs/ +tests/ +scripts/ +tools/ +*.md +*.log +tmp/ +temp/ diff --git a/README.md b/README.md index 76b5d94..67d821b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,83 @@ Optional — background inbox sync workers (need Redis): 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. + +```bash +docker compose build +docker compose up -d +docker compose ps +``` + +| 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: + +```bash +docker compose --profile postgres build postgres +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 +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 diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..99613fb --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# +# 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 . + +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/srv + +WORKDIR /srv + +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 . + +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 3a584e1..1da523b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,43 @@ +# 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:`. +# +# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend: +# +# docker build -f backend/Dockerfile -t hrms-backend:local . +# +# backend/job/candidate imports the bulk-ats scoring engine (`app.core.errors`, +# `app.services.pdf`, `app.services.scoring`), which lives in app/ at the repo root +# and is pulled in transitively by inbox.plugins -> inbox.tasks. A ./backend context +# cannot see it, so the workers would die on import. + FROM python:3.12-slim -WORKDIR /app -ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app -COPY requirements.txt . +WORKDIR /app + +COPY backend/requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt -COPY . . +# 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. +COPY backend/ /app/ +COPY app/ /app/app/ -# Runs the Taskiq worker against taskiq_management.broker_setup. -# docker-compose overrides this command if needed. -CMD ["taskiq", "worker", "taskiq_management.broker_setup:broker", "inbox.tasks", "inbox.sync_tasks", "taskiq_management.tasks"] +# 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 + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..9286ca9 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,60 @@ +# 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. +# +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# +# 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. +# +# 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. + +services: + backend-api: + command: + ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + volumes: + - ./backend:/app + - ./app:/app/app + + ats-engine: + command: + [ + "uvicorn", + "app.main:create_app", + "--factory", + "--host", + "0.0.0.0", + "--port", + "8100", + "--reload", + ] + volumes: + - ./app:/srv/app + + taskiq-worker: + volumes: + - ./backend:/app + - ./app:/app/app + + taskiq-scheduler: + volumes: + - ./backend:/app + - ./app:/app/app + + taskiq-cv-worker: + volumes: + - ./backend:/app + - ./app:/app/app + + taskiq-cv-scheduler: + volumes: + - ./backend:/app + - ./app:/app/app diff --git a/docker-compose.yml b/docker-compose.yml index e29ec44..bcb6da6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,82 @@ +# HR-ATS-Portal — every service and every image, in one file. +# +# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend +# docker compose up -d +# 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). +# +# 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). +# +# 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 + +x-backend-build: &backend-build + # Root context, not ./backend: backend/job/candidate imports the bulk-ats engine + # from app/, which sits outside the backend folder. See backend/Dockerfile. + context: . + dockerfile: backend/Dockerfile + +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} + REDIS_URL: redis://redis:6379/0 + 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. +x-attachments: &attachments + - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments + +x-backend-service: &backend-service + build: *backend-build + image: hrms-backend:local + working_dir: /app + env_file: + - ./backend/.env + environment: *backend-env + extra_hosts: + - "host.docker.internal:host-gateway" + 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 + services: + # --- Redis (broker + result backend for taskiq) ---------------------------------- redis: image: redis:7-alpine container_name: hrms-redis @@ -14,11 +92,80 @@ services: retries: 5 restart: unless-stopped - taskiq-worker: + # --- 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" + 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()", + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 40s + + # --- bulk ATS scoring engine (standalone service form of app/) -------------------- + ats-engine: build: - context: ./backend + context: . + dockerfile: app/Dockerfile + 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" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8100/api/v1/health',timeout=3)", + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + + # --- React portal ----------------------------------------------------------------- + frontend: + build: + context: ./frontend + args: + # Baked into the bundle at build time — change it and rebuild, not restart. + VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000} + image: hrms-frontend:local + container_name: hrms-frontend + ports: + - "${FRONTEND_PORT:-5173}:80" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"] + interval: 15s + timeout: 5s + retries: 5 + restart: unless-stopped + + # --- background processing (same image as backend-api, different command) --------- + taskiq-worker: + <<: *backend-service container_name: hrms-taskiq-worker - working_dir: /app command: [ "taskiq", @@ -30,53 +177,29 @@ services: "--workers", "1", ] - env_file: - - ./backend/.env environment: - PYTHONPATH: /app - REDIS_URL: redis://redis:6379/0 + <<: *backend-env TASKIQ_QUEUE_NAME: inbox TASKIQ_WORKER_NAME: worker-01 - # .env uses localhost for the host-side API; containers must reach the host. - DB_HOST: host.docker.internal - EMAIL_URL: http://host.docker.internal:5000 - BACKEND_URL: http://host.docker.internal:8000 - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments - depends_on: - redis: - condition: service_healthy - restart: unless-stopped + volumes: *attachments taskiq-scheduler: - build: - context: ./backend + <<: *backend-service container_name: hrms-taskiq-scheduler - working_dir: /app - command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"] - env_file: - - ./backend/.env + command: + [ + "taskiq", + "scheduler", + "taskiq_management.broker_setup:scheduler", + "inbox.sync_tasks", + ] environment: - PYTHONPATH: /app - REDIS_URL: redis://redis:6379/0 + <<: *backend-env TASKIQ_QUEUE_NAME: inbox - DB_HOST: host.docker.internal - EMAIL_URL: http://host.docker.internal:5000 - BACKEND_URL: http://host.docker.internal:8000 - extra_hosts: - - "host.docker.internal:host-gateway" - depends_on: - redis: - condition: service_healthy - restart: unless-stopped taskiq-cv-worker: - build: - context: ./backend + <<: *backend-service container_name: hrms-taskiq-cv-worker - working_dir: /app command: [ "taskiq", @@ -86,30 +209,15 @@ services: "--workers", "1", ] - env_file: - - ./backend/.env environment: - PYTHONPATH: /app - REDIS_URL: redis://redis:6379/0 + <<: *backend-env TASKIQ_CV_QUEUE_NAME: cv_upload TASKIQ_WORKER_NAME: cv-worker-01 - DB_HOST: host.docker.internal - EMAIL_URL: http://host.docker.internal:5000 - BACKEND_URL: http://host.docker.internal:8000 - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments - depends_on: - redis: - condition: service_healthy - restart: unless-stopped + volumes: *attachments taskiq-cv-scheduler: - build: - context: ./backend + <<: *backend-service container_name: hrms-taskiq-cv-scheduler - working_dir: /app command: [ "taskiq", @@ -117,21 +225,49 @@ services: "taskiq_management.cv_broker_setup:cv_scheduler", "inbox.cv_tasks", ] - env_file: - - ./backend/.env environment: - PYTHONPATH: /app - REDIS_URL: redis://redis:6379/0 + <<: *backend-env TASKIQ_CV_QUEUE_NAME: cv_upload - DB_HOST: host.docker.internal - EMAIL_URL: http://host.docker.internal:5000 - BACKEND_URL: http://host.docker.internal:8000 - extra_hosts: - - "host.docker.internal:host-gateway" - depends_on: - redis: - condition: service_healthy + + # --- 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: diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile new file mode 100644 index 0000000..ac681d2 --- /dev/null +++ b/docker/postgres/Dockerfile @@ -0,0 +1,24 @@ +# 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 +# +# Context is ./docker/postgres. + +FROM postgres:16-alpine + +# The inbox tables store tz-aware timestamps and the app reads them as UTC +# (backend/migrations/versions/20260812_1035-b3f1c2d4e5a6_inbox_timestamps_tz_aware.py). +ENV TZ=UTC \ + PGTZ=UTC + +# Runs once, against an empty data volume only. +COPY initdb/ /docker-entrypoint-initdb.d/ diff --git a/docker/postgres/initdb/01-init.sql b/docker/postgres/initdb/01-init.sql new file mode 100644 index 0000000..4b98944 --- /dev/null +++ b/docker/postgres/initdb/01-init.sql @@ -0,0 +1,17 @@ +-- Runs once, on first initialisation of an empty data volume. + +-- Timestamps are stored tz-aware and read back as UTC by the backend. +ALTER SYSTEM SET timezone TO 'UTC'; +ALTER SYSTEM SET log_timezone TO 'UTC'; + +-- Known migration gap in the inbox module: Alembic autogeneration creates every +-- table on first boot, but not this enum type, and a brand-new database fails +-- without it (README "Fresh database — one manual step"). +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_application_status') THEN + CREATE TYPE candidate_application_status AS ENUM + ('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED'); + END IF; +END +$$; diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c7d81d3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.vite/ +tmp/ +.env +.env.* +!.env.development +!.env.production +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..caa77df --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 +# +# React portal: Vite build in node, served by nginx with the SPA history fallback. +# Context is ./frontend. +# +# docker build -t hrms-frontend:local ./frontend + +FROM node:22-alpine AS build + +WORKDIR /src + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# Vite inlines VITE_* at BUILD time, so the API origin is fixed when the image is +# built, not when the container starts — rebuild the image to point it elsewhere. +# `.env.production.local` outranks every other env file, so this wins over the empty +# VITE_API_BASE in .env.production (which means "same origin, behind a proxy"). +ARG VITE_API_BASE=http://localhost:8000 +RUN printf 'VITE_API_BASE=%s\n' "$VITE_API_BASE" > .env.production.local \ + && npm run build + +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/dist/index.html b/frontend/dist/index.html index af70aa1..59405ff 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,7 +23,7 @@ - + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..147d1dd --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # 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 + # file) 404s when a confirmation email link is opened cold. + location / { + try_files $uri $uri/ /index.html; + } + + # Hashed filenames, so they can be cached hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # index.html must never be cached, or a redeploy keeps serving the old asset hashes. + location = /index.html { + add_header Cache-Control "no-store"; + } + + gzip on; + gzip_min_length 1024; + gzip_types text/css text/javascript application/javascript application/json image/svg+xml; +} diff --git a/frontend/src/screens/ScoredCandidateProfile.jsx b/frontend/src/screens/ScoredCandidateProfile.jsx index 5c74999..633f576 100644 --- a/frontend/src/screens/ScoredCandidateProfile.jsx +++ b/frontend/src/screens/ScoredCandidateProfile.jsx @@ -1,6 +1,15 @@ /* The profile modal for candidate rows on /candidates (identity from /candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct - from CandidateProfile.jsx, which renders the 8-tab TalentPool modal. */ + from CandidateProfile.jsx, which renders the 8-tab TalentPool modal. + + SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch, + the candidate's current ats_results row. It has to be, because the detail + payload is not a source of scoring at all — serialize_candidate_profile and + serialize_manual_candidate_profile both hardcode ai_score, matched_keywords, + missing_keywords, summary_critique and scored_at to null/[] + (backend/job/candidate/serializers.py:116, 174-185). Reading scoring from it + meant every candidate on this screen showed "Not scored yet" however many + times the engine had actually scored them. */ import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' @@ -12,6 +21,7 @@ import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' +import * as pipelineApi from '../api/pipeline' const TABS = ['Overview', 'Scoring', 'File'] const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 } @@ -41,6 +51,13 @@ function formatExperience(value, unit) { return text } +/** ats_results.computed_at is an ISO string; fmtDate takes a Date. */ +function fmtStamp(value) { + if (!value) return null + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : fmtDate(d) +} + function useCandidateDetail(userId) { return useQuery({ queryKey: qk.candidates.detail(userId), @@ -49,11 +66,51 @@ function useCandidateDetail(userId) { }) } +/** + * The candidate's current ats_results row — the only scoring source this modal has. + * + * Sent WITHOUT job_post_id, for the same reason Talent Pool omits it: a row here + * is a candidate USER account with no job context (toCandidateUserView leaves + * jobId null), so pinning could only ever hide a score that exists under some + * other post. Unpinned, the endpoint answers with the newest current score the + * candidate has anywhere. + * + * Keyed by qk.pipeline.candidateScore, so re-opening the same candidate — or + * opening one already viewed in Talent Pool — repaints from cache. + */ +function useAtsResult(userId) { + return useQuery({ + queryKey: qk.pipeline.candidateScore({ userId: userId ?? null }), + queryFn: () => pipelineApi.fetchCandidateScore({ userId }), + select: pipelineApi.toAtsScore, + enabled: Boolean(userId), + }) +} + +/** + * job_post_id -> title, so the score reads as "scored against Senior Backend + * Engineer" rather than a uuid. Same query key and row shape as the Candidates + * screen's own jobs query, so this is a cache hit rather than a second request. + */ +function useJobTitles() { + return useQuery({ + queryKey: qk.jobPosts.list(), + queryFn: async () => { + const res = await candidatesApi.listJobs() + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.map((row) => ({ id: row.id, title: row.title })) + }, + select: (rows) => new Map(rows.map((row) => [String(row.id), row.title])), + }) +} + export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) { const [tab, setTab] = useState('Overview') const isLive = Boolean(c.userId) const detail = useCandidateDetail(c.userId) const live = detail.data ?? null + const ats = useAtsResult(c.userId) + const atsRow = ats.data ?? null const view = useMemo(() => { const currentTitle = stripSentinel(live?.current_title) ?? c.currentTitle ?? null @@ -65,17 +122,20 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose live?.documents?.[0]?.name || c.filename || null const matchSummary = live?.match_summary ?? null const messageId = live?.message_id ?? null - const aiScore = live?.ai_score ?? c.aiScore ?? null + // ats_results wins over the detail payload's denormalised copy, because it is + // the row the copy is made from — and on this screen the copy is always null. + const aiScore = atsRow?.overall_score ?? live?.ai_score ?? c.aiScore ?? null const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? [] const missingSkills = live?.missing_keywords ?? c.missingSkills ?? [] const critique = live?.summary_critique ?? c.critique ?? null const errorCode = live?.error_code ?? c.errorCode ?? null const errorMessage = live?.error_message ?? live?.match_error ?? c.errorMessage ?? null const scoredFor = live?.job_title ?? jobTitle ?? null - const scored = live + const scored = atsRow != null || (live ? Boolean(live.scored_at || live.ai_score != null) - : c.scoringStatus === 'completed' + : c.scoringStatus === 'completed') return { + band: atsRow?.band || null, name: c.name, email: c.email, applied: c.applied, @@ -100,7 +160,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose sourceLabel: SOURCE_LABEL[source] ?? source ?? '—', subtitle: filename || c.email || null, } - }, [c, live, jobTitle]) + }, [c, live, atsRow, jobTitle]) // enabled:false stays pending forever in TanStack v5 — short-circuit when no userId. const guard = !isLive ? null @@ -146,8 +206,9 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose {view.aiScore != null && (
- -
AI Match
+ + {/* The band replaces the static label only when the ATS row answered. */} +
{view.band || 'AI Match'}
)} @@ -157,7 +218,12 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
- {guard ?? (<> + {/* Scoring sits OUTSIDE `guard`: it renders from the ats_results query, so + a slow or failed detail fetch must not blank it, and its own loading + and error states belong to that query. */} + {tab === 'Scoring' && } + + {tab !== 'Scoring' && (guard ?? (<> {tab === 'Overview' && ( <>
@@ -168,67 +234,101 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
Source
{view.sourceLabel}
Added On
{view.applied ? fmtDate(view.applied) : '—'}
- {view.scored && ( + {/* Gated on the list being non-empty, not on `scored`: the detail + payload never carries keywords, so keying it to the score would + render a heading over a dash for every scored candidate. */} + {view.matchedSkills.length > 0 && ( <>
Matched Skills
- {view.matchedSkills.length - ? view.matchedSkills.map((s) => {s}) - : } + {view.matchedSkills.map((s) => {s})}
)} )} - {tab === 'Scoring' && ( - view.scored ? ( - <> -
AI Assessment
-

{view.critique ?? '—'}

-
- Matched Skills ({view.matchedSkills.length}) -
-
- {view.matchedSkills.length - ? view.matchedSkills.map((s) => ( - {s} - )) - : } -
-
- Missing Skills ({view.missingSkills.length}) -
-
- {view.missingSkills.length - ? view.missingSkills.map((s) => ( - {s} - )) - : None — full match} -
- - ) : ( - - This candidate has not been scored against a job post. - - ) - )} - {tab === 'File' && (
File Name
{view.filename ?? '—'}
Source
{view.sourceLabel}
- {view.messageId && ( -
Inbox Message
{view.messageId}
- )}
Detail
{view.matchSummary ?? '—'}
{view.errorCode && (
Error
{view.errorCode}
)}
)} - )} + ))}
) } + +/** + * The ats_results row, and nothing else. + * + * What that table stores IS the result: overall_score, band, the job post it was + * computed against, and when (backend/inbox/models.py::AtsResults). The matched + * and missing keywords and the critique live on the `candidates` table, which + * this endpoint does not join — so they are absent here rather than rendered as + * a heading over a dash. + */ +function ScoringTab({ enabled, ats, fallbackJobTitle }) { + // Before the early returns: hook order cannot depend on query state. + const { data: jobTitles } = useJobTitles() + const row = ats.data ?? null + + // enabled:false stays pending forever in TanStack v5, so a candidate with no + // userId must short-circuit rather than spin. + if (!enabled) { + return ( + + This candidate has no account to look a score up against. + + ) + } + if (ats.isPending) { + return Fetching the ATS result. + } + if (ats.isError) { + return ( + + {friendlyAuthError(ats.error, 'Please try again.')} + + ) + } + if (!row) { + return ( + + This candidate has not been scored against a job post. + + ) + } + + // The uuid resolves to a title only once the jobs list is cached; the prop is + // the fallback, and it is '—' on this screen when the row carries no job. + const against = (row.job_post_id && jobTitles?.get(String(row.job_post_id))) || fallbackJobTitle || '—' + + return ( + <> +
+ {/* overall_score is a float column; the ring and the label both want an int. */} + +
+
{row.band || 'Scored'}
+
ATS match score out of 100
+
+
+
+
+
Scored Against
+
{against}
+
+
+
Scored On
+
{fmtStamp(row.computed_at) ?? '—'}
+
+
+ + ) +}