Skip to content

Multi-body bookings: divisional and multi-body visibility scopes (#19) - #20

Merged
pataniaeli merged 7 commits into
devfrom
feature/multi-body-bookings
Aug 26, 2026
Merged

Multi-body bookings: divisional and multi-body visibility scopes (#19)#20
pataniaeli merged 7 commits into
devfrom
feature/multi-body-bookings

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

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_id stays populated for all three scopes as the originating body. That
keeps 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, manageable
    by anyone holding Leadership anywhere in it.
  • multi -- an explicit set in booking_bodies, which holds every participating
    body 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 bookings
would 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_policies before the rewrite so
prior behavior can be restored exactly rather than reconstructed.

booking_is_visible / booking_is_manageable are now the single definition of
the rule, delegated to by every child-table policy so it cannot drift between
tables. The search_path fix on the three pre-existing helpers clears a live
function_search_path_mutable finding that predates this work.

Two bugs were caught during application rather than after:

  • Postgres rejects = any((select f())) -- it treats the subquery as a row set
    and compares uuid = uuid[]. Four forms were tested; = any((select f())::uuid[])
    works and preserves the InitPlan.
  • The new helpers were exposed as public RPC endpoints. Testing the revoke first
    revealed that policy evaluation itself requires EXECUTE, so revoking from
    authenticated breaks every booking read. The shipped grant revokes
    PUBLIC/anon only.

Verification

Persona matrix run against real Campus Affairs data, in rolled-back transactions:

Persona single divisional multi
Member of owning body see see see
Leadership of owning body see + manage see + manage see + manage
Leadership of peer Campus Affairs body -- see + manage --
Leadership of DEI (multi participant) -- -- see + manage
Unrelated body -- -- --

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 --noEmit clean, npm run build succeeds, lint at exact parity with the
dev baseline (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

  • Notification fan-out: multi notifies all listed bodies; divisional notifies the
    owning body plus peer leadership, so a whole division is not emailed on every
    edit. Centralized in resolveBookingRecipients -- a one-line change if you want
    it different.
  • A divisional booking snapshots its division, but its audience is computed live:
    adding a body to a division retroactively grants its members visibility of past
    divisional bookings. Believed intended, flagging explicitly.
  • The multi ">= 2 bodies including the owner" invariant cannot be a constraint
    trigger, because supabase-js writes parent and join rows in two separate
    transactions. Enforced in validateScopeSelection, with a standing integrity
    query in the schema migration.
  • The division list now lives in four places (three CHECKs + DIVISIONS). A real
    divisions lookup table is the right fix but means replacing
    bodies_division_check -- wider blast radius than this issue. Worth a follow-up.

🤖 Generated with Claude Code

pataniaeli and others added 4 commits August 25, 2026 22:17
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>
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chambers Ready Ready Preview Aug 26, 2026 2:57am

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>
@SGAOperations SGAOperations self-assigned this Aug 26, 2026
@SGAOperations SGAOperations linked an issue Aug 26, 2026 that may be closed by this pull request
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>
@pataniaeli
pataniaeli merged commit ba23eaa into dev Aug 26, 2026
4 checks passed
@pataniaeli
pataniaeli deleted the feature/multi-body-bookings branch August 26, 2026 03:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hidden functionality breaks on multi-divisional bookings Multi-body bookings

2 participants