CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.
CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.
DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.
Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.
One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.
No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.
server/models.py LocationNode
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
server/app.py GET/POST/PATCH + import, parser, slug
html/work-package-suite.html step 11, an 11th rail button
html/work-package-suite-app.js the step's logic; LAST_STEP replaces 10
html/work-package-suite-styles.css the list, the report
html/theme-light.css .field-error, now declared once
tests/locations_check.py new - 58 checks
tests/stepper_check.py STEP_COUNT 10 -> 11
Done when
[x] CSV upload and paste both work and report rejected rows with reasons
[x] duplicates are detected and reported rather than silently merged
[x] values are editable after import - rename, add, deactivate
[x] deactivating hides it from new work packages; an existing package
referencing it still resolves, because the row is retained
[x] values are stored as codes suitable for grouping
[x] no guessed real-world floor names exist anywhere in the code
Two decisions worth disagreeing with
Step 11, appended, not step 2, inserted. Locations belong beside Project by
subject. Renumbering 2-10 would touch every sop-step-N id, every
collectStepData case, every gate key and the analytics history - a large
silent-mismatch surface for an ordering change. The count now lives in one
place (LAST_STEP), so reordering later is cheap.
Any project member may edit the list, not only a Project Admin. It matches how
the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
completed SOP, not on writing one. If the location list should be tighter than
the SOP it belongs to, that is a product call.
Verified one at a time
locations_check 58/58 new
stepper_check 70/70 (11 steps)
browser_check 71/71
a11y 22/22 sop now rings 38 focusable elements
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
alembic upgrade / downgrade / upgrade all clean on a throwaway SQLite
file, and the migrated schema matches Base.metadata.create_all
column for column - dev auto-creates and production migrates,
so a divergence between the two is invisible until it ships
.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.
Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 isserver.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