From 928ab8c90006c77413bc52cde2c3f7e48df66a86 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Wed, 5 Aug 2026 13:48:43 -0700 Subject: [PATCH] Archive projects, auto-add default members, rebuild the admin console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things asked for together, plus the migration they share (a7c31f9e5b02 — additive, with database defaults for existing rows, so unlike the users.role rewrite it is safe under a code-only rollback). ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and freezes read-only, without losing anything. Hiding is free: GET /api/projects defaults to archived=exclude, so the home picker and the app-bar switcher drop it without either of them changing. Freezing is require_project_writable(), which every write that lands on a project now goes through — SOP and WP upserts (both ends, so a package can be moved neither into nor out of an archived job), deletes, issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403: nobody lacks a permission, the project's state is the objection, and the browser outbox in project-data.js retires 4xx ops instead of retrying them against a job that will never accept them. Unarchive and delete stay allowed on purpose — unarchive is the one write an archived project must take, and archive-then-delete is a normal sequence. DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the people who belong on every job, so an admin says it once instead of remembering it at each project creation. It runs on the is_new branch of upsert_project, which is the single road into project creation, so the home page, the sample project and the demo seeder are all covered and an update never re-runs it. Note the interaction with the existing creator-grant: that row commits first and add_default_members never overwrites an existing membership, so the creator grant now carries the creator's own auto_add_role — otherwise someone flagged "Project Admin on every job" would land as a plain member on the one job they started themselves. ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in an 860px card meant every cell wrapped, so one user occupied a ~100px band, the action buttons stacked, and the table spilled outside its own white card. Now 1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls sideways, and one spacing/control scale across all twelve cards. Truncation hangs off a span inside the cell rather than max-width on the td, which table-layout:auto treats as advisory — the usual reason cell ellipsis works in the stylesheet and not on the page. Found in review and fixed here rather than later: - Stored XSS in the new Projects card, reachable by any signed-in user, landing in an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used in eight places escapes in the wrong order — uesc leaves backslashes alone, so a stored name containing \' closes the JS string literal and the rest executes. jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go through it. The same bug, unescaped entirely, was in the SOP builder's custom constraint names (escHandlerArg there). Three of seven test payloads escaped the literal under the old idiom — one of them a plain name ending in a backslash, so it was breaking buttons for innocent input too. - _save_comment resolved wp_id and sop_id with if/elif but stored both, so a payload naming a WP you may touch and a SOP you may not was authorised on the WP alone and still wrote into the other project's thread. Both are checked now. - Promoting an account to admin left its default-member flag set but invisible, ready to take effect again on demotion — cleared, as set_user_auto_add already does for the role. smoketest.py and the console's own smoke test both assert the archive round trip: out of the default list, present with archived=all, writes refused with 409, and all of it undone by unarchiving. Co-Authored-By: Claude Opus 5 (1M context) --- DEPLOY-runbook-2026-08-04.md | 83 ++++- DEPLOYMENT.md | 69 +++- html/admin.html | 305 ++++++++++++++---- html/admin.js | 292 ++++++++++++++++- html/index.html | 28 +- html/project-data.js | 41 ++- html/work-package-suite-app.js | 9 +- html/wp-chrome.css | 26 ++ html/wp-chrome.js | 53 +++ ...02_project_archive_and_auto_add_members.py | 48 +++ server/app.py | 223 ++++++++++++- server/models.py | 18 +- server/seed_demo.py | 7 +- server/smoketest.py | 26 +- 14 files changed, 1092 insertions(+), 136 deletions(-) create mode 100644 server/alembic/versions/a7c31f9e5b02_project_archive_and_auto_add_members.py 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 @@