Files
Project-SDE-WP-Suite/server
n.siegfried b670ae719d T4.1 - B4: the counts come from the server, and disagreement is said out loud
The defect B4 names is not that the numbers were wrong. On one browser with one
cache they were right. It is that they were derived from the caller's own
localStorage, so two people on the same project saw different numbers and neither
was told.

SERVER. /api/wps/metrics now returns everything the creator's dashboard shows -
total, mine, release-ready, on hold, overdue, est/actual hours, by_status,
by_discipline, progress (overall and per discipline) and the gating list. It
already existed for a subset; the rest was being summed in the browser.

Two things moved to the server rather than being duplicated there:

  - PROGRESS_WEIGHT, the status-to-percentage table. It was PROGRESS_W in
    wp-creation-app.js; the JS copy is deleted rather than left in place, because
    two copies of a weighting table is how the two drift apart.
  - "release-ready" now counts `waitingOn` predecessors as blocking, which the
    browser did and the old endpoint did not. Without that the phrase would have
    changed meaning the moment the dashboard stopped computing it locally.

New GET /api/projects/{id}/summary gives the launcher the SOP state it was
reading out of localStorage.

by_location is shaped for CR-018 in wave 6, per the task's instruction not to
build a shape that cannot group by building/floor/sector. It reports its own
dimensions alongside the groups:

  {"dimensions": ["location"], "groups": [{"key": {...}, "total": n,
    "release_ready": n, "on_hold": n, "overdue": n, "by_status": {...}}]}

Today a package carries one free-text `location`, so that is the one dimension.
_location_key() already prefers structured building/floor/sector when present, so
CR-004 changes the dimensions and the keys and leaves the response shape alone.

CLIENT. The launcher's SOP card and the dashboard's tiles, chips, progress bars
and gating panel all read the server. There is deliberately no cache fallback: a
silently-stale number that looks authoritative is the thing being removed, so a
failed request renders an explicit error and a retry.

Writes flush through the outbox before the counts are re-read (dashRefreshAfterWrite).
Without that the refresh races the push and shows pre-write totals - the same
stale number arriving by a different route.

THE ONE COUNT STILL COMPUTED LOCALLY, stated rather than skipped: the board table
is a LIST of the packages this browser holds, which is what keeps the field view
working offline, and its header counts rows. Rather than pretend otherwise, it is
reconciled against the server's total and shows "this browser has N of M" when
they differ - usually a save that has not finished syncing. Nobody now sees a
number that disagrees with the project without being told, which is what B4 asks
for.

VERIFICATION. tests/aggregates_check.py, 16 checks, all passing. It tests what was
broken rather than whether the totals are right - the latter passed before this
change:

  - root and pat get byte-identical aggregates for the same project
  - the dashboard shows the server's total after localStorage is POISONED with a
    different package list; it cannot do that if it is summing the cache
  - a simulated outage renders "Counts unavailable", names the failure, offers a
    retry, renders no tiles beside it, and announces via role=alert
  - the launcher reports the SOP complete against a cache that says otherwise,
    and says "Could not check" when the request fails
  - by_location's groups are keyed by its declared dimensions, carry their own
    rollups, and sum to the project total

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:24:12 -05:00
..

Work Package Suite API

A small Python (FastAPI) service that stores project SOPs, Work Packages, and comments in PostgreSQL. NGINX serves the static site and proxies /api/ to this service.

browser → NGINX ──serves──> static site (index.html, …)
                └─proxy /api/─> api container (:8000) → db container (postgres)

Endpoints

Method Path Purpose
GET /api/health liveness check (unauthenticated)
POST /api/auth/login sign in ({username, password}) — sets the session cookie
POST /api/auth/logout clear the session cookie
GET /api/auth/me the logged-in user
POST /api/auth/password change your own password
GET /api/auth/users list accounts (admin)
POST /api/auth/users create an account (admin)
DELETE /api/auth/users/{id} delete an account (admin)
POST /api/sops create/update a SOP (upsert by id)
GET /api/sops list SOP summaries
GET /api/sops/latest?complete=true most recent (complete) SOP
GET /api/sops/{id} full SOP document
DELETE /api/sops/{id} delete a SOP
POST /api/wps create/update a Work Package (upsert by id)
GET /api/wps?sop_id=… list WPs (optionally for one SOP)
GET /api/wps/{id} full WP document
DELETE /api/wps/{id} delete a WP
POST /api/comments (and /api/feedback) add a comment
GET /api/comments?source=&sop_id=&wp_id=&step= list comments

Interactive docs once running: /api/docs.

The full client document is stored in each row's data (JSON) column; common fields (name, number, status, …) are promoted to columns for listing/filtering.


Login portal (user accounts)

The suite is gated by a username/password login. Sign-in issues a signed JWT that rides in an HttpOnly, SameSite=Lax cookie (wp_session); the cookie is marked Secure automatically whenever the request arrives over HTTPS (via NGINX's X-Forwarded-Proto). There is no server-side session store — each request is validated by checking the cookie's signature and expiry.

The real security boundary is the API: every /api/ data route is refused with 401 unless a valid session cookie is present (see auth_gate in app.py). The static pages additionally include auth-guard.js, which redirects to login.html when there's no session — that's for UX, not protection.

Passwords are stored only as bcrypt hashes (server/auth.py). Roles are admin (may manage users) and user.

Set the signing secret

Add AUTH_SECRET_KEY to .env (see .env.example). Required in production — without it the API uses a random per-process key, so logins reset on restart.

python -c "import secrets; print(secrets.token_urlsafe(48))"

Create the first admin

The /api/auth/users endpoint needs an existing admin, so bootstrap one from a shell (run from the project root, like uvicorn):

python -m server.manage_users create-admin alice --name "Alice Smith"
# prompts for a password (min 8 chars)

In Docker:

docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"

Other commands: create <user> --role user, list, reset-password <user>, disable <user>, enable <user>. After that, admins can add users through the API (or you can keep using the CLI).


Local dev

cd server
python -m venv .venv && . .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# No DATABASE_URL → uses a local sqlite file, so you can start immediately:
uvicorn server.app:app --reload --port 8000       # run from the PROJECT ROOT

Then open http://localhost:8000/api/docs.

Run uvicorn/gunicorn from the project root (the folder that contains the server/ directory), because the import path is server.app:app.


Production — Docker Compose

This is the recommended production setup. Three containers run in an isolated internal network; only NGINX is exposed to the outside via the external proxy network.

[external proxy network]
        │
   ┌────▼────┐   internal network   ┌──────────┐   ┌────────┐
   │  nginx  │ ───────────────────> │   api    │ → │   db   │
   └─────────┘                      └──────────┘   └────────┘

1. Create the credentials file

Create .env in the project root (same directory as docker-compose.yml). This file is never committed — add it to .gitignore.

# .env  — project root
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>

# Must match POSTGRES_* above; hostname is the compose service name "db"
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite

Generate a strong password:

openssl rand -base64 32

2. Add the Dockerfile

Create Dockerfile in the project root:

FROM python:3.12-slim
WORKDIR /app
COPY server/requirements.txt ./server/
RUN pip install --no-cache-dir -r server/requirements.txt
COPY server/ ./server/
EXPOSE 8000
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
     "-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]

3. Update the NGINX site config

The API is no longer at 127.0.0.1:8000 — it is the api container. Update the /api/ proxy block in your nginx conf (e.g. nginx/conf.d/wp-suite.conf):

location /api/ {
    proxy_pass http://api:8000;          # ← service name, not localhost
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Proto $scheme;
    client_max_body_size 5m;
}

4. docker-compose.yml

Replace your existing docker-compose.yml with:

services:

  webserver:
    image: nginx:alpine
    container_name: nginx_webserver
    volumes:
      - ./html:/usr/share/nginx/html:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./logs:/var/log/nginx
    restart: unless-stopped
    depends_on:
      api:
        condition: service_started
    networks:
      - proxy       # external — reachable by your reverse proxy / traefik
      - internal    # needs a path to the api container

  api:
    build: .
    container_name: wp_api
    env_file: .env                        # loads DATABASE_URL
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy        # waits for postgres to accept connections
    networks:
      - internal

  db:
    image: postgres:16-alpine
    container_name: wp_db
    env_file: .env                        # loads POSTGRES_DB / USER / PASSWORD
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

volumes:
  pgdata:

networks:
  proxy:
    name: proxy
    external: true
  internal:
    internal: true    # no outbound internet access from api/db

5. First-time startup

# Build the api image and start all containers
docker compose up -d --build

# Confirm all three containers are running
docker compose ps

# Tail logs (Ctrl-C to stop following)
docker compose logs -f api

Tables are created automatically on first API startup — no manual CREATE TABLE needed.

Authentication notes

Postgres → API authentication is handled entirely through DATABASE_URL in .env. The db container uses POSTGRES_USER / POSTGRES_PASSWORD to initialise the database on first run; the api container uses the matching credentials in DATABASE_URL to connect. Neither credential ever appears in the compose file itself.

Network isolation: the db container is on the internal network only — it has no port exposed to the host and is unreachable from outside the compose stack. Only the api container can open a connection to it.

Changing the password: update both POSTGRES_PASSWORD and the password in DATABASE_URL in .env, then:

# Stop api first (db must keep running to accept the ALTER USER command)
docker compose stop api
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
docker compose start api

Day-to-day operations

# Rebuild api after a code change
docker compose up -d --build api

# View postgres data directly
docker compose exec db psql -U wpsuite -d wpsuite

# Take a database backup
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql

# Restore from backup
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql

# Stop everything (data volume is preserved)
docker compose down

# Stop everything AND delete all data
docker compose down -v

Quick test

/api/health is open; data routes now require a session, so log in first and reuse the cookie jar:

curl http://127.0.0.1:8000/api/health          # {"ok":true} — no auth needed

# Sign in, saving the session cookie to a jar
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"<password>"}'

# Reuse the cookie on protected routes
curl -b jar.txt http://127.0.0.1:8000/api/comments

Without the cookie, protected routes return 401 {"detail":"Not authenticated"}.

Or via the nginx proxy (replace with your hostname):

curl https://wp-suite.company.local/api/health