Multi-body bookings: divisional and multi-body visibility scopes (#19) - #20
Merged
Conversation
Bookings and room_requests gain a `scope` of single | divisional | multi. `body_id` stays populated in every case as the originating body, so all 48 existing bookings and 13 requests remain valid with no backfill, and audit log / email attribution never depends on the scope. - schema: scope + division columns, booking_bodies and room_request_bodies join tables, CHECK constraints, partial indexes on the divisional path. - rls: generalizes the policies that previously resolved visibility through a single body_id. booking_is_visible / booking_is_manageable are now the single definition, delegated to by every child-table policy so the rule cannot drift. Also pins search_path on the three pre-existing helpers, clearing a live function_search_path_mutable finding. - grants: new helpers are internal RLS predicates, not API surface, so EXECUTE is revoked from PUBLIC/anon. `authenticated` must keep it -- verified that policy evaluation itself requires EXECUTE on functions the policy calls. Includes a rollback script captured verbatim from pg_policies before the rewrite, so the prior behavior can be restored exactly rather than from memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds lib/booking-scope.ts as the single app-side home for the scope rule, mirroring booking_is_visible / booking_is_manageable in SQL. RLS only guards reads here -- every booking and request write goes through the service-role client, which bypasses RLS entirely -- so the guards in this module are the real write authorization. Shipping the migration without these would let leadership request a divisional booking for a division they do not lead. - replaces the single-body Leadership check duplicated across the revision, cancellation and request routes with requireBookingManager. - the three admin booking routes persist scope and sync booking_bodies on both create and edit, clearing the join rows when the scope moves away from multi. - resolveBookingRecipients replaces six per-route membership queries that only ever notified the owning body, which would under-notify divisional and multi bookings. Multi notifies all listed bodies; divisional notifies the owning body plus peer leadership, so a whole division is not emailed on every edit. - my-rooms can no longer express visibility as one .in(body_id): PostgREST has no subquery syntax, so the divisional and multi paths are resolved to ids first and folded into a single .or(). canManage is now computed server-side per booking instead of derived client-side from a flat leadership list, which only ever worked for single-body bookings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BookingScopeSelector replaces the plain Body <select> that the three admin create forms, three admin edit forms, and the leadership request page each declared inline. Single body stays the default and still reads as one field, so the common case is unchanged. Admins may scope to any division; leadership only to divisions they lead, and the option is disabled with a hint when they lead none. The multi-body pool is every active body for everyone, since any leadership may request a multi-body booking with any combination -- that leaks nothing, as bodies are already readable by any authenticated user. Switching scope clears the fields the new scope does not use, so the payload always satisfies the database CHECK constraints. ScopeLabel renders "Campus Affairs (Division)" and "DEI Committee + 2 others" with the full list on hover and click-to-expand. My Rooms now groups by scope rather than by body, since for divisional and multi bookings the owning body is not what determines who sees them. public/sw.js is the regenerated next-pwa artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`supabase db dump` writes data.sql / roles.sql / schema.sql into the repo root. These should never be committed: a populated roles.sql can contain role passwords and data.sql is a full copy of production. Schema history belongs in supabase/migrations/. The three currently present are 0 bytes, from an interrupted dump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Resolves two conflicts in My Rooms, where the #14/#13/#16 rework landed on the same files as the scope changes. - app/api/my-rooms/route.ts: kept the scope-aware visibility rewrite (the three-way OR across body / division / booking_bodies, and server-computed canManage) and folded in dev'"'"'s senate_type_preferences fetch and return. The old leadershipBodyIds response field stays dropped -- it only ever described single-body bookings. - app/(dashboard)/my-rooms/page.tsx: took dev'"'"'s reworked page as the base and reapplied the scope changes onto it, rather than the reverse, so the new list/calendar toggle, search, status filter and Senate preferences are all preserved. FlatBooking now lives in dev'"'"'s shared.ts, so canManage / scopeKey / scopeLabel and the scopeKeyOf / scopeLabelOf helpers moved there too -- calendar-view and booking-detail-modal consume the same type and now show the scope label rather than the owning body, matching the rest of My Rooms. Their #15 bcc fix composes well with the wider divisional/multi fan-out: the larger recipient list is no longer exposed via to:/cc:. tsc clean, build succeeds, and the only lint findings in the touched area are pre-existing ones in notification-bell.tsx and LoginCard.tsx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closed
booking_bodies and room_request_bodies were created with a composite (parent_id, body_id) primary key -- the textbook junction shape. PostgREST detects that and began inferring a many-to-many `bookings <-> bodies` relationship through booking_bodies, on top of the existing many-to-one bookings.body_id -> bodies.id. With two candidate relationships every `bodies(name)` embed became ambiguous and PostgREST answered 300 / PGRST201. That broke reads app-wide, including code that predates this feature -- creating the tables was enough on its own. The Administrator and My Rooms pages showed "no bookings found" while all 48 bookings were still present. board_memberships is the precedent already in this schema: also a junction table, but with a surrogate id primary key and the pair merely UNIQUE, which is why users <-> bodies has never been ambiguous. The junction tables now match it. The unique constraint preserves the real invariant; only the backing index changes. Also stops the two affected read routes from coercing a failed query into an empty list. `data || []` is what turned a hard API error into a calm "no bookings found" -- the failure was indistinguishable from having no data, which is why this looked like missing rows rather than a broken query. Verified over the REST API that both the pre-existing `bodies(name)` embeds and the new nested ones return 200, and re-ran the RLS persona matrix to confirm the primary key change did not affect visibility. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A hidden booking is only visible to those who can manage it, but resolveBookingRecipients never consulted `hidden` -- so every member of the audience got a user_alert and an email whenever one was updated, telling them about a booking they cannot see. Hidden now narrows recipients to Leadership across the whole audience, for all three scopes, which is exactly the set canManageScoped() admits. Confirmed against real data: a hidden Campus Affairs divisional booking previously notified 7 people including 1 plain member, and now notifies the 6 leadership. `hidden` is looked up inside resolveBookingRecipients rather than passed in by each route, because a caller forgetting to pass it is precisely what caused this bug. One lookup, no route can bypass it. Visibility itself was already correct and needed no change: my-rooms filters on `!hidden || canManage`, and canManage is scope-aware, so members are excluded from hidden single-body, divisional and multi bookings alike. Bumps the version to 1.13.0 and moves the FAQ roadmap heading to v1.14.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #19. Bookings can now be scoped to a single body (the default and
unchanged path), a whole division, or an explicit set of bodies.
Approach
body_idstays populated for all three scopes as the originating body. Thatkeeps all 48 existing bookings and 13 requests valid with no backfill, and means
audit-log and email attribution never depends on the scope.
single-- one body. Unchanged behavior.divisional-- visible to anyone with a membership in that division, manageableby anyone holding Leadership anywhere in it.
multi-- an explicit set inbooking_bodies, which holds every participatingbody including the owner.
Two things worth knowing before reviewing
Production RLS was not in version control. The tracked migrations folder held
only two recent cleanup files; the base schema and a large set of live policies
existed only in the project. Fourteen policies resolved visibility through a
single
body_id, so without generalizing them divisional and multi bookingswould have been silently invisible to exactly the people the feature is for.
RLS only guards reads. Every booking and request write goes through the
service-role client, which bypasses RLS entirely, so the hand-rolled Leadership
checks are the real write authorization. Shipping the migration without the route
changes would let leadership request a divisional booking for a division they do
not lead. Both layers change here.
Migrations (already applied to production)
Three, plus a rollback captured verbatim from
pg_policiesbefore the rewrite soprior behavior can be restored exactly rather than reconstructed.
booking_is_visible/booking_is_manageableare now the single definition ofthe rule, delegated to by every child-table policy so it cannot drift between
tables. The
search_pathfix on the three pre-existing helpers clears a livefunction_search_path_mutablefinding that predates this work.Two bugs were caught during application rather than after:
= any((select f()))-- it treats the subquery as a row setand compares
uuid = uuid[]. Four forms were tested;= any((select f())::uuid[])works and preserves the InitPlan.
revealed that policy evaluation itself requires EXECUTE, so revoking from
authenticatedbreaks every booking read. The shipped grant revokesPUBLIC/anononly.Verification
Persona matrix run against real Campus Affairs data, in rolled-back transactions:
Also confirmed: no policy recursion; child and two-level-deep tables filter
identically; leadership can create a divisional request for their own division
but is blocked for one they do not lead, and blocked from a body they do not
lead. All test data rolled back -- 48 bookings, zero residue, zero integrity
violations.
tsc --noEmitclean,npm run buildsucceeds, lint at exact parity with thedevbaseline (119 problems / 15 errors, all pre-existing).Not yet done
No multi-body booking has been created through the UI yet. The database rule
is proven; the end-to-end pass -- create one of each scope from each form, check
the integrity query in the schema migration, confirm email fan-out -- is still
outstanding and is the main thing to exercise when reviewing.
Notes
owning body plus peer leadership, so a whole division is not emailed on every
edit. Centralized in
resolveBookingRecipients-- a one-line change if you wantit different.
adding a body to a division retroactively grants its members visibility of past
divisional bookings. Believed intended, flagging explicitly.
multi">= 2 bodies including the owner" invariant cannot be a constrainttrigger, because supabase-js writes parent and join rows in two separate
transactions. Enforced in
validateScopeSelection, with a standing integrityquery in the schema migration.
DIVISIONS). A realdivisionslookup table is the right fix but means replacingbodies_division_check-- wider blast radius than this issue. Worth a follow-up.🤖 Generated with Claude Code