diff --git a/DEPLOY-runbook-2026-08-04.md b/DEPLOY-runbook-2026-08-04.md index fa4a9b9..53234ec 100644 --- a/DEPLOY-runbook-2026-08-04.md +++ b/DEPLOY-runbook-2026-08-04.md @@ -1,7 +1,10 @@ -# Deploy runbook — WP Suite, 2026-08-04 +# Deploy runbook — WP Suite **For:** IT / whoever administers the Docker host and Portainer **From:** n.siegfried@prime-controls.com +**Revised:** 2026-08-05 — **this replaces the 2026-08-04 version.** Same procedure, +but the deploy now carries a second database migration and a new admin screen. If +you already have the earlier copy, work from this one instead. **Expected duration:** 10–15 minutes, including the backup **Expected downtime:** under a minute, while containers are recreated @@ -23,15 +26,25 @@ Container names are fixed by the compose file and are the same on every host: ## What this deploy changes -Front-end and nginx changes, plus pending database migrations that run -automatically. Two things make it more than a routine restart: +Front-end and nginx changes, a new admin screen, plus pending database migrations +that run automatically. Three things make it more than a routine restart: 1. **The nginx config and the entire `html/` directory are baked into the container image at build time.** A plain restart deploys nothing — the stack must be re-pulled and re-built. 2. **A pending migration rewrites existing rows** in the `users.role` column - (values `user` → `project_user`). That is why step 1 is a backup and not - optional. + (`b41c7ae90d52`, values `user` → `project_user`). That is why step 1 is a backup + and not optional. If a previous deploy already applied it, it will not run again — + step 0 tells you which of these you are actually about to run. +3. **A second migration adds new columns** (`a7c31f9e5b02`: an archive timestamp on + projects, and two default-membership fields on users). This one is additive and + has database defaults for existing rows, so it does not rewrite anything. + +For the people using the app, the visible changes are: projects can now be +**archived** from the Admin Console (they disappear from the pickers and go +read-only, and can be brought back), certain users can be set to join **every new +project automatically**, and the Admin Console has been rebuilt so the user table +fits on screen. Migrations run themselves when the `wp_api` container starts. There is nothing to type and **no new environment variables** — do not change the stack's @@ -53,6 +66,17 @@ docker inspect wp_api --format 'api image: {{.Image}}' stack is currently on (Portainer → the stack → the Git reference / last-updated commit). Without these, rollback is guesswork. +The first command prints the migration the database is currently on. Use it to see +which migrations this deploy will actually run: + +| `alembic current` shows | What will run | What that means | +|---|---|---| +| `c93f2b1d7e04` or earlier | both migrations | The `users.role` rewrite is included — the backup in step 1 matters most in this case. | +| `d15b8c4ef207` | only `a7c31f9e5b02` | The `users.role` rewrite already happened on an earlier deploy. This one is additive only. | +| `a7c31f9e5b02` | nothing | The database is already up to date; this is a code-only deploy. | + +Take the backup either way. + --- ## Step 1 — Back up the database @@ -113,9 +137,17 @@ docker logs wp_api --tail 40 ``` You are looking for Alembic `Running upgrade …` lines followed by gunicorn -starting up, and **no** traceback. The API deliberately refuses to start if a -migration fails, so a restarting `wp_api` container means the migration failed — -go to Rollback. +starting up, and **no** traceback. The last one should end at `a7c31f9e5b02`. The +API deliberately refuses to start if a migration fails, so a restarting `wp_api` +container means the migration failed — go to Rollback. + +Confirm the database landed on the new revision: + +```bash +docker exec wp_api alembic -c server/alembic.ini current +``` + +Expected: `a7c31f9e5b02 (head)`. Then verify nginx's own view of its config: @@ -160,11 +192,22 @@ Open the site and press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app uses a service worker; a normal reload can serve the previous version and make a good deploy look broken. -Sanity check: log in, and confirm the home page offers to select or create a -project. +Sanity checks — all four should take under a minute: -**Deploy complete.** Please report back: the step 0 output, the backup filename, -and the two header lines from step 4. +1. Log in. The home page offers to select or create a project. +2. Open **Admin Console** (the link is on the home page; you need an admin account). + The user table should read as **one line per user** — if rows are three lines tall + and the table spills outside its white card, you are still on the old cached + files: hard-reload again. +3. Two new cards are present and load: **Projects**, and **Default members on new + projects**. Both should list rows, not an error. +4. In the **Projects** card, click **Archive** on a project you don't mind hiding + (a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived** — + it should reappear marked `archived`. Click **Unarchive** to put it back. That + round trip proves the new migration and the new endpoint are both live. + +**Deploy complete.** Please report back: the step 0 output (including which +migrations ran), the backup filename, and the two header lines from step 4. --- @@ -194,10 +237,18 @@ docker logs wp_api --tail 100 ``` Send me that output. **Do not restore the database and do not roll the API back -without contacting me first** — if the `users.role` migration already committed, -rolling the API back to the previous image without converting those values back -will break logins. That conversion is a one-line command, but it has to match -what actually ran. +without contacting me first.** Which migration got as far as committing decides what +is safe, and they are not the same: + +- **`a7c31f9e5b02`** (the new columns) is additive. If only this one ran, rolling + the API back to the previous image is safe on its own — the old code simply + ignores the extra columns. Nothing needs converting. +- **`b41c7ae90d52`** (the `users.role` rewrite) is not. If that one committed, + rolling the API back without converting those values back **will break logins**. + That conversion is a one-line command, but it has to match what actually ran. + +The `alembic current` output from step 0, plus the `Running upgrade …` lines in the +log above, are exactly what tells us which case you are in — please include both. Reach me at n.siegfried@prime-controls.com. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e8b7b85..489480b 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -209,11 +209,11 @@ users on the same project see the same server-stored SOP and Work Packages. | Table | Holds | Key columns | |-------|-------|-------------| -| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` | +| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `data` | | `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) | | `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) | | `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` | -| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, login-lockout + `token_version` fields | +| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields | | `project_members` | per-project access control | `user_id` → users, `project_id` → projects | | `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` | | `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) | @@ -224,18 +224,23 @@ column; frequently-listed fields are promoted to real columns for filtering. ### Endpoints (summary) -Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` · +Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`, +`POST /api/projects/{id}/archive` · SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` · Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`, `POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`, `GET /api/wps/metrics` · Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` · Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management -under `/api/auth/users` · Admin-only `GET/PUT /api/settings`, +under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) · +Admin-only `GET/PUT /api/settings`, `POST /api/settings/test-email`, `GET /api/notifications`, `GET /api/projects/{id}/members`. -List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference -and request shapes: `/api/docs` and [`server/README.md`](server/README.md). +List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects` +and `GET /api/wps` both take `archived=exclude|only|all` and **default to +`exclude`** — anything that needs to see archived rows (the admin console, the demo +cleanup) must ask for them. Full reference and request shapes: `/api/docs` and +[`server/README.md`](server/README.md). --- @@ -416,8 +421,9 @@ not get a second bar. - Switching project reloads the current page with `?project=`; every page already resolves its project from that parameter. - Search calls `GET /api/search?q=`, which is **scoped to the caller's projects** - (`scope_to_access`) and hides archived work packages. LIKE wildcards in the query - are escaped, so searching `100%` matches a literal `100%`. Two-character minimum. + (`scope_to_access`) and hides archived work packages, archived projects, and + anything belonging to an archived project. LIKE wildcards in the query are escaped, + so searching `100%` matches a literal `100%`. Two-character minimum. - Ctrl/Cmd-K focuses the field from anywhere. ## Schema migrations (Alembic) @@ -470,6 +476,53 @@ the project's members plus app admins, each with their effective role on that project. A project with nobody assigned shows only the admins, which is why assigning people is the first step on a new job. +### Default members on new projects + +Memberships are also created automatically. **Admin console → Default members on +new projects** flags accounts (`users.auto_add_projects`) that belong on every job — +the PM who runs them all, the QC lead — with the role they should hold there +(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` = +inherit the account's own). + +- It applies **only to projects created after the flag is set**. Nothing is + back-filled onto existing jobs; use **Project access** for those. +- App admins are skipped (they already reach every project) and the flag is cleared + if an account is promoted to admin. Inactive accounts are skipped. +- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it + covers every route into project creation — the home page, the sample project, the + demo seeder. An update never re-runs it. +- If the creator is themselves a flagged member, the membership created for them as + creator carries their `auto_add_role`, so they aren't silently downgraded on the + one job they started. +- Audit-logged once per project as `project_access_granted` with + `detail.reason = "auto_add_projects"`. + +## Archiving a project + +A finished job is archived rather than deleted: `projects.archived_at`, set from +**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs +Project Admin **on that project**, same bar as deleting it). + +An archived project is **hidden and frozen**: + +- It leaves the home picker, the app-bar switcher and global search, because + `GET /api/projects` defaults to `archived=exclude`. +- It is still readable by id, so a deep link renders it — with a read-only banner + from `wp-chrome.js` — and the admin console still lists it under + `?archived=all`. +- Every write that lands on it is refused with **409** by + `require_project_writable()`: saving a project, SOP or work package, deleting + either, issuing, status changes, WP archiving, and comments on its WPs/SOPs. + Moving a work package *into* or *out of* an archived project is refused too. + 409 rather than 403 is deliberate — nobody lacks a permission, the project's state + is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops + instead of retrying them forever. +- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived + project must accept, and archive-then-delete is a normal sequence. + +Nothing is removed, and unarchiving restores all of it. `server/smoketest.py` +asserts the whole round trip. + ## Asset freshness (why the app can't run half-updated) A page must never run against a stylesheet or script from a previous deploy. Three diff --git a/html/admin.html b/html/admin.html index 4fbdcbc..43a6214 100644 --- a/html/admin.html +++ b/html/admin.html @@ -14,58 +14,198 @@ @@ -82,34 +222,34 @@