Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
184 commits
Select commit Hold shift + click to select a range
2ce0b0d
Add teams tables, models and migration
vincentmakes Oct 21, 2026
2b7fe6e
Add team rules and team-aware tag validation
vincentmakes Oct 21, 2026
73a029e
Add the teams API
vincentmakes Oct 21, 2026
f59892c
Expose and validate project teams
vincentmakes Oct 21, 2026
3496072
Carry team membership on the user and staff APIs
vincentmakes Oct 21, 2026
b1d7c5d
Scope tags, equipment and company events to teams
vincentmakes Oct 21, 2026
a8581b5
Export teams in the site workbook
vincentmakes Oct 21, 2026
c7ef07f
Seed demo teams
vincentmakes Oct 21, 2026
4eac36a
Add team types and API
vincentmakes Oct 21, 2026
1afea3b
Add team filter helpers and team-aware closures
vincentmakes Oct 21, 2026
fc54188
Load teams into the stores and WebSocket slices
vincentmakes Oct 21, 2026
135e2cb
Add the header team filter
vincentmakes Oct 21, 2026
21afdb3
Add the Teams settings panel
vincentmakes Oct 21, 2026
ea1ec87
Apply the team filter to views and pickers
vincentmakes Oct 21, 2026
10bb651
Make site closures team-aware
vincentmakes Oct 21, 2026
e07066e
Add team pickers and project teams
vincentmakes Oct 21, 2026
4768a86
Scope tags, equipment and events to teams in Settings
vincentmakes Oct 21, 2026
96a6c52
Fix team-limited closures in the calendar header and polish team scoping
vincentmakes Oct 21, 2026
90e84a1
Polish teams backend
vincentmakes Oct 21, 2026
2bd5406
Document teams and bump to 1.12.0
vincentmakes Oct 21, 2026
6e3ffe4
Clear the team filter when a site has no active teams
vincentmakes Oct 21, 2026
c37669c
Refuse to delete a team that would strand tagged projects
vincentmakes Oct 21, 2026
bd965f6
Add the Teams screenshots to the settings guide
vincentmakes Oct 21, 2026
f16bb2e
Add team-filter predicates for events and tags
vincentmakes Oct 21, 2026
04c604a
Apply the team filter to company events, tag options and card assignees
vincentmakes Oct 21, 2026
5af555d
Resolve project tags against every site tag in the tag filter
vincentmakes Oct 21, 2026
2500a6d
Show an event's teams and document the team filter
vincentmakes Oct 21, 2026
d0768c7
Name only this site's teams in the Staff events row
vincentmakes Oct 21, 2026
1028740
Align the staff guide and changelog with the team filter
vincentmakes Oct 21, 2026
c907325
Add the permission catalogue and RBAC schema
vincentmakes Oct 21, 2026
2823f96
Resolve permissions per request and add the roles API
vincentmakes Oct 21, 2026
14902d4
Gate every tenant route on permission keys
vincentmakes Oct 21, 2026
883deef
Let users.create alone create default-role accounts
vincentmakes Oct 21, 2026
7c996dd
Scope reads and writes to the user's visible sites
vincentmakes Oct 21, 2026
e6b9649
Scope column values, user skills and shared bookings to visible sites
vincentmakes Oct 21, 2026
d2df059
Add the approval workflow for own bookings
vincentmakes Oct 21, 2026
569843c
Add todos and seed the demo roles and approvals
vincentmakes Oct 21, 2026
e5fb6c9
Keep an admin's role settings on a demo re-seed
vincentmakes Oct 21, 2026
2574bb3
Gate the frontend on permission keys and visible sites
vincentmakes Oct 21, 2026
9981f73
Gate equipment bookings on equipment_bookings.manage
vincentmakes Oct 21, 2026
ee14c85
Add Settings > Roles with the permission matrix and approvals
vincentmakes Oct 21, 2026
096bdc1
Show pending bookings and handle sent-for-approval writes
vincentmakes Oct 21, 2026
8c76d48
Add the My Todos view with live todo and approval lists
vincentmakes Oct 21, 2026
cb0e728
Show decided requests and load errors in My Todos
vincentmakes Oct 21, 2026
621f310
Document roles, approvals and My Todos and bump to 1.13.0
vincentmakes Oct 21, 2026
45169bd
Copy the shared permission keys into the Docker frontend build
vincentmakes Oct 21, 2026
86c6a38
Add the roles, approvals, My Todos and pending-booking screenshots
vincentmakes Oct 22, 2026
85e24cd
Loosen the frontend floors and fill release-doc gaps
vincentmakes Oct 22, 2026
7731ec2
Guard the fixture defaults and polish the approval wording
vincentmakes Oct 22, 2026
c6a50aa
Add a shared two-month date range picker
vincentmakes Oct 22, 2026
f08e169
Re-measure the date panel on its status row and refocus on keyboard s…
vincentmakes Oct 22, 2026
cd55f21
Pick every date in the forms on the shared calendar
vincentmakes Oct 22, 2026
1992958
Keep the header site's holidays out of the Settings calendars
vincentmakes Oct 22, 2026
b366f0f
Document the date picker and bump to 1.14.0
vincentmakes Oct 22, 2026
d20b3e6
Add the date picker screenshot
vincentmakes Oct 22, 2026
1394046
Fix the assignment date sentence and name the phase dialog
vincentmakes Oct 22, 2026
978fbf3
Mark the active date field and fix the picker's touch and keyboard gaps
vincentmakes Oct 22, 2026
8a8064a
Validate instance defaults and add the Kanban and Nager switches
vincentmakes Oct 22, 2026
a9517ba
Add the assignment_changed and project_changed notification types
vincentmakes Oct 22, 2026
47acf1f
Notify the booked person and the PM when someone else changes their plan
vincentmakes Oct 22, 2026
39b1e46
Add the Features & defaults settings section and apply the defaults
vincentmakes Oct 22, 2026
41d6841
Follow the board switch in the role guard and re-seed types per site
vincentmakes Oct 22, 2026
6254dde
Show booking and project change notifications in the bell
vincentmakes Oct 22, 2026
7c9f423
Mirror the disableable permission keys in the shared catalogue
vincentmakes Oct 22, 2026
8ea6007
Release 1.15.0: document the admin defaults and change notifications
vincentmakes Oct 22, 2026
6bdb3e1
Add the Features & defaults screenshot
vincentmakes Oct 22, 2026
a27babd
Deliver change notifications on their own session
vincentmakes Oct 22, 2026
85502dd
Clarify deleted-project and merge-window notification docs
vincentmakes Oct 22, 2026
d40a374
Add the "Bookable as a resource" flag to users
vincentmakes Oct 22, 2026
0d67546
Refuse new bookings of people who are not bookable
vincentmakes Oct 22, 2026
dd5c181
Expire only create requests of people no longer bookable
vincentmakes Oct 22, 2026
bcdfc7f
Add the "Bookable as a resource" switch and hide self-booking for others
vincentmakes Oct 22, 2026
2295d79
Release 1.16.0: bookable users
Oct 22, 2026
395523b
Read an unset bookable flag as bookable on /auth/me
vincentmakes Oct 22, 2026
ec0a161
Give phase and subphase bookings optional own dates
vincentmakes Oct 22, 2026
5e4ace4
Carry segment dates through approvals and card moves
vincentmakes Oct 22, 2026
7b609c9
Let phase and subphase bookings keep their own dates on the Gantt
vincentmakes Oct 22, 2026
d6dca1c
Fit card segments locally only after a card move or resize
vincentmakes Oct 22, 2026
cdbabca
Group a person's bookings on a card into one overview row
Oct 22, 2026
200b1a8
Release 1.17.0: unlinked bookings and the overview row
vincentmakes Oct 22, 2026
e146078
Capture the assignment overview row screenshot
vincentmakes Oct 22, 2026
cfca19a
Print overview-row allocations like the booking rows
Oct 22, 2026
da3ad13
Show the card badge on the overview row
Oct 22, 2026
3577bd9
Indent grouped bookings under their overview row
Oct 22, 2026
935c9c8
Add the Material icon set behind one Icon wrapper
Oct 22, 2026
6548e94
Draw the Settings hub and admin portal icons with Material icons
Oct 22, 2026
437c7b3
Draw the header, sidebar and shared component icons with Material icons
Oct 22, 2026
639c915
Draw the Gantt icons with Material icons
Oct 22, 2026
f4cb5d8
Draw the view and dialog icons with Material icons
Oct 22, 2026
a0f3b4a
Guard against inline svg icons and document the icon set
Oct 22, 2026
c772f2f
Inline the colour-by and group-by trigger icons
Oct 22, 2026
de5f9f9
Give the lazy dialogs time to load in the modal container test
Oct 22, 2026
cccfdbe
Split the card badge into a pin and a name that scrolls
Oct 22, 2026
6527747
Give the flash notice an opaque surface
Oct 22, 2026
2ffbe5d
Draw the Equipment view with a wrench instead of the Settings cog
Oct 22, 2026
f0faac9
Recapture the overview row with the new card badges
Oct 22, 2026
37e7880
Document the booking-row controls and the Material icons for 1.17.0
vincentmakes Oct 22, 2026
53a8d7f
Refresh the documentation screenshots with the Material icons
vincentmakes Oct 22, 2026
55e141d
List a person's bookings in date order and lock an overlapping relink
vincentmakes Oct 22, 2026
18e6203
Recapture the pending, My Todos and overview-row screenshots
vincentmakes Oct 22, 2026
853a136
Convert tag groups into project fields (schema and migration)
vincentmakes Oct 23, 2026
6f69a97
Add the project fields API and the project_fields.manage key
vincentmakes Oct 23, 2026
220cee2
Seed the demo's former tag groups as project fields
Oct 23, 2026
69c993d
Add the built-in project field label and hidden-field settings
Oct 23, 2026
e571913
Store project field values on projects
Oct 23, 2026
233d285
Export project fields, items and values with the site
Oct 23, 2026
663b455
Test the 500-field cap on project field_values
Oct 23, 2026
c519d7a
Drop unknown project field ids silently
Oct 23, 2026
59051ef
Edit project fields in the project dialog and drop tag groups from th…
Oct 23, 2026
b467d2d
Add Settings → Project fields
Oct 23, 2026
b60abde
Test that a store reset clears the project fields
Oct 23, 2026
fb1ad24
Test the project dialog's local tag patch shape
Oct 23, 2026
38a81e6
Filter, group and colour the Gantt by project fields
Oct 23, 2026
3097c32
Reset the saved Gantt choices once on the 2.0.0 view store
Oct 23, 2026
b827bb2
Release 2.0.0: project fields replace tag groups
vincentmakes Oct 23, 2026
34f606a
Capture the project fields screenshots
vincentmakes Oct 23, 2026
3d4a823
Open the project-field settings to project_fields.manage
Oct 23, 2026
bd50a59
Recapture the project filter screenshot
Oct 23, 2026
e56cbae
Add place_booking: where a dateless second booking goes on a card
Oct 23, 2026
7758fa9
Plan, shrink and re-plan auto-placed bookings in the approval service
Oct 23, 2026
b0f08bd
Place a dateless second booking on the phase, subphase and Kanban cre…
Oct 23, 2026
f5b1bd9
Offer a second booking on a card: "Assign me again" and the Kanban pi…
Oct 23, 2026
af77d60
Name each built-in project field with one default everywhere
Oct 23, 2026
1a55e04
Show multi-select project fields as a dropdown
Oct 23, 2026
88be9ca
Add custom project fields from a compact row after the list
Oct 23, 2026
3c38644
Raise the frontend branch coverage floor to 82.9
Oct 23, 2026
cb2aba4
Cover the dropdown's focus-keeping mousedown handlers
Oct 23, 2026
11c7ebb
Portal the Gantt header dropdowns so an open overview cannot clip them
Oct 23, 2026
10dd2bd
Raise the frontend line, statement and function coverage floors
Oct 23, 2026
f9ca9a7
Document the placed second booking, the field labels and the portaled…
Oct 23, 2026
2afeaa9
Recapture the project dialog, the field settings and the header dropd…
Oct 23, 2026
9e9f5f1
Remove personal todos from the backend
Oct 23, 2026
f5e2476
Let card_comments hold project threads
Oct 23, 2026
5a679ca
Serve project comment threads and open the thread reads
Oct 23, 2026
9c16beb
Say when the edited RBAC migrations first ship
Oct 23, 2026
0322179
Lock the card before placing a dateless booking
Oct 23, 2026
188cb4c
Stack anchored panels above the header; no second booking on one-day …
Oct 23, 2026
79e8c60
Replace My Todos with My Work
Oct 23, 2026
2587587
Comment on bookings from their dialog; one lock rule for the dialog
Oct 23, 2026
87add9b
Seed the overview defaults into the Gantt panels only; header gap
Oct 23, 2026
4fdbb01
Document My Work, comments on bookings and the panel-only defaults
Oct 23, 2026
061e5be
Recapture the screenshots on the reset demo: My Work and My Assignments
Oct 23, 2026
efb6a69
Gate archived threads on views.archived; stamp new comments once
Oct 23, 2026
9681e78
Final fix wave: removal lock, parent cards, mention cache, small nits
Oct 23, 2026
4e3cce8
Recapture the screenshots with the new My Work icon; changelog fix line
Oct 23, 2026
411daf1
Add SearchableSelect: a Select-looking picker with a focused search box
Oct 23, 2026
13a5aa1
MultiSelectDropdown: optional search box, Clear switch, trigger summary
Oct 23, 2026
091f8eb
Every people picker gets a search box
Oct 23, 2026
c0e7678
Listbox option rows are 44 px touch targets on phones
Oct 23, 2026
ddf038c
Raise the frontend coverage floors with the searchable pickers
Oct 23, 2026
5fa0c50
Document the searchable people pickers
Oct 23, 2026
053b6d6
Recapture the dialogs whose people picker changed
Oct 23, 2026
6c4c2bd
Polish the searchable pickers after review and the live check
Oct 23, 2026
baca73b
Let an explicit null clear the project manager
Oct 23, 2026
17ca859
Keep the search box whole when a picker panel is taller than the dial…
Oct 23, 2026
49e30bf
Give the assign dialogs room for the open resource picker
Oct 23, 2026
772f3bf
Update vite and vitest to close the open dependency advisories
Oct 23, 2026
f922156
Log only whether an SSO client secret is set, not a digest of it
vincentmakes Oct 23, 2026
46da2b8
Keep every SSO redirect on a same-origin path
vincentmakes Oct 23, 2026
a97a57e
Give the CI workflows read-only repository permissions by default
vincentmakes Oct 23, 2026
7af920f
Shape the SSO hardening the way code scanning recognises it
vincentmakes Oct 23, 2026
1b61341
Move the CLAUDE.md reference into seven Codebase Reference pages
Oct 25, 2026
cf44dae
Cut CLAUDE.md to a core of rules and pointers
Oct 25, 2026
ddbdc3d
Add the AI workflow page and point the READMEs at the reference pages
Oct 25, 2026
edd5636
Keep the last agent rules and the screenshot rules in the CLAUDE.md core
Oct 25, 2026
261880c
Add a tenant SSO test route on a shared Entra diagnostic
vincentmakes Oct 25, 2026
f23f1a6
Let a confirmation require a typed word
vincentmakes Oct 25, 2026
2b5a73d
Test and confirm changes to live SSO settings in Settings
vincentmakes Oct 25, 2026
30454c4
Document the SSO test and the typed confirmation
vincentmakes Oct 25, 2026
7a31050
Clear the SSO test result on toggle and hide the test note under orga…
vincentmakes Oct 25, 2026
592f1ab
Keep the SSO test log line out of CodeQL's secret heuristic
vincentmakes Oct 25, 2026
7220e01
Merge the CLAUDE.md split (#85) into feat/teams
vincentmakes Oct 26, 2026
047a348
Merge main (#85 squashed) into feat/teams
vincentmakes Oct 26, 2026
4dd8f79
Show Teams after the project fields, right before Tags
vincentmakes Oct 26, 2026
6b68afd
Move the Confirmed toggle right before Teams in the project dialog
vincentmakes Oct 26, 2026
5d22cb1
Extract the comment-posting helpers into a card_comments service
vincentmakes Oct 26, 2026
8f35206
Let staff bookings and booking requests carry an optional comment
vincentmakes Oct 26, 2026
268de86
Add an optional note to equipment bookings
vincentmakes Oct 26, 2026
aeee213
Add the booking comment and equipment note to the assignment dialog
vincentmakes Oct 26, 2026
d385425
Show request messages with mention pills in My Work
vincentmakes Oct 26, 2026
c558707
Document booking comments, request messages and equipment notes
vincentmakes Oct 26, 2026
9a5050d
Word the booking comment field for who it notifies, with a real label
vincentmakes Oct 26, 2026
343cfad
Open the assignment dialog from "Assign me…" and "Request to assign me…"
vincentmakes Oct 26, 2026
46d98ce
Keep the preset person in the assignment picker, wherever they work
vincentmakes Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
# that doesn't depend on someone opening a PR.
- cron: "17 6 * * 2"

# Least privilege by default: every job reads the repository only, and a job
# that needs more (the coverage badge push) asks for it itself.
permissions:
contents: read

# A new push to a pull request supersedes the run still going for the previous
# one. Pushes to main are never cancelled: each one publishes the coverage
# badge, and a cancelled run would leave it behind.
Expand Down Expand Up @@ -71,6 +76,7 @@ jobs:
- 'requirements*.txt'
- 'pyproject.toml'
- 'migrations/**'
- 'shared/**'
- 'VERSION'
ci:
- '.github/workflows/backend.yml'
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
# Every pull request, whatever its base (stacked PRs included).
pull_request:

# Least privilege: the jobs only read the repository.
permissions:
contents: read

# A new push to a pull request supersedes the run still going for the previous
# one. Pushes to main are never cancelled, so every commit on main is known to
# build.
Expand Down Expand Up @@ -63,6 +67,7 @@ jobs:
- 'requirements.txt'
- 'app/**'
- 'frontend/**'
- 'shared/**'
- 'migrations/**'
- 'VERSION'
- 'public/**'
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ on:
# the audit needs a trigger that doesn't depend on someone opening a PR.
- cron: "17 6 * * 1"

# Least privilege by default: every job reads the repository only, and a job
# that needs more (the coverage badge push) asks for it itself.
permissions:
contents: read

# A new push to a pull request supersedes the run still going for the previous
# one. Pushes to main are never cancelled: each one publishes the coverage
# badge, and a cancelled run would leave it behind.
Expand Down Expand Up @@ -67,6 +72,7 @@ jobs:
filters: |
frontend:
- 'frontend/**'
- 'shared/**'
ci:
- '.github/workflows/frontend.yml'

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
pull_request:
branches: [main]

# Least privilege: the jobs only read the repository.
permissions:
contents: read

jobs:
version-check:
runs-on: ubuntu-latest
Expand Down
32 changes: 32 additions & 0 deletions .trail/learnings/2026-10-21-rbac-approval-workflow-claude.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: "RBAC approvals: eleven self-booking routes through one service, 202 contract, live approvers, expiry hooks"
author: Claude
date: 2026-10-21
branch: feat/rbac
cost: { minutes: 0, tokens: 0 }
touches:
- app/services/approvals.py
- app/routers/approvals.py
- app/routers/assignments.py
- app/routers/kanban.py
- app/routers/projects.py
- app/routers/export.py
- app/services/notifications.py
- app/websocket/broadcast.py
- tests/test_approvals_service.py
- tests/test_approvals_routes.py
- tests/test_project_access_routes.py
---

## Decided
- One path: creates call `resolve_self_booking`, row writes `approvals.resolve_row_write` (pending create → its requester amends/cancels it through the request; anyone else 403 without rights, else 409); `"approval"` → `approvals.submit_request`, which commits, publishes and returns the 202 JSONResponse itself. Direct writes call `expire_overtaken` before the write and `publish` after the commit; phase/subphase/project deletes call `cancel_for_entity` right after collecting the subtree (before any ORM delete: autoflush would cascade the requests away).
- The requester's own direct write (allow mode, or an editor on their own row) closes their open request `cancelled`, not `expired(overtaken)`; an update back to the current values withdraws the open request (200, no new request); DELETE of one's own pending create = cancel (200 `{cancelled: true}`).
- Approvers are never stored: `approver_user_ids` is one SQL query (admins ∪ PM if `pm_approves` ∪ policy roles, the last two only when they see the site via `user_sites` or a `sites.view_all` role; requester excluded). `can_decide` is the pure per-row check; the caller's site visibility is checked separately.
- Invalid stored values on approve (cannot happen through the API) answer 409 and leave the request pending (no close reason fits); every other re-validation failure closes `expired(<reason>)` with a 409 `{detail, status, close_reason}`.
- Closing any request marks the approvers' unread `approval_requested` rows for it read (one UPDATE).

## Tried and ruled out
- Sequence-mocked service tests: the approve path is ~9 queries. `tests/test_approvals_service.py` uses a `FakeDB` that answers each SELECT by what it selects (entity + first column name, `with_for_update` tells the locked row from the duplicate check), so the tests pin the state machine, not the query order.

## Open
- Not implemented (not in the brief): a fresh `approval_requested` to a new PM on a `pm_id` change or to new approvers on a policy edit (edge cases #2/#10); "Apply now" (#3, frontend).
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: "RBAC todos: private rows with per-user pushes, approvals merged live, eligibility in SQL, demo pending booking"
author: Claude
date: 2026-10-21
branch: feat/rbac
cost: { minutes: 0, tokens: 0 }
touches:
- app/routers/todos.py
- app/schemas/todo.py
- app/services/approvals.py
- app/routers/users.py
- app/services/tenant_data_migrations.py
- app/scripts/demo_data.py
- app/services/demo_seed.py
- tests/test_todos_routes.py
- tests/test_approvals_followups.py
---

## Decided
- Todos are private like notifications: every query carries `assignee_id = me OR created_by = me`, strangers get 404, writes are never broadcast tenant-wide (path in `SKIP_PATTERNS`); `todo:changed {id, kind, action}` goes to assignee + creator (+ the previous assignee on a reassign) via `send_user_event`, and `todo_assigned` only when the assignee is not the actor.
- Rights: own todo = `todos.manage_own`; someone else's = `todos.assign_others`, outright or the PM bundle on the todo's project (`user_can`); PUT checks the todo as it is and as it becomes; DELETE = creator (or the assignee once `created_by` is NULL). The project's `pm_id` is read only when the bundle can decide (`_pm_if_needed`).
- `GET /todos/mine` merges `approvals.decidable_todo_items` (id `approval:<n>`); sort = open first, due date (none last), oldest first.
- `to_decide` eligibility is in SQL (`approvals.decider_clause`: PM unless the action's policy has `pm_approves = false`, or the caller's role in the policy roles; admins unrestricted), so `limit` never returns a short page. The Python `can_decide` filter stays as a safety net.
- `expire_for_requester` runs from users.py on PUT `active=0`, toggle-active off and DELETE (`deleted=True`: no notification, events `delete`); publish after the commit.
- Data step cleanups after the request expiry: pending rows whose request is not pending are deleted, approved rows lose pointers to closed requests (one statement per assignment table).
- Demo: roles before users; the pending row is inserted only `WHERE EXISTS` its request is still pending, then the request's `assignment_id` is filled (`AND assignment_id IS NULL`), mirroring `submit_request`'s order. The `approval_requested` notification's entity is `("approval_request", project, target)`.

## Tried and ruled out
- Proving "a page is not cut short" with mocks: impossible. `tests/test_approvals_followups.py` runs `list_requests` against in-memory SQLite (sync Session behind a tiny async `execute` adapter; every tenant table copied into a fresh MetaData with the PostgreSQL CHECKs stripped, only the listing's tables created). Reuse that pattern when a query's semantics, not its shape, needs a test.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: "RBAC visible sites: every read and write scoped server-side, view keys gate reads, site_links fixture serves the lookups"
author: Claude
date: 2026-10-21
branch: feat/rbac
cost: { minutes: 0, tokens: 0 }
touches:
- app/services/site_access.py
- app/routers/projects.py
- app/routers/vacations.py
- app/routers/equipment.py
- app/routers/kanban.py
- app/routers/users.py
- tests/conftest.py
- tests/test_site_visibility.py
---

## Decided
- One module, `app/services/site_access.py`: `visible_site_ids` (None = `sites.view_all`), `ensure_site_visible` (site param / body `site_id`, 403; None = site-less, view_all only), `visible_sites_clause` (list filters), `ensure_project_visible` (403, also on every project-editor/assignment/booking/Kanban write, even with `projects.edit_any`), `ensure_equipment_visible` (404), `person_is_visible` / `ensure_person_visible` (oneself or a shared visible site). view_all never queries.
- Status codes: a site/project parameter outside = 403; reading a site, team, equipment, staff member or user outside = 404 (hide existence); writing on a person outside = 403.
- `archived=true` without `views.archived` = 403, but `archived=all` just drops archived projects: `fetchAllProjects()` calls `archived=all`, a 403 there would break the whole Gantt load for a role without the key.
- Vacations: `absences.view_others` = own + members of the visible sites (view_all: all); without it own only; `?siteId=` needs the key and a visible site. Superusers lost tenant-wide absence reads (deliberate).

## Tried and ruled out
- Putting the site check inside `project_access` helpers: they take no `request` (no per-request cache) and the change would ripple through every key-helper unit test. Routes call `ensure_project_visible` right before the key check instead.
- Querying the project's/equipment's/person's sites inline in route tests: dozens of existing `side_effect` lists would shift. The lookups are module functions (`_load_project_site_id`, `_load_equipment_site_ids`, `_load_user_site_ids`) that `authed_client` patches; the `site_links` fixture places people/projects/equipment (default: at the caller's sites). Their real queries are pinned in `tests/test_visible_site_ids.py`.

## Open
- Not scoped: `GET /equipment-types` (configuration). Fix round 1 scoped custom-column values (read filter through the owning project, `ensure_project_visible` on writes), `GET /skills/user/{id}` (404) and masks other sites' project names in `/equipment/{id}/assignments` as `[External Project]`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: "Team filter covers team-limited company events and the tag filter options; an event's teams are shown"
author: Claude
date: 2026-10-21
branch: feat/teams
cost: { minutes: 0, tokens: 0 }
touches:
- frontend/src/utils/teams.ts
- frontend/src/hooks/useTeamFilter.ts
- frontend/src/hooks/useTagFilter.ts
- frontend/src/components/gantt/GanttContainer.tsx
- frontend/src/components/views/StaffView/StaffView.tsx
- frontend/src/components/views/StaffView/StaffTimelineBody.tsx
- frontend/src/components/views/StaffView/CompanyEventsRow.tsx
- frontend/src/components/views/CrossSiteView/CrossSiteView.tsx
- frontend/src/components/views/EquipmentView/EquipmentView.tsx
- frontend/src/components/views/ArchivedView/ArchivedView.tsx
- frontend/src/components/modals/KanbanCardModal.tsx
- frontend/src/components/settings/panels/CompanyEventsPanel.tsx
- CLAUDE.md
- CHANGELOG.md
- docs/user-guide/settings.md
- docs/user-guide/vacations.md
---

## Decided
- Every read of a team-scoped entity under the current site goes through `useTeamFilter()` (or a hook built on it). The header filter only knew projects, staff rows and equipment, so a company event limited to team B stayed on every timeline and in the Staff view's Company events row under filter A, and the tag filter offered team-B tags.
- Per-site "no team here = whole site" entities — equipment, company events, tags — share one rule, `siteScopedTeamsMatchFilter` in `utils/teams.ts` (`equipmentMatchesTeamFilter` / `eventMatchesTeamFilter` / `tagMatchesTeamFilter`): an entity passes when it has no team at this site, or when one of its teams here is selected. `noTeam` applies only to projects and staff rows; it never lets a team-limited event or tag through.
- Views take events from `useTeamFilteredCompanyEvents()` (`{events, eventDates}`, the unfiltered store values when the filter is null, else the filtered list and a rebuilt date Set). Writes and refetches still go to `appStore.companyEvents`.
- The tag filter narrows its **options** (`siteTags` = site tags passing `tagMatches`) but resolves projects against **every** site tag (`allSiteTags`). Resolving against the narrowed list made a visible project carrying a filtered-out tag count as having no tag of that group, so it matched "(No tag)".
- The Kanban card dialog narrows its unassigned candidates on cards of the current site only.
- Group-by sections (`useGanttGrouping`) and Colour-by (`useGanttColorBy`) are deliberately **not** narrowed: a section exists only when a visible project carries the tag, and hiding the section would misfile that project.
- `VacationModal`'s staff picker is deliberately tenant-wide (admins and superusers book absences for anyone at any site).
- An event's teams are visible: Settings → Company events has a Teams column (chips with the team colour, `title="<Team> · <Site>"`, *Whole site* when none, "N teams" when no id resolves), and the Staff view's Company events row names them after the event name for closures and plain events.
- The Staff row's team label and the Closure badge tooltip name only the **current site's** teams (`teamIdsOnSite(event.team_ids, teamsForSite(teams, siteId))`, inactive included): an event shared with another site and limited only to teams there is whole-site here, as the filter treats it, so it shows no label and the whole-site closure tooltip. The Settings table stays cross-site (each chip's title carries its site).

## Tried and ruled out
- Narrowing `siteTags` and using it for both the dropdown and project evaluation (first cut of task 2): leaks projects into "(No tag)" selections, see above.

## Watch out
- A new view that draws company events from `appStore.companyEvents` directly reintroduces the leak; use `useTeamFilteredCompanyEvents()`.
- `useTeamFilter.ts` imports `buildEventDateSet` through the `@/api` barrel: a test that mocks `@/api` must keep it (spread `importOriginal`).
- The Staff view mounts several `useTeamFilteredCompanyEvents()` instances (view, timeline body, events row); they are memoised per instance, harmless, but a prop would do if it ever shows in profiles.
47 changes: 47 additions & 0 deletions .trail/learnings/2026-10-21-teams-per-site-scoping-claude.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
title: "Teams per site: per-site 'no rows = whole site' scoping for tags, equipment and events; team-aware closures"
author: Claude
date: 2026-10-21
branch: feat/teams
cost: { minutes: 0, tokens: 0 }
touches:
- app/models/team.py
- app/routers/teams.py
- app/services/team_rules.py
- app/services/tag_rules.py
- app/routers/projects.py
- app/routers/users.py
- app/routers/equipment.py
- app/routers/tags.py
- app/routers/sites.py
- app/routers/export.py
- migrations/add_teams.sql
- frontend/src/utils/teams.ts
- frontend/src/utils/siteClosure.ts
- frontend/src/hooks/useTeamFilter.ts
- frontend/src/stores/viewStore.ts
- frontend/src/components/layout/Header/TeamSelector.tsx
- frontend/src/components/settings/panels/TeamsPanel.tsx
- frontend/src/components/settings/panels/TeamScope.tsx
- CLAUDE.md
- docs/user-guide/settings.md
- docs/developer-guide/api-reference.md
---

## Decided
- Data model: `teams` belongs to one site (`site_id`, name unique per site, description, colour, `active`); `team_members`, `project_teams`, `tag_teams`, `company_event_teams`, `equipment_teams` are plain M:N tables, every FK `ON DELETE CASCADE` (site delete removes teams and every link). `migrations/add_teams.sql` = `tenant_provisioner.TEAMS_SCHEMA_STATEMENTS`, also run by the tenant auto-migration.
- Members must belong to the team's site (`load_site_members`, 400). Project teams must be at the project's site (a site-less project has none); unknown team ids on project writes are dropped silently (What-If replay), like tag ids. Users: `team_ids` strict (404), superuser only teams at their sites (403); a `site_ids` change alone prunes the memberships of removed sites.
- Tags, equipment and company events: per site, **no team row at a site = the whole site**; rows = only those teams there. Their `team_ids` are one flat list across sites — the API checks each id is at one of the entity's sites (`ensure_teams_within_sites`), and the client restricts it to the current site's teams (`teamIdsOnSite`) before every predicate. A tag limited to teams at S applies to a project at S only if the project shares one (`tag_applies_to_project`); narrowing a tag's teams under existing projects → 409.
- Scoping considers all teams, inactive included; only the header filter (`useTeamFilter`) offers active teams. Equipment limited solely to an inactive team is therefore hidden under any team filter.
- Closures: a team-limited closure takes out only its teams' members at the shared sites (`getClosureOn(..., staffTeamIds, teamSiteById)`) and never shades the calendar header (`timeline.ts` treats it as a plain event); the Closure badge tooltip names the teams. A plain team-limited event shows to the whole site.
- The header filter lives in `viewStore.teamFilters`, keyed by `tagFilterKey(siteId)` (tenant + site), absent = Whole site. viewStore's `merge` is field by field, so `teamFilters` is listed there (with `sanitizeTeamFilters`) — forgetting it silently resets the filter on every reload.
- No team data in the session or `/auth/me`: the current user's teams come from the staff rows (`team_ids` per user–site row; `selectStaffTeamIds` unions them).

## Tried and ruled out
- Nothing substantial was tried and dropped; the per-site rule held through implementation and review.

## Watch out
- Every `getClosureOn` caller must pass the person's team set and `selectTeamSiteById`; the defaults treat a team-limited closure as whole-site, so a missed caller silently over-blocks.
- Node 25's built-in `localStorage` shadows jsdom's: run vitest with `NODE_OPTIONS="--no-experimental-webstorage"`.
- In the sandbox, pytest must run from a temp cwd (running it from the repo root fails with a PermissionError on the unreadable `.env`) — use the wrapper script.
- `docs/assets/screenshots/settings-teams.png` and `team-filter.png` do not exist yet: the `settings-teams` / `team-filter` shots are registered in `scripts/screenshots/capture.py` but were not captured; add the image references to `docs/user-guide/settings.md#teams` after capturing.
Loading
Loading