From cd8447b8a63f124dfa7f8469ba42c397f5833213 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 22:17:19 -0400 Subject: [PATCH 1/6] Add multi-body booking scope to the database (#19) 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 --- ...60826000000_multi_body_bookings_schema.sql | 148 ++++++++ ...20260826001000_multi_body_bookings_rls.sql | 345 ++++++++++++++++++ ...2000_multi_body_bookings_helper_grants.sql | 37 ++ .../20260826_multi_body_bookings_rollback.sql | 154 ++++++++ 4 files changed, 684 insertions(+) create mode 100644 supabase/migrations/20260826000000_multi_body_bookings_schema.sql create mode 100644 supabase/migrations/20260826001000_multi_body_bookings_rls.sql create mode 100644 supabase/migrations/20260826002000_multi_body_bookings_helper_grants.sql create mode 100644 supabase/migrations/rollback/20260826_multi_body_bookings_rollback.sql diff --git a/supabase/migrations/20260826000000_multi_body_bookings_schema.sql b/supabase/migrations/20260826000000_multi_body_bookings_schema.sql new file mode 100644 index 0000000..566d9f1 --- /dev/null +++ b/supabase/migrations/20260826000000_multi_body_bookings_schema.sql @@ -0,0 +1,148 @@ +-- Multi-body bookings (issue #19) -- part 1 of 2: schema. +-- +-- A booking (and the room_request that may precede it) gains a `scope`: +-- 'single' -- one body. The existing behavior, and the default for every existing row. +-- 'divisional' -- owned by `body_id` but visible/manageable across `division`. +-- 'multi' -- owned by `body_id`, shared with the bodies listed in `booking_bodies`. +-- +-- `body_id` stays populated in all three cases as the originating/owning body, so existing rows +-- stay valid untouched and attribution for audit_logs and notification emails is preserved. +-- +-- Part 2 (…_multi_body_bookings_rls.sql) generalizes the RLS policies that currently resolve +-- visibility through a single `body_id`. This file is deliberately additive and separate so the +-- policy rewrite can be reverted without reverting the schema. + +-- --------------------------------------------------------------------------- +-- bookings +-- --------------------------------------------------------------------------- + +alter table public.bookings + add column scope text not null default 'single', + add column division text; + +alter table public.bookings + add constraint bookings_scope_check + check (scope in ('single', 'divisional', 'multi')), + -- Mirrors bodies_division_check. See the note at the foot of this file about the duplication. + add constraint bookings_division_check + check (division is null or division = any (array[ + 'Office of the President'::text, 'Academic Affairs'::text, 'Campus Affairs'::text, + 'DEI'::text, 'Student Success'::text, 'Operational Affairs'::text, + 'External Affairs'::text, 'Student Involvement'::text, 'Senate'::text, + 'Non-Divisional'::text])), + -- division is present if and only if the booking is divisional + add constraint bookings_division_required_check + check ((scope = 'divisional') = (division is not null)); + +-- Verified 0 rows with a null body_id before writing this. The FK is already RESTRICT, so this +-- narrows the type without changing any delete behavior. +alter table public.bookings alter column body_id set not null; + +-- --------------------------------------------------------------------------- +-- room_requests -- same shape, so a request can carry its scope through fulfillment +-- --------------------------------------------------------------------------- + +alter table public.room_requests + add column scope text not null default 'single', + add column division text; + +alter table public.room_requests + add constraint room_requests_scope_check + check (scope in ('single', 'divisional', 'multi')), + add constraint room_requests_division_check + check (division is null or division = any (array[ + 'Office of the President'::text, 'Academic Affairs'::text, 'Campus Affairs'::text, + 'DEI'::text, 'Student Success'::text, 'Operational Affairs'::text, + 'External Affairs'::text, 'Student Involvement'::text, 'Senate'::text, + 'Non-Divisional'::text])), + add constraint room_requests_division_required_check + check ((scope = 'divisional') = (division is not null)); + +alter table public.room_requests alter column body_id set not null; + +-- --------------------------------------------------------------------------- +-- Join tables for 'multi' scope +-- --------------------------------------------------------------------------- + +-- Invariant (enforced in lib/booking-scope.ts, not here -- see the note below): +-- scope='multi' -> contains every participating body, INCLUDING bookings.body_id, count >= 2 +-- scope<>'multi' -> empty +-- +-- body_id is ON DELETE RESTRICT rather than CASCADE on purpose: bookings.body_id is already +-- RESTRICT, and cascading here would silently shrink a booking's audience with no trace. +create table public.booking_bodies ( + booking_id uuid not null references public.bookings(id) on delete cascade, + body_id uuid not null references public.bodies(id) on delete restrict, + created_at timestamptz not null default now(), + primary key (booking_id, body_id) +); + +-- The PK covers booking_id -> bodies; this covers the reverse lookup my-rooms needs. +create index booking_bodies_body_id_idx on public.booking_bodies (body_id); + +alter table public.booking_bodies enable row level security; + +create table public.room_request_bodies ( + request_id uuid not null references public.room_requests(id) on delete cascade, + body_id uuid not null references public.bodies(id) on delete restrict, + created_at timestamptz not null default now(), + primary key (request_id, body_id) +); + +create index room_request_bodies_body_id_idx on public.room_request_bodies (body_id); + +alter table public.room_request_bodies enable row level security; + +-- --------------------------------------------------------------------------- +-- Indexes for the divisional visibility path +-- --------------------------------------------------------------------------- + +create index bookings_divisional_idx + on public.bookings (division) where scope = 'divisional'; + +create index room_requests_divisional_idx + on public.room_requests (division) where scope = 'divisional'; + +-- --------------------------------------------------------------------------- +-- Assert the implicit backfill +-- --------------------------------------------------------------------------- + +-- No backfill statement is needed: Postgres applies a non-volatile DEFAULT to existing rows +-- without a table rewrite, so every pre-existing booking/request is already ('single', null). +-- This asserts that actually happened rather than trusting it. +do $$ +begin + if exists (select 1 from public.bookings + where scope <> 'single' or division is not null) + or exists (select 1 from public.room_requests + where scope <> 'single' or division is not null) + then + raise exception 'multi-body migration: pre-existing rows are not all single-scope'; + end if; +end $$; + +-- --------------------------------------------------------------------------- +-- Notes +-- --------------------------------------------------------------------------- +-- +-- 1. The "multi has >= 2 bodies including the owner" invariant is NOT a constraint trigger. +-- supabase-js writes the parent row and the join rows as two separate HTTP requests, i.e. two +-- transactions, so even a DEFERRABLE INITIALLY DEFERRED trigger fires while booking_bodies is +-- still empty and every multi insert would fail. It is enforced in validateScopeSelection(). +-- Standing integrity check for ops: +-- +-- select b.id, b.scope, count(bb.body_id) as linked +-- from public.bookings b +-- left join public.booking_bodies bb on bb.booking_id = b.id +-- group by b.id, b.scope +-- having (b.scope = 'multi' +-- and (count(bb.body_id) < 2 or not bool_or(bb.body_id = b.body_id))) +-- or (b.scope <> 'multi' and count(bb.body_id) > 0); +-- +-- 2. The division list now appears in three CHECK constraints plus DIVISIONS in +-- lib/booking-scope.ts. The right fix is a public.divisions lookup table with real FKs, which +-- would also make the bodies-tab dropdown DB-driven -- but that means replacing +-- bodies_division_check, a wider blast radius than this issue warrants. Tracked as follow-up. +-- +-- 3. CHECK constraints here are validated immediately; at 48 bookings / 13 requests the scan is +-- free. On a large table these would want ADD ... NOT VALID followed by VALIDATE CONSTRAINT. diff --git a/supabase/migrations/20260826001000_multi_body_bookings_rls.sql b/supabase/migrations/20260826001000_multi_body_bookings_rls.sql new file mode 100644 index 0000000..6c6cc06 --- /dev/null +++ b/supabase/migrations/20260826001000_multi_body_bookings_rls.sql @@ -0,0 +1,345 @@ +-- Multi-body bookings (issue #19) -- part 2 of 2: RLS helpers and policies. +-- +-- Every existing policy resolves visibility through a single body id, via is_body_member(body_id) +-- / is_body_leadership(body_id). Divisional and multi bookings would therefore be invisible to +-- exactly the people the feature exists for. This file generalizes those predicates. +-- +-- WHY SECURITY DEFINER IS SAFE HERE (this is the load-bearing assumption -- verified before +-- writing this migration, re-verify if the project's ownership model ever changes): +-- * Postgres applies RLS to tables referenced inside a policy expression -- that is what produces +-- "infinite recursion detected in policy for relation ...". +-- * Postgres does NOT apply RLS when the current role owns the table and the table is not marked +-- FORCE ROW LEVEL SECURITY. +-- * Every public table here is owned by `postgres`, and none has FORCE ROW LEVEL SECURITY +-- (checked: 0 rows with relforcerowsecurity). +-- Therefore a SECURITY DEFINER helper owned by postgres reads these tables with RLS off and can +-- never re-enter a policy. auth.uid() / auth.jwt() still work inside them -- they read the +-- request.jwt.claims GUC, which is session state, not role state. +-- +-- Corollary, and the trap this file is written to avoid: if the `bookings` policy used an inline +-- EXISTS against booking_bodies AND the booking_bodies policy used an inline EXISTS against +-- bookings, that is genuine mutual recursion -- and it fails at query time, not at migration time. +-- A SECURITY DEFINER function on either side breaks the cycle; both sides use one here so neither +-- is load-bearing on its own. +-- +-- Second corollary: SECURITY DEFINER functions are never inlined by the planner. So on `bookings` +-- and `room_requests`, where the row's own columns are in scope, the predicate is expressed over +-- those columns plus once-per-query array lookups, rather than a per-row function call. The +-- (select ...) wrapper makes them InitPlans, continuing the idiom from +-- 20260825000000_rls_auth_initplan_fix.sql. + +-- --------------------------------------------------------------------------- +-- 0. Pin search_path on the existing helpers +-- --------------------------------------------------------------------------- +-- These are SECURITY DEFINER with a mutable search_path, which is a live Supabase security lint +-- (function_search_path_mutable) and a real privilege-escalation surface. They already reference +-- only public tables and schema-qualified auth.*, so this is a no-op behaviorally. + +alter function public.is_admin() set search_path = public, pg_temp; +alter function public.is_body_member(uuid) set search_path = public, pg_temp; +alter function public.is_body_leadership(uuid) set search_path = public, pg_temp; + +-- --------------------------------------------------------------------------- +-- 1. Identity helpers -- the caller's memberships as arrays +-- --------------------------------------------------------------------------- +-- Used as `body_id = any ((select public.my_body_ids())::uuid[])` these evaluate once per query as an +-- InitPlan, replacing N per-row is_body_member() calls. + +create or replace function public.my_body_ids() +returns uuid[] +language sql stable security definer set search_path = public, pg_temp +as $$ + select coalesce(array_agg(m.body_id), '{}'::uuid[]) + from public.board_memberships m + where m.user_id = auth.uid(); +$$; + +create or replace function public.my_leadership_body_ids() +returns uuid[] +language sql stable security definer set search_path = public, pg_temp +as $$ + select coalesce(array_agg(m.body_id), '{}'::uuid[]) + from public.board_memberships m + where m.user_id = auth.uid() and m.role = 'Leadership'; +$$; + +create or replace function public.my_divisions() +returns text[] +language sql stable security definer set search_path = public, pg_temp +as $$ + select coalesce(array_agg(distinct b.division), '{}'::text[]) + from public.board_memberships m + join public.bodies b on b.id = m.body_id + where m.user_id = auth.uid(); +$$; + +create or replace function public.my_leadership_divisions() +returns text[] +language sql stable security definer set search_path = public, pg_temp +as $$ + select coalesce(array_agg(distinct b.division), '{}'::text[]) + from public.board_memberships m + join public.bodies b on b.id = m.body_id + where m.user_id = auth.uid() and m.role = 'Leadership'; +$$; + +-- --------------------------------------------------------------------------- +-- 2. Per-booking predicates -- for child tables, where the parent row is not in scope +-- --------------------------------------------------------------------------- + +create or replace function public.booking_body_is_member(p_booking_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.booking_bodies bb + where bb.booking_id = p_booking_id + and bb.body_id = any (public.my_body_ids()::uuid[]) + ); +$$; + +create or replace function public.booking_body_is_leadership(p_booking_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.booking_bodies bb + where bb.booking_id = p_booking_id + and bb.body_id = any (public.my_leadership_body_ids()::uuid[]) + ); +$$; + +-- The single definition of "can this user see this booking". Every child-table policy delegates +-- here so the rule cannot drift between tables. +create or replace function public.booking_is_visible(p_booking_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.bookings b + where b.id = p_booking_id + and ( b.body_id = any (public.my_body_ids()::uuid[]) + or (b.scope = 'divisional' and b.division = any (public.my_divisions()::text[])) + or (b.scope = 'multi' and public.booking_body_is_member(b.id)) ) + ); +$$; + +create or replace function public.booking_is_manageable(p_booking_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.bookings b + where b.id = p_booking_id + and ( b.body_id = any (public.my_leadership_body_ids()::uuid[]) + or (b.scope = 'divisional' and b.division = any (public.my_leadership_divisions()::text[])) + or (b.scope = 'multi' and public.booking_body_is_leadership(b.id)) ) + ); +$$; + +-- One-level-deeper wrappers, so the grandchild policies stay a single call. +create or replace function public.weekly_booking_is_visible(p_weekly_booking_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select public.booking_is_visible( + (select w.booking_id from public.weekly_room_bookings w where w.id = p_weekly_booking_id)); +$$; + +create or replace function public.tabling_booking_is_visible(p_tabling_booking_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select public.booking_is_visible( + (select t.booking_id from public.tabling_bookings t where t.id = p_tabling_booking_id)); +$$; + +-- --------------------------------------------------------------------------- +-- 3. Per-request predicates +-- --------------------------------------------------------------------------- + +create or replace function public.request_body_is_member(p_request_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.room_request_bodies rb + where rb.request_id = p_request_id + and rb.body_id = any (public.my_body_ids()::uuid[]) + ); +$$; + +create or replace function public.request_is_visible(p_request_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.room_requests r + where r.id = p_request_id + and ( r.body_id = any (public.my_body_ids()::uuid[]) + or (r.scope = 'divisional' and r.division = any (public.my_divisions()::text[])) + or (r.scope = 'multi' and public.request_body_is_member(r.id)) ) + ); +$$; + +-- Deliberately the ORIGINATING body's leadership, not a general "manageable": only the body that +-- opened the request may attach detail rows to it. Otherwise leadership of any body that merely +-- appears on a multi request could mutate it. +create or replace function public.request_owner_is_leadership(p_request_id uuid) +returns boolean +language sql stable security definer set search_path = public, pg_temp +as $$ + select exists ( + select 1 from public.room_requests r + where r.id = p_request_id and public.is_body_leadership(r.body_id) + ); +$$; + +-- --------------------------------------------------------------------------- +-- 4. Policy rewrites +-- --------------------------------------------------------------------------- +-- Each stays a SINGLE policy per table/command/role, preserving the intent of +-- 20260825010000_consolidate_multiple_permissive_policies.sql. +-- +-- Behavioral note on the child tables: today their policies do an inline +-- `exists (select 1 from bookings ...)`, and that subquery has bookings' own RLS applied to it -- +-- so each child policy is implicitly "AND the parent booking is visible". Delegating to +-- booking_is_visible() removes that implicit coupling. The net result is identical because +-- booking_is_visible() encodes the same rule; the safety now comes from there being exactly one +-- definition, used by every table. + +-- bookings: SELECT. Cheap column tests gate the expensive branch; the array lookups are InitPlans. +drop policy "bookings_select_admin_or_member" on public.bookings; +create policy "bookings_select_admin_or_member" on public.bookings + for select to authenticated + using ( + is_admin() + or body_id = any ((select public.my_body_ids())::uuid[]) + or (scope = 'divisional' and division = any ((select public.my_divisions())::text[])) + or (scope = 'multi' and public.booking_body_is_member(id)) + ); + +-- bookings INSERT/UPDATE/DELETE (admin-only) and anon_select_bookings_count are unchanged. + +-- one_time_room_bookings / weekly_room_bookings / tabling_bookings: SELECT +drop policy "one_time_select_admin_or_member" on public.one_time_room_bookings; +create policy "one_time_select_admin_or_member" on public.one_time_room_bookings + for select to authenticated + using (is_admin() or public.booking_is_visible(booking_id)); + +drop policy "weekly_select_admin_or_member" on public.weekly_room_bookings; +create policy "weekly_select_admin_or_member" on public.weekly_room_bookings + for select to authenticated + using (is_admin() or public.booking_is_visible(booking_id)); + +drop policy "tabling_select_admin_or_member" on public.tabling_bookings; +create policy "tabling_select_admin_or_member" on public.tabling_bookings + for select to authenticated + using (is_admin() or public.booking_is_visible(booking_id)); + +-- weekly_room_occurrences / tabling_sessions: SELECT (one level deeper) +drop policy "occurrences_select_admin_or_member" on public.weekly_room_occurrences; +create policy "occurrences_select_admin_or_member" on public.weekly_room_occurrences + for select to authenticated + using (is_admin() or public.weekly_booking_is_visible(weekly_booking_id)); + +drop policy "tabling_sessions_select_admin_or_member" on public.tabling_sessions; +create policy "tabling_sessions_select_admin_or_member" on public.tabling_sessions + for select to authenticated + using (is_admin() or public.tabling_booking_is_visible(tabling_booking_id)); + +-- cancellation_requests +drop policy "cancel_requests_select_admin_or_member" on public.cancellation_requests; +create policy "cancel_requests_select_admin_or_member" on public.cancellation_requests + for select to authenticated + using (is_admin() or public.booking_is_visible(booking_id)); + +drop policy "cancel_requests_insert_admin_or_leadership" on public.cancellation_requests; +create policy "cancel_requests_insert_admin_or_leadership" on public.cancellation_requests + for insert to authenticated + with check (is_admin() or public.booking_is_manageable(booking_id)); + +-- cancel_requests_update_admin is unchanged. + +-- room_requests: SELECT +drop policy "requests_select_admin_or_member" on public.room_requests; +create policy "requests_select_admin_or_member" on public.room_requests + for select to authenticated + using ( + is_admin() + or body_id = any ((select public.my_body_ids())::uuid[]) + or (scope = 'divisional' and division = any ((select public.my_divisions())::text[])) + or (scope = 'multi' and public.request_body_is_member(id)) + ); + +-- room_requests: INSERT -- the spec's leadership rule, in SQL. +-- * leadership of the originating body is always required +-- * 'multi' adds no restriction on the other bodies (any leadership may request any combination) +-- * 'divisional' additionally requires leadership somewhere in that division +-- The "multi has >= 2 bodies" rule cannot live here: the child rows do not exist yet at insert +-- time. It is enforced in validateScopeSelection(). +drop policy "requests_insert_admin_or_leadership" on public.room_requests; +create policy "requests_insert_admin_or_leadership" on public.room_requests + for insert to authenticated + with check ( + is_admin() + or ( is_body_leadership(body_id) + and ( scope <> 'divisional' + or division = any ((select public.my_leadership_divisions())::text[]) ) ) + ); + +-- requests_update_admin / requests_delete_admin are unchanged. + +-- room_request_details / tabling_request_sessions +drop policy "request_details_select_admin_or_member" on public.room_request_details; +create policy "request_details_select_admin_or_member" on public.room_request_details + for select to authenticated + using (is_admin() or public.request_is_visible(request_id)); + +drop policy "request_details_insert_admin_or_leadership" on public.room_request_details; +create policy "request_details_insert_admin_or_leadership" on public.room_request_details + for insert to authenticated + with check (is_admin() or public.request_owner_is_leadership(request_id)); + +drop policy "tabling_req_sessions_select_admin_or_member" on public.tabling_request_sessions; +create policy "tabling_req_sessions_select_admin_or_member" on public.tabling_request_sessions + for select to authenticated + using (is_admin() or public.request_is_visible(request_id)); + +drop policy "tabling_req_sessions_insert_admin_or_leadership" on public.tabling_request_sessions; +create policy "tabling_req_sessions_insert_admin_or_leadership" on public.tabling_request_sessions + for insert to authenticated + with check (is_admin() or public.request_owner_is_leadership(request_id)); + +-- --------------------------------------------------------------------------- +-- 5. Policies for the new join tables +-- --------------------------------------------------------------------------- +-- App writes go through the service-role client and bypass RLS regardless; these exist so the +-- tables are not open holes and so the rls_disabled_in_public advisor stays quiet. No anon policy, +-- so anon is default-deny (unlike bookings, which has a pre-existing anon count policy). + +create policy "booking_bodies_select_admin_or_member" on public.booking_bodies + for select to authenticated + using (is_admin() or public.booking_is_visible(booking_id)); + +create policy "booking_bodies_insert_admin" on public.booking_bodies + for insert to authenticated + with check (is_admin()); + +create policy "booking_bodies_delete_admin" on public.booking_bodies + for delete to authenticated + using (is_admin()); + +create policy "room_request_bodies_select_admin_or_member" on public.room_request_bodies + for select to authenticated + using (is_admin() or public.request_is_visible(request_id)); + +create policy "room_request_bodies_insert_admin_or_leadership" on public.room_request_bodies + for insert to authenticated + with check (is_admin() or public.request_owner_is_leadership(request_id)); + +create policy "room_request_bodies_delete_admin" on public.room_request_bodies + for delete to authenticated + using (is_admin()); + +-- bodies and board_memberships policies are unchanged. diff --git a/supabase/migrations/20260826002000_multi_body_bookings_helper_grants.sql b/supabase/migrations/20260826002000_multi_body_bookings_helper_grants.sql new file mode 100644 index 0000000..413c237 --- /dev/null +++ b/supabase/migrations/20260826002000_multi_body_bookings_helper_grants.sql @@ -0,0 +1,37 @@ +-- Multi-body bookings (issue #19) -- part 3 of 3: lock down the helper functions. +-- +-- Postgres grants EXECUTE on new functions to PUBLIC by default, so every helper added in +-- 20260826001000_multi_body_bookings_rls.sql was reachable by the anon role as an RPC endpoint +-- (/rest/v1/rpc/), which the Supabase linter flags as +-- anon_security_definer_function_executable. These are internal RLS predicates, not API surface. +-- +-- IMPORTANT, verified empirically before writing this: evaluating an RLS policy DOES require the +-- querying role to hold EXECUTE on any function that policy calls. Revoking from `authenticated` +-- makes every booking read fail with "permission denied for function my_body_ids". So +-- `authenticated` must keep EXECUTE -- only PUBLIC and anon are revoked. Revoking from PUBLIC is +-- the part that actually does the work: revoking from `anon` alone is a no-op, because anon +-- inherits the default PUBLIC grant. +-- +-- The pre-existing is_admin / is_body_member / is_body_leadership helpers carry the same lint and +-- are deliberately left alone here -- changing their grants is a wider blast radius than this +-- issue, and they leak nothing beyond the caller's own memberships. + +revoke execute on function + public.my_body_ids(), public.my_leadership_body_ids(), + public.my_divisions(), public.my_leadership_divisions(), + public.booking_body_is_member(uuid), public.booking_body_is_leadership(uuid), + public.booking_is_visible(uuid), public.booking_is_manageable(uuid), + public.weekly_booking_is_visible(uuid), public.tabling_booking_is_visible(uuid), + public.request_body_is_member(uuid), public.request_is_visible(uuid), + public.request_owner_is_leadership(uuid) +from public, anon; + +grant execute on function + public.my_body_ids(), public.my_leadership_body_ids(), + public.my_divisions(), public.my_leadership_divisions(), + public.booking_body_is_member(uuid), public.booking_body_is_leadership(uuid), + public.booking_is_visible(uuid), public.booking_is_manageable(uuid), + public.weekly_booking_is_visible(uuid), public.tabling_booking_is_visible(uuid), + public.request_body_is_member(uuid), public.request_is_visible(uuid), + public.request_owner_is_leadership(uuid) +to authenticated, service_role; diff --git a/supabase/migrations/rollback/20260826_multi_body_bookings_rollback.sql b/supabase/migrations/rollback/20260826_multi_body_bookings_rollback.sql new file mode 100644 index 0000000..a13feef --- /dev/null +++ b/supabase/migrations/rollback/20260826_multi_body_bookings_rollback.sql @@ -0,0 +1,154 @@ +-- ROLLBACK for the multi-body bookings migrations (issue #19). +-- +-- NOT a migration -- this file lives outside the numbered sequence deliberately so the Supabase +-- CLI never applies it. Run it by hand only if the multi-body RLS rewrite has to be undone. +-- +-- The policy definitions below were captured verbatim from pg_policies on the live project +-- immediately before 20260826001000_multi_body_bookings_rls.sql was applied, so this restores the +-- exact prior behavior rather than a reconstruction from memory. +-- +-- ORDER MATTERS: restore the policies first, then drop the helper functions they stop referencing, +-- and only then drop the columns/tables. Dropping a function that a live policy still calls fails. + +begin; + +-- --------------------------------------------------------------------------- +-- 1. Restore the original policies +-- --------------------------------------------------------------------------- + +drop policy if exists "bookings_select_admin_or_member" on public.bookings; +create policy bookings_select_admin_or_member on public.bookings + for select to authenticated using ((is_admin() OR is_body_member(body_id))); + +drop policy if exists "one_time_select_admin_or_member" on public.one_time_room_bookings; +create policy one_time_select_admin_or_member on public.one_time_room_bookings + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM bookings + WHERE ((bookings.id = one_time_room_bookings.booking_id) AND is_body_member(bookings.body_id)))))); + +drop policy if exists "weekly_select_admin_or_member" on public.weekly_room_bookings; +create policy weekly_select_admin_or_member on public.weekly_room_bookings + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM bookings + WHERE ((bookings.id = weekly_room_bookings.booking_id) AND is_body_member(bookings.body_id)))))); + +drop policy if exists "tabling_select_admin_or_member" on public.tabling_bookings; +create policy tabling_select_admin_or_member on public.tabling_bookings + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM bookings + WHERE ((bookings.id = tabling_bookings.booking_id) AND is_body_member(bookings.body_id)))))); + +drop policy if exists "occurrences_select_admin_or_member" on public.weekly_room_occurrences; +create policy occurrences_select_admin_or_member on public.weekly_room_occurrences + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM (weekly_room_bookings + JOIN bookings ON ((bookings.id = weekly_room_bookings.booking_id))) + WHERE ((weekly_room_bookings.id = weekly_room_occurrences.weekly_booking_id) AND is_body_member(bookings.body_id)))))); + +drop policy if exists "tabling_sessions_select_admin_or_member" on public.tabling_sessions; +create policy tabling_sessions_select_admin_or_member on public.tabling_sessions + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM (tabling_bookings + JOIN bookings ON ((bookings.id = tabling_bookings.booking_id))) + WHERE ((tabling_bookings.id = tabling_sessions.tabling_booking_id) AND is_body_member(bookings.body_id)))))); + +drop policy if exists "cancel_requests_select_admin_or_member" on public.cancellation_requests; +create policy cancel_requests_select_admin_or_member on public.cancellation_requests + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM bookings + WHERE ((bookings.id = cancellation_requests.booking_id) AND is_body_member(bookings.body_id)))))); + +drop policy if exists "cancel_requests_insert_admin_or_leadership" on public.cancellation_requests; +create policy cancel_requests_insert_admin_or_leadership on public.cancellation_requests + for insert to authenticated with check ((is_admin() OR (EXISTS ( SELECT 1 + FROM bookings + WHERE ((bookings.id = cancellation_requests.booking_id) AND is_body_leadership(bookings.body_id)))))); + +drop policy if exists "requests_select_admin_or_member" on public.room_requests; +create policy requests_select_admin_or_member on public.room_requests + for select to authenticated using ((is_admin() OR is_body_member(body_id))); + +drop policy if exists "requests_insert_admin_or_leadership" on public.room_requests; +create policy requests_insert_admin_or_leadership on public.room_requests + for insert to authenticated with check ((is_admin() OR is_body_leadership(body_id))); + +drop policy if exists "request_details_select_admin_or_member" on public.room_request_details; +create policy request_details_select_admin_or_member on public.room_request_details + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM room_requests + WHERE ((room_requests.id = room_request_details.request_id) AND is_body_member(room_requests.body_id)))))); + +drop policy if exists "request_details_insert_admin_or_leadership" on public.room_request_details; +create policy request_details_insert_admin_or_leadership on public.room_request_details + for insert to authenticated with check ((is_admin() OR (EXISTS ( SELECT 1 + FROM room_requests + WHERE ((room_requests.id = room_request_details.request_id) AND is_body_leadership(room_requests.body_id)))))); + +drop policy if exists "tabling_req_sessions_select_admin_or_member" on public.tabling_request_sessions; +create policy tabling_req_sessions_select_admin_or_member on public.tabling_request_sessions + for select to authenticated using ((is_admin() OR (EXISTS ( SELECT 1 + FROM room_requests + WHERE ((room_requests.id = tabling_request_sessions.request_id) AND is_body_member(room_requests.body_id)))))); + +drop policy if exists "tabling_req_sessions_insert_admin_or_leadership" on public.tabling_request_sessions; +create policy tabling_req_sessions_insert_admin_or_leadership on public.tabling_request_sessions + for insert to authenticated with check ((is_admin() OR (EXISTS ( SELECT 1 + FROM room_requests + WHERE ((room_requests.id = tabling_request_sessions.request_id) AND is_body_leadership(room_requests.body_id)))))); + +-- --------------------------------------------------------------------------- +-- 2. Drop the helper functions added by the RLS migration +-- --------------------------------------------------------------------------- +-- The pinned search_path on is_admin / is_body_member / is_body_leadership is deliberately NOT +-- reverted: it is a security fix, independent of this feature, and reverting it would reintroduce +-- the function_search_path_mutable finding. + +drop function if exists public.booking_is_visible(uuid); +drop function if exists public.booking_is_manageable(uuid); +drop function if exists public.booking_body_is_member(uuid); +drop function if exists public.booking_body_is_leadership(uuid); +drop function if exists public.weekly_booking_is_visible(uuid); +drop function if exists public.tabling_booking_is_visible(uuid); +drop function if exists public.request_is_visible(uuid); +drop function if exists public.request_body_is_member(uuid); +drop function if exists public.request_owner_is_leadership(uuid); +drop function if exists public.my_body_ids(); +drop function if exists public.my_leadership_body_ids(); +drop function if exists public.my_divisions(); +drop function if exists public.my_leadership_divisions(); + +-- --------------------------------------------------------------------------- +-- 3. Drop the schema additions +-- --------------------------------------------------------------------------- +-- DESTRUCTIVE: this discards which bodies every multi-body booking was shared with. If any +-- non-single bookings exist, export them before running this section: +-- select b.id, b.scope, b.division, array_agg(bb.body_id) from bookings b +-- left join booking_bodies bb on bb.booking_id = b.id +-- where b.scope <> 'single' group by b.id; + +drop table if exists public.booking_bodies; +drop table if exists public.room_request_bodies; + +drop index if exists public.bookings_divisional_idx; +drop index if exists public.room_requests_divisional_idx; + +alter table public.bookings + drop constraint if exists bookings_scope_check, + drop constraint if exists bookings_division_check, + drop constraint if exists bookings_division_required_check, + drop column if exists scope, + drop column if exists division; + +alter table public.room_requests + drop constraint if exists room_requests_scope_check, + drop constraint if exists room_requests_division_check, + drop constraint if exists room_requests_division_required_check, + drop column if exists scope, + drop column if exists division; + +-- body_id was NOT NULL-able before this feature only incidentally (it had no nulls). Restoring +-- nullability is optional; uncomment if you want the exact prior column definition back. +-- alter table public.bookings alter column body_id drop not null; +-- alter table public.room_requests alter column body_id drop not null; + +commit; From 08a7c88497cc66e3a2630bedc712dfcf44e90ca6 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 22:17:31 -0400 Subject: [PATCH 2/6] Resolve booking permissions across a booking's full scope (#19) 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 --- .../administrator/bookings/one-time/route.ts | 88 +++- app/api/administrator/bookings/route.ts | 9 +- .../administrator/bookings/tabling/route.ts | 88 +++- .../administrator/bookings/weekly/route.ts | 88 +++- app/api/cancellation-requests/route.ts | 26 +- app/api/my-rooms/route.ts | 185 ++++--- app/api/request/route.ts | 72 ++- app/api/revision-requests/route.ts | 24 +- lib/booking-scope.ts | 497 ++++++++++++++++++ 9 files changed, 865 insertions(+), 212 deletions(-) create mode 100644 lib/booking-scope.ts diff --git a/app/api/administrator/bookings/one-time/route.ts b/app/api/administrator/bookings/one-time/route.ts index 0d2e0e8..29fa964 100644 --- a/app/api/administrator/bookings/one-time/route.ts +++ b/app/api/administrator/bookings/one-time/route.ts @@ -6,6 +6,13 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' import { waitUntil } from '@vercel/functions' +import { + loadScopeContext, + validateScopeSelection, + resolveBookingRecipients, + syncBookingBodies, + type ScopedRow, +} from '@/lib/booking-scope' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -32,7 +39,7 @@ export async function POST(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { body_id, purpose, sessions, semester_id } = await request.json() + const { body_id, purpose, sessions, semester_id, scope, division, body_ids } = await request.json() const { data: semester } = await adminSupabase .from('semesters') @@ -44,6 +51,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid semester.' }, { status: 400 }) } + const ctx = await loadScopeContext(supabase, user) + const selection = validateScopeSelection(ctx, { scope, body_id, division, body_ids }) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 400 }) + // admin_role is already a claim on the verified JWT, so this no longer needs // a round trip to the users table. const creatorRole = user.app_metadata?.admin_role ?? null @@ -51,12 +62,26 @@ export async function POST(request: Request) { // Create parent booking const { data: booking, error: bookingError } = await adminSupabase .from('bookings') - .insert({ body_id, purpose, type: 'One-Time Room', created_by: user.id, creator_role: creatorRole, semester_id }) + .insert({ + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + purpose, + type: 'One-Time Room', + created_by: user.id, + creator_role: creatorRole, + semester_id, + }) .select() .single() if (bookingError) return NextResponse.json({ error: bookingError.message }, { status: 500 }) + const { error: bodiesError } = await syncBookingBodies( + adminSupabase, booking.id, selection.value.scope, selection.value.body_ids + ) + if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) + // Create one-time room booking rows const sessionRows = sessions.map((s: OneTimeSession) => ({ booking_id: booking.id, @@ -88,15 +113,30 @@ export async function PATCH(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { booking_id, body_id, purpose, sessions } = await request.json() + const { booking_id, body_id, purpose, sessions, scope, division, body_ids } = await request.json() + + const ctx = await loadScopeContext(supabase, user) + const selection = validateScopeSelection(ctx, { scope, body_id, division, body_ids }) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 400 }) const { error: bookingError } = await adminSupabase .from('bookings') - .update({ body_id, purpose }) + .update({ + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + purpose, + }) .eq('id', booking_id) if (bookingError) return NextResponse.json({ error: bookingError.message }, { status: 500 }) + // Clears the join rows when the scope moved away from 'multi'. + const { error: bodiesError } = await syncBookingBodies( + adminSupabase, booking_id, selection.value.scope, selection.value.body_ids + ) + if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) + // Delete existing session rows and reinsert const { error: deleteError } = await adminSupabase .from('one_time_room_bookings') @@ -132,19 +172,24 @@ export async function PATCH(request: Request) { const { data: bodyData } = await adminSupabase .from('bodies') .select('name') - .eq('id', body_id) + .eq('id', selection.value.body_id) .single() const bodyName = bodyData?.name ?? 'Unknown' - const { data: members } = await adminSupabase - .from('board_memberships') - .select('user_id, users(email, is_active)') - .eq('body_id', body_id) + // The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for + // the divisional/multi fan-out policy. + const scopedRow: ScopedRow = { + id: booking_id, + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + } + const recipients = await resolveBookingRecipients(adminSupabase, scopedRow) - if (members?.length && auditLog) { + if (recipients.length && auditLog) { await adminSupabase.from('user_alerts').insert( - members.map((m: { user_id: string }) => ({ - user_id: m.user_id, + recipients.map(r => ({ + user_id: r.userId, audit_log_id: auditLog.id, booking_id, booking_type: 'One-Time Room', @@ -159,11 +204,7 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const emails = (members ?? []) - .flatMap((m: { users: { email: string; is_active: boolean } | { email: string; is_active: boolean }[] | null }) => - Array.isArray(m.users) ? m.users.filter(u => u.is_active).map(u => u.email) : m.users?.is_active ? [m.users.email] : [] - ) - .filter(Boolean) as string[] + const emails = recipients.map(r => r.email) await sendBookingUpdatedEmail({ bodyName, roomOrTable: firstSession.room_name || 'N/A', @@ -190,15 +231,10 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const { data: leaders } = await adminSupabase - .from('board_memberships') - .select('users(full_name, is_active)') - .eq('body_id', body_id) - .eq('role', 'Leadership') - - const contacts = (leaders ?? []) - .flatMap((l: { users: { full_name: string; is_active: boolean }[] }) => l.users.filter(u => u.is_active).map(u => u.full_name)) - .filter(Boolean) as string[] + const leaders = await resolveBookingRecipients(adminSupabase, scopedRow, { + leadershipOnly: true, + }) + const contacts = leaders.map(l => l.fullName).filter(Boolean) await sendMissedReservationEmail({ bodyName, diff --git a/app/api/administrator/bookings/route.ts b/app/api/administrator/bookings/route.ts index dfe359a..c5f843a 100644 --- a/app/api/administrator/bookings/route.ts +++ b/app/api/administrator/bookings/route.ts @@ -38,8 +38,9 @@ export async function GET(request: Request) { let oneTimeQ = supabase .from('bookings') .select(` - id, purpose, body_id, is_event, hidden, + id, purpose, body_id, is_event, hidden, scope, division, bodies(name), + booking_bodies(body_id, bodies(name)), creator_role, one_time_room_bookings(id, room_name, booking_date, start_time, end_time, status, reservation_code) `) @@ -50,8 +51,9 @@ export async function GET(request: Request) { let weeklyQ = supabase .from('bookings') .select(` - id, purpose, body_id, is_event, hidden, + id, purpose, body_id, is_event, hidden, scope, division, bodies(name), + booking_bodies(body_id, bodies(name)), creator_role, weekly_room_bookings(id, room_name, start_date, end_date, start_time, end_time, status, reservation_code, weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type) @@ -64,8 +66,9 @@ export async function GET(request: Request) { let tablingQ = supabase .from('bookings') .select(` - id, purpose, body_id, is_event, hidden, + id, purpose, body_id, is_event, hidden, scope, division, bodies(name), + booking_bodies(body_id, bodies(name)), creator_role, tabling_bookings(id, reservation_code, tabling_sessions(id, location, session_date, start_time, end_time, status, reservation_code) diff --git a/app/api/administrator/bookings/tabling/route.ts b/app/api/administrator/bookings/tabling/route.ts index bd09cf7..94fcff9 100644 --- a/app/api/administrator/bookings/tabling/route.ts +++ b/app/api/administrator/bookings/tabling/route.ts @@ -6,6 +6,13 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' import { waitUntil } from '@vercel/functions' +import { + loadScopeContext, + validateScopeSelection, + resolveBookingRecipients, + syncBookingBodies, + type ScopedRow, +} from '@/lib/booking-scope' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -23,7 +30,7 @@ export async function POST(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { body_id, purpose, reservation_code, sessions, semester_id } = await request.json() + const { body_id, purpose, reservation_code, sessions, semester_id, scope, division, body_ids } = await request.json() const { data: semester } = await adminSupabase .from('semesters') @@ -35,6 +42,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid semester.' }, { status: 400 }) } + const ctx = await loadScopeContext(supabase, user) + const selection = validateScopeSelection(ctx, { scope, body_id, division, body_ids }) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 400 }) + // admin_role is already a claim on the verified JWT, so this no longer needs // a round trip to the users table. const creatorRole = user.app_metadata?.admin_role ?? null @@ -42,12 +53,26 @@ export async function POST(request: Request) { // Create parent booking const { data: booking, error: bookingError } = await adminSupabase .from('bookings') - .insert({ body_id, purpose, type: 'Tabling', created_by: user.id, creator_role: creatorRole, semester_id }) + .insert({ + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + purpose, + type: 'Tabling', + created_by: user.id, + creator_role: creatorRole, + semester_id, + }) .select() .single() if (bookingError) return NextResponse.json({ error: bookingError.message }, { status: 500 }) + const { error: bodiesError } = await syncBookingBodies( + adminSupabase, booking.id, selection.value.scope, selection.value.body_ids + ) + if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) + // Create tabling booking const { data: tabling, error: tablingError } = await adminSupabase .from('tabling_bookings') @@ -104,16 +129,31 @@ export async function PATCH(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { booking_id, tabling_id, body_id, purpose, reservation_code, sessions } = await request.json() + const { booking_id, tabling_id, body_id, purpose, reservation_code, sessions, scope, division, body_ids } = await request.json() + + const ctx = await loadScopeContext(supabase, user) + const selection = validateScopeSelection(ctx, { scope, body_id, division, body_ids }) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 400 }) // Update parent booking const { error: bookingError } = await adminSupabase .from('bookings') - .update({ body_id, purpose }) + .update({ + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + purpose, + }) .eq('id', booking_id) if (bookingError) return NextResponse.json({ error: bookingError.message }, { status: 500 }) + // Clears the join rows when the scope moved away from 'multi'. + const { error: bodiesError } = await syncBookingBodies( + adminSupabase, booking_id, selection.value.scope, selection.value.body_ids + ) + if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) + // Update tabling booking const { error: tablingError } = await adminSupabase .from('tabling_bookings') @@ -154,19 +194,24 @@ export async function PATCH(request: Request) { const { data: bodyData } = await adminSupabase .from('bodies') .select('name') - .eq('id', body_id) + .eq('id', selection.value.body_id) .single() const bodyName = bodyData?.name ?? 'Unknown' - const { data: members } = await adminSupabase - .from('board_memberships') - .select('user_id, users(email, is_active)') - .eq('body_id', body_id) + // The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for + // the divisional/multi fan-out policy. + const scopedRow: ScopedRow = { + id: booking_id, + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + } + const recipients = await resolveBookingRecipients(adminSupabase, scopedRow) - if (members?.length && auditLog) { + if (recipients.length && auditLog) { await adminSupabase.from('user_alerts').insert( - members.map((m: { user_id: string }) => ({ - user_id: m.user_id, + recipients.map(r => ({ + user_id: r.userId, audit_log_id: auditLog.id, booking_id, booking_type: 'Tabling', @@ -181,11 +226,7 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const emails = (members ?? []) - .flatMap((m: { users: { email: string; is_active: boolean } | { email: string; is_active: boolean }[] | null }) => - Array.isArray(m.users) ? m.users.filter(u => u.is_active).map(u => u.email) : m.users?.is_active ? [m.users.email] : [] - ) - .filter(Boolean) as string[] + const emails = recipients.map(r => r.email) await sendBookingUpdatedEmail({ bodyName, roomOrTable: sessions[0]?.location || 'N/A', @@ -212,15 +253,10 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const { data: leaders } = await adminSupabase - .from('board_memberships') - .select('users(full_name, is_active)') - .eq('body_id', body_id) - .eq('role', 'Leadership') - - const contacts = (leaders ?? []) - .flatMap((l: { users: { full_name: string; is_active: boolean }[] }) => l.users.filter(u => u.is_active).map(u => u.full_name)) - .filter(Boolean) as string[] + const leaders = await resolveBookingRecipients(adminSupabase, scopedRow, { + leadershipOnly: true, + }) + const contacts = leaders.map(l => l.fullName).filter(Boolean) await sendMissedReservationEmail({ bodyName, diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 51e0fe6..5a9842e 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/route.ts @@ -6,6 +6,13 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' import { waitUntil } from '@vercel/functions' +import { + loadScopeContext, + validateScopeSelection, + resolveBookingRecipients, + syncBookingBodies, + type ScopedRow, +} from '@/lib/booking-scope' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -36,7 +43,7 @@ export async function POST(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { body_id, purpose, room_name, start_date, end_date, start_time, end_time, reservation_code, status, semester_id } = await request.json() + const { body_id, purpose, room_name, start_date, end_date, start_time, end_time, reservation_code, status, semester_id, scope, division, body_ids } = await request.json() const { data: semester } = await adminSupabase .from('semesters') @@ -48,6 +55,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid semester.' }, { status: 400 }) } + const ctx = await loadScopeContext(supabase, user) + const selection = validateScopeSelection(ctx, { scope, body_id, division, body_ids }) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 400 }) + // admin_role is already a claim on the verified JWT, so this no longer needs // a round trip to the users table. const creatorRole = user.app_metadata?.admin_role ?? null @@ -55,12 +66,26 @@ export async function POST(request: Request) { // Create parent booking const { data: booking, error: bookingError } = await adminSupabase .from('bookings') - .insert({ body_id, purpose, type: 'Weekly Room', created_by: user.id, creator_role: creatorRole, semester_id }) + .insert({ + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + purpose, + type: 'Weekly Room', + created_by: user.id, + creator_role: creatorRole, + semester_id, + }) .select() .single() if (bookingError) return NextResponse.json({ error: bookingError.message }, { status: 500 }) + const { error: bodiesError } = await syncBookingBodies( + adminSupabase, booking.id, selection.value.scope, selection.value.body_ids + ) + if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) + // Create weekly room booking const { data: weekly, error: weeklyError } = await adminSupabase .from('weekly_room_bookings') @@ -97,16 +122,31 @@ export async function PATCH(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { booking_id, weekly_id, body_id, purpose, room_name, start_date, end_date, start_time, end_time, reservation_code, status, occurrences } = await request.json() + const { booking_id, weekly_id, body_id, purpose, room_name, start_date, end_date, start_time, end_time, reservation_code, status, occurrences, scope, division, body_ids } = await request.json() + + const ctx = await loadScopeContext(supabase, user) + const selection = validateScopeSelection(ctx, { scope, body_id, division, body_ids }) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 400 }) // Update parent booking const { error: bookingError } = await adminSupabase .from('bookings') - .update({ body_id, purpose }) + .update({ + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + purpose, + }) .eq('id', booking_id) if (bookingError) return NextResponse.json({ error: bookingError.message }, { status: 500 }) + // Clears the join rows when the scope moved away from 'multi'. + const { error: bodiesError } = await syncBookingBodies( + adminSupabase, booking_id, selection.value.scope, selection.value.body_ids + ) + if (bodiesError) return NextResponse.json({ error: bodiesError }, { status: 500 }) + // Update weekly booking base fields const { error: weeklyError } = await adminSupabase .from('weekly_room_bookings') @@ -151,22 +191,27 @@ export async function PATCH(request: Request) { const { data: bodyData } = await adminSupabase .from('bodies') .select('name') - .eq('id', body_id) + .eq('id', selection.value.body_id) .single() const bodyName = bodyData?.name ?? 'Unknown' - const { data: members } = await adminSupabase - .from('board_memberships') - .select('user_id, users(email, is_active)') - .eq('body_id', body_id) + // The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for + // the divisional/multi fan-out policy. + const scopedRow: ScopedRow = { + id: booking_id, + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, + } + const recipients = await resolveBookingRecipients(adminSupabase, scopedRow) - if (members?.length && auditLog) { + if (recipients.length && auditLog) { const changedOcc = newOccurrences.find( o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code ) ?? newOccurrences[0] await adminSupabase.from('user_alerts').insert( - members.map((m: { user_id: string }) => ({ - user_id: m.user_id, + recipients.map(r => ({ + user_id: r.userId, audit_log_id: auditLog.id, booking_id, booking_type: 'Weekly Room', @@ -181,11 +226,7 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const emails = (members ?? []) - .flatMap((m: { users: { email: string; is_active: boolean } | { email: string; is_active: boolean }[] | null }) => - Array.isArray(m.users) ? m.users.filter(u => u.is_active).map(u => u.email) : m.users?.is_active ? [m.users.email] : [] - ) - .filter(Boolean) as string[] + const emails = recipients.map(r => r.email) await sendBookingUpdatedEmail({ bodyName, roomOrTable: room_name || 'N/A', @@ -213,15 +254,10 @@ export async function PATCH(request: Request) { waitUntil( (async () => { try { - const { data: leaders } = await adminSupabase - .from('board_memberships') - .select('users(full_name, is_active)') - .eq('body_id', body_id) - .eq('role', 'Leadership') - - const contacts = (leaders ?? []) - .flatMap((l: { users: { full_name: string; is_active: boolean }[] }) => l.users.filter(u => u.is_active).map(u => u.full_name)) - .filter(Boolean) as string[] + const leaders = await resolveBookingRecipients(adminSupabase, scopedRow, { + leadershipOnly: true, + }) + const contacts = leaders.map(l => l.fullName).filter(Boolean) await sendMissedReservationEmail({ bodyName, diff --git a/app/api/cancellation-requests/route.ts b/app/api/cancellation-requests/route.ts index f553523..df0b0d0 100644 --- a/app/api/cancellation-requests/route.ts +++ b/app/api/cancellation-requests/route.ts @@ -3,6 +3,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' +import { requireBookingManager } from '@/lib/booking-scope' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -20,27 +21,12 @@ export async function POST(request: Request) { const { booking_id, occurrence_id, scope, cancellation_type = 'Cancellation' } = await request.json() - const { data: bookingRow } = await adminSupabase - .from('bookings') - .select('body_id, type') - .eq('id', booking_id) - .single() + // Leadership of any body the booking is scoped to may request a cancellation -- for a divisional + // or multi booking that is wider than the owning body. + const guard = await requireBookingManager(supabase, adminSupabase, user, booking_id) + if (guard.error) return guard.error - if (!bookingRow) return NextResponse.json({ error: 'Booking not found' }, { status: 404 }) - - if (!user.app_metadata?.is_admin) { - const { data: membership } = await supabase - .from('board_memberships') - .select('id') - .eq('user_id', user.id) - .eq('body_id', bookingRow.body_id) - .eq('role', 'Leadership') - .maybeSingle() - - if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const bookingType = bookingRow.type + const bookingType = guard.row.type // Create cancellation request const { error: requestError } = await adminSupabase diff --git a/app/api/my-rooms/route.ts b/app/api/my-rooms/route.ts index 7d33861..4075956 100644 --- a/app/api/my-rooms/route.ts +++ b/app/api/my-rooms/route.ts @@ -2,6 +2,20 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' +import { canManageScoped, loadScopeContext, type ScopedRow } from '@/lib/booking-scope' + +interface BookingRow { + id: string + purpose: string + body_id: string + hidden: boolean + scope: ScopedRow['scope'] + division: ScopedRow['division'] + bodies: { name: string } | null + booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null +} + +const SELECT_BASE = 'id, purpose, body_id, hidden, scope, division, bodies(name), booking_bodies(body_id, bodies(name))' export async function GET() { const supabase = await createClient() @@ -12,91 +26,120 @@ export async function GET() { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const isAdmin = !!user.app_metadata?.is_admin - - // Get user's body memberships and active semester in parallel - const [{ data: memberships }, { data: activeSemester }] = await Promise.all([ - supabase - .from('board_memberships') - .select('body_id, role') - .eq('user_id', user.id), - supabase - .from('semesters') - .select('id') - .eq('is_active', true) - .single(), + // The scope context and the active semester are independent, so fetch both at once. + const [ctx, { data: activeSemester }] = await Promise.all([ + loadScopeContext(supabase, user), + supabase.from('semesters').select('id').eq('is_active', true).single(), ]) - if (!memberships || memberships.length === 0) { - return NextResponse.json({ bookings: [], leadershipBodyIds: [] }) + if (ctx.bodyIds.length === 0) { + return NextResponse.json({ + oneTimeBookings: [], + weeklyBookings: [], + tablingBookings: [], + }) } - const bodyIds = memberships.map(m => m.body_id) - const leadershipBodyIds = new Set( - memberships.filter(m => m.role === 'Leadership').map(m => m.body_id) - ) - if (!activeSemester) { return NextResponse.json({ oneTimeBookings: [], weeklyBookings: [], tablingBookings: [], - leadershipBodyIds: [...leadershipBodyIds], }) } - // Fetch all booking types in parallel - const [{ data: oneTimeBookings }, { data: weeklyBookings }, { data: tablingBookings }] = await Promise.all([ - // Fetch one-time room bookings - supabase - .from('bookings') - .select(` - id, purpose, body_id, hidden, - bodies(name), - one_time_room_bookings(id, room_name, booking_date, start_time, end_time, status, reservation_code) - `) - .eq('type', 'One-Time Room') - .eq('semester_id', activeSemester.id) - .in('body_id', bodyIds), - // Fetch weekly room bookings with occurrences - supabase - .from('bookings') - .select(` - id, purpose, body_id, hidden, - bodies(name), - weekly_room_bookings(id, room_name, start_date, end_date, start_time, end_time, status, reservation_code, - weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type) - ) - `) - .eq('type', 'Weekly Room') - .eq('semester_id', activeSemester.id) - .in('body_id', bodyIds), - // Fetch tabling bookings with sessions - supabase - .from('bookings') - .select(` - id, purpose, body_id, hidden, - bodies(name), - tabling_bookings(id, reservation_code, - tabling_sessions(id, location, session_date, start_time, end_time, status, reservation_code) - ) - `) - .eq('type', 'Tabling') - .eq('semester_id', activeSemester.id) - .in('body_id', bodyIds), + // This page is deliberately "the bookings of the bodies I belong to", so it filters by + // membership even for admins -- who would otherwise pass RLS for every booking in the system. + // + // A single `.in('body_id', ...)` can no longer express that: a booking also reaches this user if + // it is divisional in one of their divisions, or multi and lists one of their bodies. PostgREST + // has no subquery syntax, so those two paths are resolved to ids first and folded into one + // `.or()`. Note `.in('division', ...)` is used rather than an inline `division.in.(...)` filter + // string, because division values contain spaces and would need manual quoting. + const [{ data: linked }, { data: divisional }] = await Promise.all([ + supabase.from('booking_bodies').select('booking_id').in('body_id', ctx.bodyIds), + ctx.divisions.length + ? supabase + .from('bookings') + .select('id') + .eq('scope', 'divisional') + .in('division', ctx.divisions) + .eq('semester_id', activeSemester.id) + : Promise.resolve({ data: [] as { id: string }[] }), ]) - // Admins see everything; others are filtered out of hidden bookings unless they hold Leadership in that body - const visible = isAdmin - ? (rows: T[]) => rows - : (rows: T[]) => - rows.filter(b => !b.hidden || leadershipBodyIds.has(b.body_id)) + const extraIds = [ + ...new Set([ + ...(linked ?? []).map((r: { booking_id: string }) => r.booking_id), + ...(divisional ?? []).map((r: { id: string }) => r.id), + ]), + ] + + // The `.or()` string embeds UUIDs into a GET URL. The semester filter keeps this bounded today; + // if extraIds ever exceeds ~150, move this query to an RPC before it hits proxy URL limits. + const orFilter = extraIds.length + ? `body_id.in.(${ctx.bodyIds.join(',')}),id.in.(${extraIds.join(',')})` + : null + + let oneTimeQ = supabase + .from('bookings') + .select(` + ${SELECT_BASE}, + one_time_room_bookings(id, room_name, booking_date, start_time, end_time, status, reservation_code) + `) + .eq('type', 'One-Time Room') + .eq('semester_id', activeSemester.id) + oneTimeQ = orFilter ? oneTimeQ.or(orFilter) : oneTimeQ.in('body_id', ctx.bodyIds) + + let weeklyQ = supabase + .from('bookings') + .select(` + ${SELECT_BASE}, + weekly_room_bookings(id, room_name, start_date, end_date, start_time, end_time, status, reservation_code, + weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type) + ) + `) + .eq('type', 'Weekly Room') + .eq('semester_id', activeSemester.id) + weeklyQ = orFilter ? weeklyQ.or(orFilter) : weeklyQ.in('body_id', ctx.bodyIds) + + let tablingQ = supabase + .from('bookings') + .select(` + ${SELECT_BASE}, + tabling_bookings(id, reservation_code, + tabling_sessions(id, location, session_date, start_time, end_time, status, reservation_code) + ) + `) + .eq('type', 'Tabling') + .eq('semester_id', activeSemester.id) + tablingQ = orFilter ? tablingQ.or(orFilter) : tablingQ.in('body_id', ctx.bodyIds) + + const [{ data: oneTimeBookings }, { data: weeklyBookings }, { data: tablingBookings }] = + await Promise.all([oneTimeQ, weeklyQ, tablingQ]) + + /** + * Decorates each row with canManage, computed server-side across the full scope. The client used + * to derive this from a flat leadershipBodyIds list, which only ever worked for single-body + * bookings. + * + * Hidden bookings stay visible only to someone who can manage them. + */ + const decorate = (rows: BookingRow[] | null) => + (rows ?? []) + .map(b => ({ + ...b, + canManage: canManageScoped( + ctx, + b, + (b.booking_bodies ?? []).map(x => x.body_id) + ), + })) + .filter(b => !b.hidden || b.canManage) return NextResponse.json({ - oneTimeBookings: visible(oneTimeBookings || []), - weeklyBookings: visible(weeklyBookings || []), - tablingBookings: visible(tablingBookings || []), - // Returned so the client doesn't have to re-query board_memberships itself. - leadershipBodyIds: [...leadershipBodyIds], + oneTimeBookings: decorate(oneTimeBookings as BookingRow[] | null), + weeklyBookings: decorate(weeklyBookings as BookingRow[] | null), + tablingBookings: decorate(tablingBookings as BookingRow[] | null), }) -} \ No newline at end of file +} diff --git a/app/api/request/route.ts b/app/api/request/route.ts index 8a49b6c..9e65b2a 100644 --- a/app/api/request/route.ts +++ b/app/api/request/route.ts @@ -3,6 +3,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' +import { DIVISIONS, loadScopeContext, validateScopeSelection } from '@/lib/booking-scope' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -22,7 +23,11 @@ export async function GET() { // The settings row is independent of the bodies lookup, so fetch both at once // rather than gating the bodies query behind it. - const [{ data: settings }, bodiesResult] = await Promise.all([ + // + // `allBodies` is the pool for a multi-body request -- any leadership may request a multi-body + // booking with any combination, so everyone gets the full active list. That leaks nothing: + // bodies_select_authenticated is already USING (true) for any authenticated user. + const [{ data: settings }, bodiesResult, { data: allBodies }] = await Promise.all([ supabase .from('app_settings') .select('min_days_advance_room, min_days_advance_tabling') @@ -32,15 +37,20 @@ export async function GET() { ? // Admins get every active body supabase .from('bodies') - .select('id, name') + .select('id, name, division') .eq('is_active', true) .order('name', { ascending: true }) : // Everyone else gets the bodies where they hold Leadership supabase .from('board_memberships') - .select('body_id, bodies(id, name)') + .select('body_id, bodies(id, name, division)') .eq('user_id', user.id) .eq('role', 'Leadership'), + supabase + .from('bodies') + .select('id, name, division') + .eq('is_active', true) + .order('name', { ascending: true }), ]) const minDaysRoom = settings?.min_days_advance_room ?? 0 @@ -52,7 +62,17 @@ export async function GET() { .map(m => m.bodies) .filter(Boolean) - return NextResponse.json({ bodies, minDaysRoom, minDaysTabling }) + // Leadership may only request a divisional booking for a division they actually lead. + const ctx = await loadScopeContext(supabase, user) + const leadershipDivisions = isAdmin ? [...DIVISIONS] : ctx.leadershipDivisions + + return NextResponse.json({ + bodies, + allBodies: allBodies ?? [], + leadershipDivisions, + minDaysRoom, + minDaysTabling, + }) } export async function POST(request: Request) { @@ -65,22 +85,22 @@ export async function POST(request: Request) { if (rateLimitRes) return rateLimitRes const body = await request.json() - const { type, body_id, purpose, notes, details, sessions } = body - - // Verify user has Leadership role in the submitted body_id (skip for admins) - if (!user.app_metadata?.is_admin) { - const { data: membership } = await supabase - .from('board_memberships') - .select('id') - .eq('user_id', user.id) - .eq('body_id', body_id) - .eq('role', 'Leadership') - .maybeSingle() - - if (!membership) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } - } + const { type, body_id, purpose, notes, details, sessions, scope, division, body_ids } = body + + // Verifies Leadership on the originating body, and -- for a divisional request -- that the user + // actually leads that division. Any leadership may request a multi-body booking with any + // combination of bodies, so there is no restriction on the non-owning bodies. + const ctx = await loadScopeContext(supabase, user) + const { data: activeBodies } = await adminSupabase + .from('bodies') + .select('id') + .eq('is_active', true) + const validBodyIds = (activeBodies ?? []).map((b: { id: string }) => b.id) + + const selection = validateScopeSelection( + ctx, { scope, body_id, division, body_ids }, validBodyIds + ) + if (!selection.ok) return NextResponse.json({ error: selection.error }, { status: 403 }) // Fetch advance notice settings and validate dates const { data: settings } = await adminSupabase @@ -137,7 +157,9 @@ export async function POST(request: Request) { .from('room_requests') .insert({ type, - body_id, + body_id: selection.value.body_id, + scope: selection.value.scope, + division: selection.value.division, purpose, notes: notes || null, requested_by: user.id, @@ -148,6 +170,14 @@ export async function POST(request: Request) { if (requestError) return NextResponse.json({ error: requestError.message }, { status: 500 }) + if (selection.value.scope === 'multi') { + const { error: bodiesError } = await adminSupabase + .from('room_request_bodies') + .insert(selection.value.body_ids.map(bid => ({ request_id: roomRequest.id, body_id: bid }))) + + if (bodiesError) return NextResponse.json({ error: bodiesError.message }, { status: 500 }) + } + // Insert type-specific details if (type === 'One-Time Room') { const sessionRows = sessions.map((s: { diff --git a/app/api/revision-requests/route.ts b/app/api/revision-requests/route.ts index c9b905e..0a226ff 100644 --- a/app/api/revision-requests/route.ts +++ b/app/api/revision-requests/route.ts @@ -3,6 +3,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUser } from '@/lib/auth' +import { requireBookingManager } from '@/lib/booking-scope' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -20,25 +21,10 @@ export async function POST(request: Request) { const { booking_id, change_type, new_start_time, new_end_time, new_room, more_info } = await request.json() - const { data: bookingRow } = await adminSupabase - .from('bookings') - .select('body_id') - .eq('id', booking_id) - .single() - - if (!bookingRow) return NextResponse.json({ error: 'Booking not found' }, { status: 404 }) - - if (!user.app_metadata?.is_admin) { - const { data: membership } = await supabase - .from('board_memberships') - .select('id') - .eq('user_id', user.id) - .eq('body_id', bookingRow.body_id) - .eq('role', 'Leadership') - .maybeSingle() - - if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + // Leadership of any body the booking is scoped to may request a revision -- for a divisional or + // multi booking that is wider than the owning body. + const guard = await requireBookingManager(supabase, adminSupabase, user, booking_id) + if (guard.error) return guard.error // Block if a pending revision request already exists for this booking const { data: existing } = await adminSupabase diff --git a/lib/booking-scope.ts b/lib/booking-scope.ts new file mode 100644 index 0000000..a8e69bf --- /dev/null +++ b/lib/booking-scope.ts @@ -0,0 +1,497 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import type { AuthedUser } from './auth' + +/** + * Multi-body bookings (issue #19). + * + * A booking -- and the room_request that may precede it -- is owned by `body_id` and scoped by + * `scope`: + * + * single one body. The default, and what the overwhelming majority of bookings are. + * divisional owned by body_id, but visible to anyone with a membership in that division and + * manageable by anyone holding Leadership anywhere in that division. + * multi owned by body_id, shared with an explicit set of bodies in booking_bodies. + * + * `body_id` is populated in all three cases, so attribution for audit logs and emails never + * depends on the scope. + * + * This module is the single home for that rule on the app side. The equivalent rule in SQL lives + * in supabase/migrations/20260826001000_multi_body_bookings_rls.sql (booking_is_visible / + * booking_is_manageable). The two must agree; if you change one, change the other. + * + * Note that RLS only guards *reads* in this app -- every booking and request write goes through + * the service-role client, which bypasses RLS entirely. The guards here are the real write + * authorization. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Mirrors bodies_division_check / bookings_division_check in the database. Kept here so the UI has + * one import rather than its own copy (bodies-tab.tsx used to declare this inline). + */ +export const DIVISIONS = [ + 'Office of the President', + 'Academic Affairs', + 'Campus Affairs', + 'DEI', + 'Student Success', + 'Operational Affairs', + 'External Affairs', + 'Student Involvement', + 'Senate', + 'Non-Divisional', +] as const + +export type Division = (typeof DIVISIONS)[number] + +/** + * Deliberately named BookingScope rather than Scope: cancellation_requests already has a `scope` + * column with values 'occurrence' | 'series', and the two collide in any shared row type. + */ +export type BookingScope = 'single' | 'divisional' | 'multi' + +export interface BodyRef { + id: string + name: string + division: Division +} + +/** The scope-bearing columns shared by `bookings` and `room_requests`. */ +export interface ScopedRow { + id: string + body_id: string + scope: BookingScope + division: Division | null +} + +/** Everything about the caller needed to decide visibility and manageability. */ +export interface ScopeContext { + isAdmin: boolean + bodyIds: string[] + leadershipBodyIds: string[] + divisions: Division[] + leadershipDivisions: Division[] +} + +export function isBookingScope(v: unknown): v is BookingScope { + return v === 'single' || v === 'divisional' || v === 'multi' +} + +export function isDivision(v: unknown): v is Division { + return typeof v === 'string' && (DIVISIONS as readonly string[]).includes(v) +} + +// --------------------------------------------------------------------------- +// Loading the caller's context +// --------------------------------------------------------------------------- + +interface MembershipRow { + body_id: string + role: string + bodies: { division: string } | { division: string }[] | null +} + +/** PostgREST returns an embedded to-one relation as an object, but types it as a possible array. */ +function embeddedDivision(bodies: MembershipRow['bodies']): string | null { + if (!bodies) return null + const row = Array.isArray(bodies) ? bodies[0] : bodies + return row?.division ?? null +} + +/** + * One query -> the caller's full scope context. + * + * Reads through the caller's own (RLS-bound) client: memberships_select_admin_or_own already + * restricts board_memberships to the caller's own rows. + */ +export async function loadScopeContext( + supabase: SupabaseClient, + user: AuthedUser +): Promise { + const isAdmin = !!user.app_metadata?.is_admin + + const { data } = await supabase + .from('board_memberships') + .select('body_id, role, bodies(division)') + .eq('user_id', user.id) + + const memberships = (data ?? []) as MembershipRow[] + + const bodyIds: string[] = [] + const leadershipBodyIds: string[] = [] + const divisions = new Set() + const leadershipDivisions = new Set() + + for (const m of memberships) { + bodyIds.push(m.body_id) + const division = embeddedDivision(m.bodies) + if (isDivision(division)) divisions.add(division) + + if (m.role === 'Leadership') { + leadershipBodyIds.push(m.body_id) + if (isDivision(division)) leadershipDivisions.add(division) + } + } + + return { + isAdmin, + bodyIds, + leadershipBodyIds, + divisions: [...divisions], + leadershipDivisions: [...leadershipDivisions], + } +} + +// --------------------------------------------------------------------------- +// Predicates -- pure, no I/O +// --------------------------------------------------------------------------- + +/** + * `linkedBodyIds` is the booking_bodies / room_request_bodies rows for this row. It is ignored for + * non-multi scopes, so passing [] is fine when you know the row is single or divisional. + */ +export function canViewScoped( + ctx: ScopeContext, + row: ScopedRow, + linkedBodyIds: string[] = [] +): boolean { + if (ctx.isAdmin) return true + if (ctx.bodyIds.includes(row.body_id)) return true + if (row.scope === 'divisional' && row.division) return ctx.divisions.includes(row.division) + if (row.scope === 'multi') return linkedBodyIds.some(id => ctx.bodyIds.includes(id)) + return false +} + +export function canManageScoped( + ctx: ScopeContext, + row: ScopedRow, + linkedBodyIds: string[] = [] +): boolean { + if (ctx.isAdmin) return true + if (ctx.leadershipBodyIds.includes(row.body_id)) return true + if (row.scope === 'divisional' && row.division) { + return ctx.leadershipDivisions.includes(row.division) + } + if (row.scope === 'multi') { + return linkedBodyIds.some(id => ctx.leadershipBodyIds.includes(id)) + } + return false +} + +// --------------------------------------------------------------------------- +// Validating a scope selection off the wire +// --------------------------------------------------------------------------- + +export interface ScopeSelection { + scope: BookingScope + body_id: string + division: string | null + body_ids: string[] +} + +export type ScopeValidation = + | { ok: true; value: { scope: BookingScope; body_id: string; division: Division | null; body_ids: string[] } } + | { ok: false; error: string } + +/** + * Mirrors the requests_insert_admin_or_leadership WITH CHECK, plus the one rule the database + * cannot express: a multi booking needs >= 2 bodies including the owner. + * + * That rule can't be a constraint trigger because supabase-js writes the parent row and the join + * rows as two separate HTTP requests -- i.e. two transactions -- so a deferred trigger would fire + * while booking_bodies is still empty and every multi insert would fail. This is the enforcement + * point instead. + * + * `validBodyIds` is the set of active body ids; pass it to reject references to inactive or + * nonexistent bodies. + */ +export function validateScopeSelection( + ctx: ScopeContext, + input: Partial, + validBodyIds?: string[] +): ScopeValidation { + const scope = input.scope ?? 'single' + if (!isBookingScope(scope)) return { ok: false, error: 'Invalid booking scope.' } + + const body_id = input.body_id + if (!body_id) return { ok: false, error: 'A body is required.' } + + if (validBodyIds && !validBodyIds.includes(body_id)) { + return { ok: false, error: 'That body is not available.' } + } + + // Non-admins may only ever originate a booking from a body they lead. + if (!ctx.isAdmin && !ctx.leadershipBodyIds.includes(body_id)) { + return { ok: false, error: 'You do not hold Leadership in that body.' } + } + + if (scope === 'single') { + return { ok: true, value: { scope, body_id, division: null, body_ids: [] } } + } + + if (scope === 'divisional') { + const division = input.division + if (!isDivision(division)) return { ok: false, error: 'A division is required.' } + // Leadership may only request divisional bookings for divisions they actually lead. + if (!ctx.isAdmin && !ctx.leadershipDivisions.includes(division)) { + return { ok: false, error: 'You do not hold Leadership in that division.' } + } + return { ok: true, value: { scope, body_id, division, body_ids: [] } } + } + + // scope === 'multi' + const body_ids = [...new Set(input.body_ids ?? [])] + if (!body_ids.includes(body_id)) body_ids.unshift(body_id) + if (body_ids.length < 2) { + return { ok: false, error: 'A multi-body booking needs at least two bodies.' } + } + if (validBodyIds && body_ids.some(id => !validBodyIds.includes(id))) { + return { ok: false, error: 'One or more selected bodies are not available.' } + } + // Any leadership may request a multi-body booking with any combination of bodies, so there is + // deliberately no leadership check on the non-owning bodies here. + return { ok: true, value: { scope, body_id, division: null, body_ids } } +} + +// --------------------------------------------------------------------------- +// Resolution -- server side, expects a service-role client +// --------------------------------------------------------------------------- + +/** + * Every body whose members make up this booking's audience. + * + * For divisional this is a live query, not a stored set: adding a body to a division + * retroactively brings its members into the audience of past divisional bookings. That is + * intended -- the booking was made *for* the division. + */ +export async function resolveBookingBodyIds( + adminSupabase: SupabaseClient, + row: ScopedRow +): Promise { + if (row.scope === 'divisional' && row.division) { + const { data } = await adminSupabase + .from('bodies') + .select('id') + .eq('division', row.division) + .eq('is_active', true) + const ids = (data ?? []).map((b: { id: string }) => b.id) + return ids.includes(row.body_id) ? ids : [row.body_id, ...ids] + } + + if (row.scope === 'multi') { + const { data } = await adminSupabase + .from('booking_bodies') + .select('body_id') + .eq('booking_id', row.id) + const ids = (data ?? []).map((b: { body_id: string }) => b.body_id) + // The invariant says body_id is already in there; be defensive anyway. + return ids.includes(row.body_id) ? ids : [row.body_id, ...ids] + } + + return [row.body_id] +} + +/** + * Writes the booking_bodies rows for a booking to exactly `bodyIds`, clearing them for any + * non-multi scope. Delete-then-reinsert, matching how the booking routes already replace their + * session rows. + * + * Not transactional -- supabase-js issues these as separate requests. A failure between the delete + * and the insert leaves a multi booking with no join rows, which the integrity query in the schema + * migration will surface. Given admin-only, low-frequency writes that is an acceptable trade + * against moving booking creation into an RPC. + */ +export async function syncBookingBodies( + adminSupabase: SupabaseClient, + bookingId: string, + scope: BookingScope, + bodyIds: string[] +): Promise<{ error: string | null }> { + const { error: deleteError } = await adminSupabase + .from('booking_bodies') + .delete() + .eq('booking_id', bookingId) + + if (deleteError) return { error: deleteError.message } + + if (scope !== 'multi' || bodyIds.length === 0) return { error: null } + + const { error: insertError } = await adminSupabase + .from('booking_bodies') + .insert(bodyIds.map(body_id => ({ booking_id: bookingId, body_id }))) + + return { error: insertError?.message ?? null } +} + +export interface Recipient { + userId: string + email: string + fullName: string +} + +interface RecipientRow { + user_id: string + body_id: string + role: string + users: { email: string; full_name: string; is_active: boolean } | { email: string; full_name: string; is_active: boolean }[] | null +} + +/** + * The people to email / create user_alerts for when a booking changes. + * + * Fan-out policy (issue #19): + * single members of the one body -- unchanged behavior. + * multi all members of every listed body. They are explicitly chosen co-owners. + * divisional members of the owning body, plus only Leadership of the peer bodies. A division + * can be large, and mass-emailing all of it on every edit would be noise. + * + * This is the single place that policy lives; change it here and every booking route follows. + * + * `leadershipOnly` narrows to Leadership across the whole audience regardless of scope -- used for + * the missed-reservation email, which only ever went to leadership. + */ +export async function resolveBookingRecipients( + adminSupabase: SupabaseClient, + row: ScopedRow, + opts: { leadershipOnly?: boolean } = {} +): Promise { + const bodyIds = await resolveBookingBodyIds(adminSupabase, row) + if (bodyIds.length === 0) return [] + + const { data } = await adminSupabase + .from('board_memberships') + .select('user_id, body_id, role, users(email, full_name, is_active)') + .in('body_id', bodyIds) + + const rows = (data ?? []) as RecipientRow[] + const byUser = new Map() + + for (const m of rows) { + const user = Array.isArray(m.users) ? m.users[0] : m.users + if (!user?.is_active || !user.email) continue + + if (opts.leadershipOnly && m.role !== 'Leadership') continue + + // Divisional: peer bodies contribute only their leadership. + if ( + !opts.leadershipOnly && + row.scope === 'divisional' && + m.body_id !== row.body_id && + m.role !== 'Leadership' + ) { + continue + } + + if (!byUser.has(m.user_id)) { + byUser.set(m.user_id, { + userId: m.user_id, + email: user.email, + fullName: user.full_name, + }) + } + } + + return [...byUser.values()] +} + +// --------------------------------------------------------------------------- +// Route guards +// --------------------------------------------------------------------------- + +/** + * Returns a NextResponse to bail out with, or null when the caller may manage this booking. + * Follows the idiom of checkRateLimit(). + * + * Replaces the single-body Leadership check that was duplicated across the revision-request, + * cancellation-request and request routes. + */ +export async function requireBookingManager( + supabase: SupabaseClient, + adminSupabase: SupabaseClient, + user: AuthedUser, + bookingId: string +): Promise< + { error: NextResponse; row: null } | { error: null; row: ScopedRow & { type: string } } +> { + const { data } = await adminSupabase + .from('bookings') + .select('id, body_id, scope, division, type') + .eq('id', bookingId) + .single() + + if (!data) { + return { error: NextResponse.json({ error: 'Booking not found' }, { status: 404 }), row: null } + } + + const row = data as ScopedRow & { type: string } + + if (user.app_metadata?.is_admin) return { error: null, row } + + const ctx = await loadScopeContext(supabase, user) + + let linked: string[] = [] + if (row.scope === 'multi') { + const { data: bb } = await adminSupabase + .from('booking_bodies') + .select('body_id') + .eq('booking_id', row.id) + linked = (bb ?? []).map((b: { body_id: string }) => b.body_id) + } + + if (!canManageScoped(ctx, row, linked)) { + return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), row: null } + } + + return { error: null, row } +} + +// --------------------------------------------------------------------------- +// Display +// --------------------------------------------------------------------------- + +export interface ScopeLabelParts { + /** Compact form for a table cell or card heading. */ + short: string + /** Every body in the audience, owner first. One entry for divisional. */ + full: string[] +} + +/** + * single -> { short: 'DEI Committee', full: ['DEI Committee'] } + * divisional -> { short: 'Campus Affairs (Division)', full: ['Campus Affairs (Division)'] } + * multi -> { short: 'DEI Committee + 2 others', full: ['DEI Committee', ...rest] } + * + * The owning body heads the multi list so attribution stays stable no matter which bodies were + * added or in what order. + */ +export function formatScopeLabel( + row: Pick & { bodies?: { name: string } | null }, + linkedBodies: { id: string; name: string }[] = [] +): ScopeLabelParts { + const ownerName = row.bodies?.name ?? 'Unknown' + + if (row.scope === 'divisional' && row.division) { + const label = `${row.division} (Division)` + return { short: label, full: [label] } + } + + if (row.scope === 'multi') { + const others = linkedBodies + .filter(b => b.id !== row.body_id) + .map(b => b.name) + .sort((a, b) => a.localeCompare(b)) + + if (others.length === 0) return { short: ownerName, full: [ownerName] } + + return { + short: `${ownerName} + ${others.length} other${others.length === 1 ? '' : 's'}`, + full: [ownerName, ...others], + } + } + + return { short: ownerName, full: [ownerName] } +} From cf7d0288ba9032896c889943d0b0aa66756650e5 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 22:18:01 -0400 Subject: [PATCH 3/6] Add the booking scope selector to all seven booking forms (#19) BookingScopeSelector replaces the plain Body setForm({ ...form, body_id: e.target.value })} - className={inputCls} - > - - {bodies.map(b => ( - - ))} - - +
diff --git a/app/(dashboard)/administrator/edit-tabling-form.tsx b/app/(dashboard)/administrator/edit-tabling-form.tsx index 78f5586..a287006 100644 --- a/app/(dashboard)/administrator/edit-tabling-form.tsx +++ b/app/(dashboard)/administrator/edit-tabling-form.tsx @@ -2,6 +2,8 @@ import { useState } from 'react' import TimePicker from './time-picker' +import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' +import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope' const STATUSES = [ 'Reserved', @@ -20,6 +22,7 @@ const STATUSES = [ interface Body { id: string name: string + division: Division } interface Session { @@ -38,6 +41,9 @@ interface EditTablingFormProps { id: string body_id: string purpose: string + scope: BookingScope + division: Division | null + booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null tabling_bookings: { id: string reservation_code: string | null @@ -65,8 +71,14 @@ const emptySession = (): Session => ({ export default function EditTablingForm({ booking, bodies, onClose, onSuccess }: EditTablingFormProps) { const t = booking.tabling_bookings?.[0] - const [form, setForm] = useState({ + const [scopeValue, setScopeValue] = useState({ + scope: booking.scope ?? 'single', body_id: booking.body_id, + division: booking.division ?? null, + body_ids: (booking.booking_bodies ?? []).map(b => b.body_id), + }) + + const [form, setForm] = useState({ purpose: booking.purpose, reservation_code: t?.reservation_code ?? '', }) @@ -92,10 +104,18 @@ export default function EditTablingForm({ booking, bodies, onClose, onSuccess }: } const handleSubmit = async () => { - if (!form.body_id || !form.purpose) { + if (!scopeValue.body_id || !form.purpose) { setError('Please fill out all required fields.') return } + if (scopeValue.scope === 'divisional' && !scopeValue.division) { + setError('Please select a division.') + return + } + if (scopeValue.scope === 'multi' && scopeValue.body_ids.filter(id => id !== scopeValue.body_id).length === 0) { + setError('Select at least one other body for a multi-body booking.') + return + } for (const s of sessions) { if (!s.location || !s.session_date || !s.start_time || !s.end_time) { @@ -112,6 +132,7 @@ export default function EditTablingForm({ booking, bodies, onClose, onSuccess }: booking_id: booking.id, tabling_id: t.id, ...form, + ...scopeValue, sessions, }), }) @@ -128,13 +149,13 @@ export default function EditTablingForm({ booking, bodies, onClose, onSuccess }: return (
-
- - -
+
diff --git a/app/(dashboard)/administrator/edit-weekly-form.tsx b/app/(dashboard)/administrator/edit-weekly-form.tsx index bfa8394..6bc1ff3 100644 --- a/app/(dashboard)/administrator/edit-weekly-form.tsx +++ b/app/(dashboard)/administrator/edit-weekly-form.tsx @@ -2,6 +2,8 @@ import { useState } from 'react' import TimePicker from './time-picker' +import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' +import { DIVISIONS, type Division, type BookingScope } from '@/lib/booking-scope' const STATUSES = [ 'Reserved', @@ -20,6 +22,7 @@ const STATUSES = [ interface Body { id: string name: string + division: Division } interface Occurrence { @@ -38,6 +41,9 @@ interface EditWeeklyFormProps { id: string body_id: string purpose: string + scope: BookingScope + division: Division | null + booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null weekly_room_bookings: { id: string room_name: string @@ -86,8 +92,14 @@ function getWeeklyDates(startDate: string, endDate: string): string[] { export default function EditWeeklyForm({ booking, bodies, onClose, onSuccess }: EditWeeklyFormProps) { const w = booking.weekly_room_bookings?.[0] - const [form, setForm] = useState({ + const [scopeValue, setScopeValue] = useState({ + scope: booking.scope ?? 'single', body_id: booking.body_id, + division: booking.division ?? null, + body_ids: (booking.booking_bodies ?? []).map(b => b.body_id), + }) + + const [form, setForm] = useState({ purpose: booking.purpose, room_name: w?.room_name ?? '', start_date: w?.start_date ?? '', @@ -106,7 +118,8 @@ export default function EditWeeklyForm({ booking, bodies, onClose, onSuccess }: const [saving, setSaving] = useState(false) const [error, setError] = useState('') - const isSenate = bodies.find(b => b.id === form.body_id)?.name === 'Senate' + // Still keyed on the owning body, which stays populated for every scope. + const isSenate = bodies.find(b => b.id === scopeValue.body_id)?.name === 'Senate' if (!w) return null @@ -127,10 +140,18 @@ export default function EditWeeklyForm({ booking, bodies, onClose, onSuccess }: } const handleSubmit = async () => { - if (!form.body_id || !form.purpose || !form.room_name || !form.start_date || !form.end_date || !form.start_time || !form.end_time) { + if (!scopeValue.body_id || !form.purpose || !form.room_name || !form.start_date || !form.end_date || !form.start_time || !form.end_time) { setError('Please fill out all required fields.') return } + if (scopeValue.scope === 'divisional' && !scopeValue.division) { + setError('Please select a division.') + return + } + if (scopeValue.scope === 'multi' && scopeValue.body_ids.filter(id => id !== scopeValue.body_id).length === 0) { + setError('Select at least one other body for a multi-body booking.') + return + } setSaving(true) const res = await fetch('/api/administrator/bookings/weekly', { @@ -140,6 +161,7 @@ export default function EditWeeklyForm({ booking, bodies, onClose, onSuccess }: booking_id: booking.id, weekly_id: w.id, ...form, + ...scopeValue, occurrences, }), }) @@ -157,14 +179,13 @@ export default function EditWeeklyForm({ booking, bodies, onClose, onSuccess }: return (
{/* Base fields */} -
- - -
- +
setForm({ ...form, purpose: e.target.value })} className={inputCls} /> diff --git a/app/(dashboard)/administrator/one-time-form.tsx b/app/(dashboard)/administrator/one-time-form.tsx index d9c4024..bc1a3a7 100644 --- a/app/(dashboard)/administrator/one-time-form.tsx +++ b/app/(dashboard)/administrator/one-time-form.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react' import TimePicker from './time-picker' +import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' +import { DIVISIONS, type Division } from '@/lib/booking-scope' const STATUSES = [ 'Reserved', @@ -20,6 +22,7 @@ const STATUSES = [ interface Body { id: string name: string + division: Division } interface Semester { @@ -76,7 +79,14 @@ interface OneTimeFormProps { export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: OneTimeFormProps) { const defaultSemesterId = semesters.find(s => s.is_active)?.id ?? '' - const [form, setForm] = useState({ body_id: '', purpose: '' }) + const [form, setForm] = useState({ purpose: '' }) + // Admins may scope a booking to any division, so the full list is always allowed here. + const [scopeValue, setScopeValue] = useState({ + scope: 'single', + body_id: '', + division: null, + body_ids: [], + }) const [semesterId, setSemesterId] = useState(defaultSemesterId) const [sessions, setSessions] = useState([emptySession()]) const [saving, setSaving] = useState(false) @@ -94,8 +104,8 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O .catch(() => {}) }, []) - const visibleRequests = form.body_id - ? pendingRequests.filter(r => r.body_id === form.body_id) + const visibleRequests = scopeValue.body_id + ? pendingRequests.filter(r => r.body_id === scopeValue.body_id) : pendingRequests const updateSession = (index: number, field: keyof OneTimeSession, value: string) => { @@ -107,10 +117,18 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O setError('Please select a semester.') return } - if (!form.body_id || !form.purpose) { + if (!scopeValue.body_id || !form.purpose) { setError('Please fill out all required fields.') return } + if (scopeValue.scope === 'divisional' && !scopeValue.division) { + setError('Please select a division.') + return + } + if (scopeValue.scope === 'multi' && scopeValue.body_ids.filter(id => id !== scopeValue.body_id).length === 0) { + setError('Select at least one other body for a multi-body booking.') + return + } for (const s of sessions) { if (!s.booking_date || !s.start_time || !s.end_time) { setError('Please fill out all session fields.') @@ -122,7 +140,12 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O const res = await fetch('/api/administrator/bookings/one-time', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ body_id: form.body_id, purpose: form.purpose, sessions, semester_id: semesterId }), + body: JSON.stringify({ + ...scopeValue, + purpose: form.purpose, + sessions, + semester_id: semesterId, + }), }) if (res.ok) { @@ -159,19 +182,13 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O )}
-
- - -
+
diff --git a/app/(dashboard)/administrator/tabling-form.tsx b/app/(dashboard)/administrator/tabling-form.tsx index c29b129..d6355ad 100644 --- a/app/(dashboard)/administrator/tabling-form.tsx +++ b/app/(dashboard)/administrator/tabling-form.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react' import TimePicker from './time-picker' +import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' +import { DIVISIONS, type Division } from '@/lib/booking-scope' const STATUSES = [ 'Reserved', @@ -20,6 +22,7 @@ const STATUSES = [ interface Body { id: string name: string + division: Division } interface Semester { @@ -78,8 +81,14 @@ const emptySession = (): Session => ({ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: TablingFormProps) { const defaultSemesterId = semesters.find(s => s.is_active)?.id ?? '' const [semesterId, setSemesterId] = useState(defaultSemesterId) - const [form, setForm] = useState({ + // Admins may scope a booking to any division, so the full list is always allowed here. + const [scopeValue, setScopeValue] = useState({ + scope: 'single', body_id: '', + division: null, + body_ids: [], + }) + const [form, setForm] = useState({ purpose: '', reservation_code: '', }) @@ -99,8 +108,8 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T .catch(() => {}) }, []) - const visibleRequests = form.body_id - ? pendingRequests.filter(r => r.body_id === form.body_id) + const visibleRequests = scopeValue.body_id + ? pendingRequests.filter(r => r.body_id === scopeValue.body_id) : pendingRequests const updateSession = (index: number, field: keyof Session, value: string) => { @@ -119,10 +128,18 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T setError('Please select a semester.') return } - if (!form.body_id || !form.purpose) { + if (!scopeValue.body_id || !form.purpose) { setError('Please fill out all required fields.') return } + if (scopeValue.scope === 'divisional' && !scopeValue.division) { + setError('Please select a division.') + return + } + if (scopeValue.scope === 'multi' && scopeValue.body_ids.filter(id => id !== scopeValue.body_id).length === 0) { + setError('Select at least one other body for a multi-body booking.') + return + } for (const s of sessions) { if (!s.location || !s.session_date || !s.start_time || !s.end_time) { @@ -135,7 +152,7 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T const res = await fetch('/api/administrator/bookings/tabling', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...form, sessions, semester_id: semesterId }), + body: JSON.stringify({ ...form, ...scopeValue, sessions, semester_id: semesterId }), }) if (res.ok) { @@ -169,19 +186,14 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T )}
-
- - -
+ +
diff --git a/app/(dashboard)/administrator/weekly-booking-grid.tsx b/app/(dashboard)/administrator/weekly-booking-grid.tsx index 57b4ba2..740628f 100644 --- a/app/(dashboard)/administrator/weekly-booking-grid.tsx +++ b/app/(dashboard)/administrator/weekly-booking-grid.tsx @@ -1,5 +1,7 @@ 'use client' +import type { BookingScope, Division } from '@/lib/booking-scope' + interface WeeklyOccurrence { id: string occurrence_date: string @@ -19,6 +21,9 @@ interface WeeklyBooking { hidden: boolean bodies: { name: string } | null creator_role: string | null + scope: BookingScope + division: Division | null + booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null weekly_room_bookings: { id: string room_name: string diff --git a/app/(dashboard)/administrator/weekly-form.tsx b/app/(dashboard)/administrator/weekly-form.tsx index 47ff5b5..2fa220c 100644 --- a/app/(dashboard)/administrator/weekly-form.tsx +++ b/app/(dashboard)/administrator/weekly-form.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react' import TimePicker from './time-picker' +import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' +import { DIVISIONS, type Division } from '@/lib/booking-scope' const STATUSES = [ 'Reserved', @@ -20,6 +22,7 @@ const STATUSES = [ interface Body { id: string name: string + division: Division } interface Semester { @@ -62,8 +65,14 @@ const labelCls = "block text-xs font-medium text-[#93b8d8] mb-1" export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: WeeklyFormProps) { const defaultSemesterId = semesters.find(s => s.is_active)?.id ?? '' const [semesterId, setSemesterId] = useState(defaultSemesterId) - const [form, setForm] = useState({ + // Admins may scope a booking to any division, so the full list is always allowed here. + const [scopeValue, setScopeValue] = useState({ + scope: 'single', body_id: '', + division: null, + body_ids: [], + }) + const [form, setForm] = useState({ purpose: '', room_name: '', start_date: '', @@ -88,8 +97,8 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We .catch(() => {}) }, []) - const visibleRequests = form.body_id - ? pendingRequests.filter(r => r.body_id === form.body_id) + const visibleRequests = scopeValue.body_id + ? pendingRequests.filter(r => r.body_id === scopeValue.body_id) : pendingRequests const handleSubmit = async () => { @@ -97,11 +106,21 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We setError('Please select a semester.') return } - if (!form.body_id || !form.purpose || !form.room_name || !form.start_date || !form.end_date || !form.start_time || !form.end_time) { + if (!scopeValue.body_id || !form.purpose || !form.room_name || !form.start_date || !form.end_date || !form.start_time || !form.end_time) { setError('Please fill out all required fields.') return } + if (scopeValue.scope === 'divisional' && !scopeValue.division) { + setError('Please select a division.') + return + } + + if (scopeValue.scope === 'multi' && scopeValue.body_ids.filter(id => id !== scopeValue.body_id).length === 0) { + setError('Select at least one other body for a multi-body booking.') + return + } + if (form.end_date < form.start_date) { setError('End date must be after start date.') return @@ -111,7 +130,7 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We const res = await fetch('/api/administrator/bookings/weekly', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...form, semester_id: semesterId }), + body: JSON.stringify({ ...form, ...scopeValue, semester_id: semesterId }), }) if (res.ok) { @@ -145,19 +164,13 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We )}
-
- - -
+
diff --git a/app/(dashboard)/my-rooms/page.tsx b/app/(dashboard)/my-rooms/page.tsx index 962a055..7a51eaf 100644 --- a/app/(dashboard)/my-rooms/page.tsx +++ b/app/(dashboard)/my-rooms/page.tsx @@ -6,6 +6,7 @@ import RevisionModal from './revision-modal' import BookingDetailModal from './booking-detail-modal' import NotificationBell from './notification-bell' import { Skeleton } from '@/app/_components/skeleton' +import { formatScopeLabel, type BookingScope, type Division } from '@/lib/booking-scope' function MyRoomsSkeleton() { return ( @@ -114,6 +115,39 @@ interface FlatBooking { status: string reservationCode: string | null senateType: string | null + /** Resolved server-side across the booking's full scope. */ + canManage: boolean + /** Groups the "All Bookings" list. Bookings that share rights share a key. */ + scopeKey: string + /** Display name for the group heading -- body name, division, or "X + N others". */ + scopeLabel: string +} + +interface ScopedBookingRow { + id: string + body_id: string + scope: BookingScope + division: Division | null + bodies: { name: string } | null + booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null + canManage: boolean +} + +/** + * A divisional booking is grouped by its division and a multi booking on its own, because in both + * cases the owning body is not what determines who sees it. + */ +function scopeKeyOf(b: ScopedBookingRow): string { + if (b.scope === 'divisional' && b.division) return `div:${b.division}` + if (b.scope === 'multi') return `multi:${b.id}` + return b.body_id +} + +function scopeLabelOf(b: ScopedBookingRow): string { + return formatScopeLabel( + b, + (b.booking_bodies ?? []).map(x => ({ id: x.body_id, name: x.bodies?.name ?? '' })) + ).short } function formatTime(time: string) { @@ -142,7 +176,6 @@ export default function MyRoomsPage() { const [filter, setFilter] = useState(7) const [all, setAll] = useState([]) const [loading, setLoading] = useState(true) - const [leadershipBodyIds, setLeadershipBodyIds] = useState([]) const [detailBooking, setDetailBooking] = useState(null) const [cancellingBooking, setCancellingBooking] = useState<{ id: string @@ -164,13 +197,11 @@ export default function MyRoomsPage() { const fetchBookings = async () => { setLoading(true) - // /api/my-rooms already resolves the caller's Leadership bodies, so this - // no longer needs a second round trip to auth + board_memberships. + // /api/my-rooms resolves visibility and per-booking manage rights across the full scope, so + // this needs no second round trip to auth + board_memberships. const res = await fetch('/api/my-rooms') const data = await res.json() - setLeadershipBodyIds(data.leadershipBodyIds || []) - const flat: FlatBooking[] = [] for (const b of data.oneTimeBookings || []) { @@ -189,6 +220,9 @@ export default function MyRoomsPage() { status: d.status, reservationCode: d.reservation_code, senateType: null, + canManage: !!b.canManage, + scopeKey: scopeKeyOf(b), + scopeLabel: scopeLabelOf(b), }) } } @@ -211,6 +245,9 @@ export default function MyRoomsPage() { status: occ.status || w.status, reservationCode: occ.reservation_code || w.reservation_code, senateType: occ.senate_type ?? null, + canManage: !!b.canManage, + scopeKey: scopeKeyOf(b), + scopeLabel: scopeLabelOf(b), }) } } @@ -233,6 +270,9 @@ export default function MyRoomsPage() { status: s.status, reservationCode: s.reservation_code || t.reservation_code, senateType: null, + canManage: !!b.canManage, + scopeKey: scopeKeyOf(b), + scopeLabel: scopeLabelOf(b), }) } } @@ -292,7 +332,7 @@ export default function MyRoomsPage() { {b.type === 'One-Time Room' ? 'One-Time/Multiple Room' : b.type} {b.status}
-

{b.bodyName}

+

{b.scopeLabel}

{b.location}

{formatDate(b.date)}

@@ -323,14 +363,18 @@ export default function MyRoomsPage() { {all.length === 0 ? (

No bookings found.

) : (() => { - const bodyMap = new Map() + // Grouped by scope, not body: a divisional booking belongs to its division and a multi + // booking stands alone, since in neither case does the owning body decide who sees it. + const bodyMap = new Map() for (const b of all) { - if (!bodyMap.has(b.bodyId)) bodyMap.set(b.bodyId, { bodyName: b.bodyName, bookings: [] }) - bodyMap.get(b.bodyId)!.bookings.push(b) + if (!bodyMap.has(b.scopeKey)) { + bodyMap.set(b.scopeKey, { bodyName: b.scopeLabel, bookings: [], canManage: b.canManage }) + } + bodyMap.get(b.scopeKey)!.bookings.push(b) } - const groups = Array.from(bodyMap.entries()).map(([bodyId, { bodyName, bookings }]) => ({ + const groups = Array.from(bodyMap.entries()).map(([bodyId, { bodyName, bookings, canManage }]) => ({ bodyId, bodyName, bookings, - isLeadership: leadershipBodyIds.includes(bodyId), + isLeadership: canManage, })) groups.sort((a, b) => { if (a.isLeadership !== b.isLeadership) return a.isLeadership ? -1 : 1 @@ -373,7 +417,7 @@ export default function MyRoomsPage() { {detailBooking && ( setDetailBooking(null)} onCancelClick={() => { const sessionCount = all.filter(b => b.bookingId === detailBooking.bookingId).length diff --git a/app/(dashboard)/request/page.tsx b/app/(dashboard)/request/page.tsx index fdffcb1..b6700d2 100644 --- a/app/(dashboard)/request/page.tsx +++ b/app/(dashboard)/request/page.tsx @@ -6,12 +6,15 @@ import { createClient } from '@/lib/supabase/client' import { getAuthedUser } from '@/lib/auth' import TimePicker from '../administrator/time-picker' import { Skeleton } from '@/app/_components/skeleton' +import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector' +import type { Division } from '@/lib/booking-scope' type RequestType = 'One-Time Room' | 'Weekly Room' | 'Tabling' interface Body { id: string name: string + division: Division } interface MyRequest { @@ -160,6 +163,10 @@ export default function RequestPage() { const router = useRouter() const [type, setType] = useState('One-Time Room') const [bodies, setBodies] = useState([]) + // The multi-body pool is every active body; leadership may request any combination. + const [allBodies, setAllBodies] = useState([]) + // Leadership may only request a divisional booking for a division they actually lead. + const [leadershipDivisions, setLeadershipDivisions] = useState([]) const [bodiesLoading, setBodiesLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [submitted, setSubmitted] = useState(false) @@ -171,8 +178,14 @@ export default function RequestPage() { const [requestsLoading, setRequestsLoading] = useState(false) const [expandedRequestId, setExpandedRequestId] = useState(null) - const [form, setForm] = useState({ + const [scopeValue, setScopeValue] = useState({ + scope: 'single', body_id: '', + division: null, + body_ids: [], + }) + + const [form, setForm] = useState({ purpose: '', notes: '', room_name: '', @@ -202,6 +215,8 @@ export default function RequestPage() { return } setBodies(resolved) + setAllBodies(data.allBodies || resolved) + setLeadershipDivisions(data.leadershipDivisions || []) setBodiesLoading(false) } fetchBodies() @@ -226,11 +241,21 @@ export default function RequestPage() { const handleSubmit = async () => { setError('') - if (!form.body_id || !form.purpose) { + if (!scopeValue.body_id || !form.purpose) { setError('Please fill out all required fields.') return } + if (scopeValue.scope === 'divisional' && !scopeValue.division) { + setError('Please select a division.') + return + } + + if (scopeValue.scope === 'multi' && scopeValue.body_ids.filter(id => id !== scopeValue.body_id).length === 0) { + setError('Select at least one other body for a multi-body request.') + return + } + if (type === 'One-Time Room') { for (const s of oneTimeSessions) { if (!s.session_date || !s.start_time || !s.end_time) { @@ -284,7 +309,7 @@ export default function RequestPage() { const payload: Record = { type, - body_id: form.body_id, + ...scopeValue, purpose: form.purpose, notes: form.notes, } @@ -323,7 +348,8 @@ export default function RequestPage() { } const resetForm = () => { - setForm({ body_id: '', purpose: '', notes: '', room_name: '', start_date: '', end_date: '', start_time: '09:00', end_time: '10:00' }) + setForm({ purpose: '', notes: '', room_name: '', start_date: '', end_date: '', start_time: '09:00', end_time: '10:00' }) + setScopeValue({ scope: 'single', body_id: '', division: null, body_ids: [] }) setSessions([emptySession()]) setOneTimeSessions([emptyOneTimeSession()]) setSubmitted(false) @@ -512,17 +538,20 @@ export default function RequestPage() { <> {/* Common fields */}
-
- - {bodiesLoading ? ( + {bodiesLoading ? ( +
+ - ) : ( - - )} -
+
+ ) : ( + + )}
diff --git a/app/_components/booking-scope-selector.tsx b/app/_components/booking-scope-selector.tsx new file mode 100644 index 0000000..6ed7711 --- /dev/null +++ b/app/_components/booking-scope-selector.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useMemo } from 'react' +import type { BookingScope, Division } from '@/lib/booking-scope' + +export interface BookingScopeValue { + scope: BookingScope + /** The owning body. Always set, for every scope. */ + body_id: string + /** Divisional only. */ + division: Division | null + /** Multi only. Includes body_id. */ + body_ids: string[] +} + +export interface ScopeBody { + id: string + name: string + division: Division +} + +interface Props { + value: BookingScopeValue + onChange: (v: BookingScopeValue) => void + /** Bodies the caller may originate a booking from (admins: all active; leadership: theirs). */ + ownerBodies: ScopeBody[] + /** The pool for a multi-body booking. Any leadership may pick any combination. */ + allBodies: ScopeBody[] + /** Divisions the caller may create a divisional booking for. */ + allowedDivisions: Division[] + disabled?: boolean +} + +const inputCls = + 'w-full bg-[#0f2a4a] border border-[#1e5080] rounded-lg px-3 py-2.5 text-sm text-[#f0f6ff] placeholder:text-[#6a96bb] focus:outline-none focus:ring-2 focus:ring-[#c8102e]/30 focus:border-[#c8102e] transition' +const labelCls = 'block text-xs font-medium text-[#93b8d8] mb-1' + +const SCOPE_OPTIONS: { value: BookingScope; label: string; hint: string }[] = [ + { value: 'single', label: 'Single body', hint: 'Only this body sees the booking.' }, + { + value: 'divisional', + label: 'Divisional', + hint: 'Everyone in the division sees it; their leadership can manage it.', + }, + { + value: 'multi', + label: 'Multiple bodies', + hint: 'Only the chosen bodies see it; leadership of any of them can manage it.', + }, +] + +/** + * The scope selector shared by all seven booking forms (three admin create, three admin edit, and + * the leadership request page). Wraps the plain Body setScope(e.target.value as BookingScope)} + disabled={disabled} + className={inputCls} + > + {SCOPE_OPTIONS.map(o => ( + + ))} + +

+ {SCOPE_OPTIONS.find(o => o.value === value.scope)?.hint} + {value.scope === 'divisional' && !canGoDivisional && ( + You do not hold Leadership in any division. + )} +

+
+ +
+ + +
+ + {value.scope === 'divisional' && ( +
+ + +
+ )} + + {value.scope === 'multi' && ( +
+ + {!value.body_id ? ( +

Select a body above first.

+ ) : ( +
+ + {otherBodies.map(b => ( + + ))} +
+ )} + {value.body_id && selectedCount === 0 && ( +

+ Select at least one other body, or switch back to Single body. +

+ )} +
+ )} +
+ ) +} diff --git a/app/_components/scope-label.tsx b/app/_components/scope-label.tsx new file mode 100644 index 0000000..25adb56 --- /dev/null +++ b/app/_components/scope-label.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useState } from 'react' +import { formatScopeLabel, type ScopedRow } from '@/lib/booking-scope' + +interface Props { + row: Pick & { bodies?: { name: string } | null } + linkedBodies?: { id: string; name: string }[] + className?: string +} + +/** + * Renders a booking's owning body for single scope, and the scope label for divisional/multi. + * Multi expands to the full list on click; the hover title always carries it. + */ +export default function ScopeLabel({ row, linkedBodies = [], className = '' }: Props) { + const [expanded, setExpanded] = useState(false) + const { short, full } = formatScopeLabel(row, linkedBodies) + + if (row.scope !== 'multi' || full.length < 2) { + return ( + + {short} + + ) + } + + return ( + + + {expanded && ( + + with {full.slice(1).join(', ')} + + )} + + ) +} diff --git a/public/sw.js b/public/sw.js index e97ccbf..9b33a2a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const i=(i,a)=>(i=new URL(i+".js",a).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(a,t)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>i(e,n),o={module:{uri:n},exports:c,require:r};s[n]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/LpjOKCys_jJ891y7ESTA6/_buildManifest.js",revision:"ece20b789087de2fb9a552ae39d909ab"},{url:"/_next/static/LpjOKCys_jJ891y7ESTA6/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/1966.1560d0f43ac9a41a.js",revision:"1560d0f43ac9a41a"},{url:"/_next/static/chunks/3794-cd98b5cd5fb65b19.js",revision:"cd98b5cd5fb65b19"},{url:"/_next/static/chunks/3899.cf10152d8085e352.js",revision:"cf10152d8085e352"},{url:"/_next/static/chunks/4bd1b696-bf5e0dbacfa5baef.js",revision:"bf5e0dbacfa5baef"},{url:"/_next/static/chunks/6622-1be3273582242e57.js",revision:"1be3273582242e57"},{url:"/_next/static/chunks/8500-41fa79ac743d83f1.js",revision:"41fa79ac743d83f1"},{url:"/_next/static/chunks/app/(dashboard)/administrator/page-0aa0edc27df89a15.js",revision:"0aa0edc27df89a15"},{url:"/_next/static/chunks/app/(dashboard)/events/page-cc45c4a6a86eaaa9.js",revision:"cc45c4a6a86eaaa9"},{url:"/_next/static/chunks/app/(dashboard)/layout-76b0a194f0cc5d7d.js",revision:"76b0a194f0cc5d7d"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-a9c83f1256ce1344.js",revision:"a9c83f1256ce1344"},{url:"/_next/static/chunks/app/(dashboard)/request/page-e33b95479c3ead13.js",revision:"e33b95479c3ead13"},{url:"/_next/static/chunks/app/(dashboard)/sga-spaces/page-fb0545d95e7660ab.js",revision:"fb0545d95e7660ab"},{url:"/_next/static/chunks/app/_global-error/page-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/_not-found/page-b09573fbbba3f839.js",revision:"b09573fbbba3f839"},{url:"/_next/static/chunks/app/api/administrator/archive/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/counts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/resend-invite/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/alerts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/display/%5BspaceId%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/checklist/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/my-rooms/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/complete/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/invalidate-otp/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/profile/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/revision-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/verify/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/command/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/connect/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/interaction/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/remaining-hours/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/by-ids/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/search/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/display/%5BspaceId%5D/page-415b8cd189fa7b18.js",revision:"415b8cd189fa7b18"},{url:"/_next/static/chunks/app/faq/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/layout-998eeaeab6160f77.js",revision:"998eeaeab6160f77"},{url:"/_next/static/chunks/app/legal/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/onboarding/page-07f581cfd59bd0d6.js",revision:"07f581cfd59bd0d6"},{url:"/_next/static/chunks/app/page-e2e6455dc296605e.js",revision:"e2e6455dc296605e"},{url:"/_next/static/chunks/app/reset-password/page-dce1b762e5bccf40.js",revision:"dce1b762e5bccf40"},{url:"/_next/static/chunks/app/signup/page-0539f393f05aca65.js",revision:"0539f393f05aca65"},{url:"/_next/static/chunks/app/slack/connect/page-60e22f90ab37aac7.js",revision:"60e22f90ab37aac7"},{url:"/_next/static/chunks/framework-a7f7b4d2dfa5296c.js",revision:"a7f7b4d2dfa5296c"},{url:"/_next/static/chunks/main-1dd03075465b2959.js",revision:"1dd03075465b2959"},{url:"/_next/static/chunks/main-app-2dfbcdcb699fb6b9.js",revision:"2dfbcdcb699fb6b9"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-5f32f9994882d039.js",revision:"5f32f9994882d039"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-541bcbd7745db4e8.js",revision:"541bcbd7745db4e8"},{url:"/_next/static/css/13748814dceb7d96.css",revision:"13748814dceb7d96"},{url:"/_next/static/css/cd0002014adac006.css",revision:"cd0002014adac006"},{url:"/_next/static/media/36966cca54120369-s.p.woff2",revision:"25ea4a783c12103f175f5b157b7d96aa"},{url:"/_next/static/media/b7387a63dd068245-s.woff2",revision:"dea099b7d5a5ea45bd4367f8aeff62ab"},{url:"/_next/static/media/e1aab0933260df4d-s.woff2",revision:"207f8e9f3761dbd724063a177d906a99"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icons/icon-192x192.png",revision:"d5bd341d0d1d9ce5ddcb8dd2fd15155d"},{url:"/icons/icon-512x512.png",revision:"38a6f129deef1207d9731d7cc6619583"},{url:"/manifest.json",revision:"940524417a2dd4c41cf0c95e3fb6cf9b"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/opsemaillogo.png",revision:"083cc51c4d24fca1abf11c3b69625815"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const i=(i,t)=>(i=new URL(i+".js",t).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(t,a)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>i(e,n),o={module:{uri:n},exports:c,require:r};s[n]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/1966.1560d0f43ac9a41a.js",revision:"1560d0f43ac9a41a"},{url:"/_next/static/chunks/2900-1c471e20b1f84c4c.js",revision:"1c471e20b1f84c4c"},{url:"/_next/static/chunks/3794-cd98b5cd5fb65b19.js",revision:"cd98b5cd5fb65b19"},{url:"/_next/static/chunks/3899.cf10152d8085e352.js",revision:"cf10152d8085e352"},{url:"/_next/static/chunks/4bd1b696-bf5e0dbacfa5baef.js",revision:"bf5e0dbacfa5baef"},{url:"/_next/static/chunks/6585-31b952072a529d33.js",revision:"31b952072a529d33"},{url:"/_next/static/chunks/6622-1be3273582242e57.js",revision:"1be3273582242e57"},{url:"/_next/static/chunks/8500-41fa79ac743d83f1.js",revision:"41fa79ac743d83f1"},{url:"/_next/static/chunks/app/(dashboard)/administrator/page-e45d10074a04215f.js",revision:"e45d10074a04215f"},{url:"/_next/static/chunks/app/(dashboard)/events/page-cc45c4a6a86eaaa9.js",revision:"cc45c4a6a86eaaa9"},{url:"/_next/static/chunks/app/(dashboard)/layout-99498c81da3fd45d.js",revision:"99498c81da3fd45d"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-5de62f39095b12da.js",revision:"5de62f39095b12da"},{url:"/_next/static/chunks/app/(dashboard)/request/page-918972a23918d155.js",revision:"918972a23918d155"},{url:"/_next/static/chunks/app/(dashboard)/sga-spaces/page-fb0545d95e7660ab.js",revision:"fb0545d95e7660ab"},{url:"/_next/static/chunks/app/_global-error/page-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/_not-found/page-b09573fbbba3f839.js",revision:"b09573fbbba3f839"},{url:"/_next/static/chunks/app/api/administrator/archive/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/counts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/resend-invite/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/alerts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/display/%5BspaceId%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/checklist/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/my-rooms/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/complete/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/invalidate-otp/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/profile/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/revision-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/verify/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/command/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/connect/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/interaction/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/remaining-hours/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/by-ids/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/search/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/display/%5BspaceId%5D/page-415b8cd189fa7b18.js",revision:"415b8cd189fa7b18"},{url:"/_next/static/chunks/app/faq/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/layout-998eeaeab6160f77.js",revision:"998eeaeab6160f77"},{url:"/_next/static/chunks/app/legal/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/onboarding/page-07f581cfd59bd0d6.js",revision:"07f581cfd59bd0d6"},{url:"/_next/static/chunks/app/page-e2e6455dc296605e.js",revision:"e2e6455dc296605e"},{url:"/_next/static/chunks/app/reset-password/page-dce1b762e5bccf40.js",revision:"dce1b762e5bccf40"},{url:"/_next/static/chunks/app/signup/page-0539f393f05aca65.js",revision:"0539f393f05aca65"},{url:"/_next/static/chunks/app/slack/connect/page-60e22f90ab37aac7.js",revision:"60e22f90ab37aac7"},{url:"/_next/static/chunks/framework-a7f7b4d2dfa5296c.js",revision:"a7f7b4d2dfa5296c"},{url:"/_next/static/chunks/main-1dd03075465b2959.js",revision:"1dd03075465b2959"},{url:"/_next/static/chunks/main-app-2dfbcdcb699fb6b9.js",revision:"2dfbcdcb699fb6b9"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-5f32f9994882d039.js",revision:"5f32f9994882d039"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-c4a7f1b17b827393.js",revision:"c4a7f1b17b827393"},{url:"/_next/static/css/13748814dceb7d96.css",revision:"13748814dceb7d96"},{url:"/_next/static/css/4b918a516cc87ca7.css",revision:"4b918a516cc87ca7"},{url:"/_next/static/j2F-bK_tHc1-THx4tsrXe/_buildManifest.js",revision:"ece20b789087de2fb9a552ae39d909ab"},{url:"/_next/static/j2F-bK_tHc1-THx4tsrXe/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/media/36966cca54120369-s.p.woff2",revision:"25ea4a783c12103f175f5b157b7d96aa"},{url:"/_next/static/media/b7387a63dd068245-s.woff2",revision:"dea099b7d5a5ea45bd4367f8aeff62ab"},{url:"/_next/static/media/e1aab0933260df4d-s.woff2",revision:"207f8e9f3761dbd724063a177d906a99"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icons/icon-192x192.png",revision:"d5bd341d0d1d9ce5ddcb8dd2fd15155d"},{url:"/icons/icon-512x512.png",revision:"38a6f129deef1207d9731d7cc6619583"},{url:"/manifest.json",revision:"940524417a2dd4c41cf0c95e3fb6cf9b"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/opsemaillogo.png",revision:"083cc51c4d24fca1abf11c3b69625815"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); From 1f9f9dd2fa504866fa990f7d1c035266a5cef9bd Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 22:19:42 -0400 Subject: [PATCH 4/6] Ignore supabase db dump output at the repo root `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 --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index b1e30e0..92e7b25 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,10 @@ next-env.d.ts # Supabase CLI scratch supabase/.temp/ + +# `supabase db dump` output, written to the repo root. Never commit these: a real +# roles.sql can contain role passwords, and data.sql is a full copy of production. +# Schema history belongs in supabase/migrations/ instead. +/data.sql +/roles.sql +/schema.sql From 26470cade3d0170f62e7f255ba8898df471220fe Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 22:40:49 -0400 Subject: [PATCH 5/6] Fix PGRST201: junction tables made every bodies() embed ambiguous (#19) 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 --- app/api/administrator/bookings/route.ts | 15 +++++++- app/api/my-rooms/route.ts | 16 +++++++-- public/sw.js | 2 +- ...03000_multi_body_junction_surrogate_pk.sql | 36 +++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20260826003000_multi_body_junction_surrogate_pk.sql diff --git a/app/api/administrator/bookings/route.ts b/app/api/administrator/bookings/route.ts index c5f843a..c90e00e 100644 --- a/app/api/administrator/bookings/route.ts +++ b/app/api/administrator/bookings/route.ts @@ -78,10 +78,23 @@ export async function GET(request: Request) { .order('created_at', { ascending: false }) if (semesterId) tablingQ = tablingQ.eq('semester_id', semesterId) - const [{ data: oneTime }, { data: weekly }, { data: tabling }] = await Promise.all([ + const [oneTimeRes, weeklyRes, tablingRes] = await Promise.all([ oneTimeQ, weeklyQ, tablingQ, ]) + // Surface query failures instead of coercing them to an empty list. A malformed embed + // (e.g. PGRST201, an ambiguous relationship) otherwise renders as a calm "no bookings + // found", which is indistinguishable from genuinely having none. + const failed = [oneTimeRes, weeklyRes, tablingRes].find(r => r.error) + if (failed?.error) { + console.error('administrator/bookings query failed:', failed.error) + return NextResponse.json({ error: failed.error.message }, { status: 500 }) + } + + const { data: oneTime } = oneTimeRes + const { data: weekly } = weeklyRes + const { data: tabling } = tablingRes + return NextResponse.json({ oneTime: oneTime || [], weekly: weekly || [], diff --git a/app/api/my-rooms/route.ts b/app/api/my-rooms/route.ts index 55b5e8d..791be79 100644 --- a/app/api/my-rooms/route.ts +++ b/app/api/my-rooms/route.ts @@ -112,8 +112,20 @@ export async function GET() { .eq('semester_id', activeSemester.id) tablingQ = orFilter ? tablingQ.or(orFilter) : tablingQ.in('body_id', ctx.bodyIds) - const [{ data: oneTimeBookings }, { data: weeklyBookings }, { data: tablingBookings }] = - await Promise.all([oneTimeQ, weeklyQ, tablingQ]) + const [oneTimeRes, weeklyRes, tablingRes] = await Promise.all([oneTimeQ, weeklyQ, tablingQ]) + + // Surface query failures instead of coercing them to an empty list. A malformed embed + // (e.g. PGRST201, an ambiguous relationship) otherwise renders as a calm "no bookings + // found", which is indistinguishable from genuinely having none. + const failed = [oneTimeRes, weeklyRes, tablingRes].find(r => r.error) + if (failed?.error) { + console.error('my-rooms query failed:', failed.error) + return NextResponse.json({ error: failed.error.message }, { status: 500 }) + } + + const { data: oneTimeBookings } = oneTimeRes + const { data: weeklyBookings } = weeklyRes + const { data: tablingBookings } = tablingRes /** * Decorates each row with canManage, computed server-side across the full scope. The client used diff --git a/public/sw.js b/public/sw.js index 7b3adb7..f47a5a2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const i=(i,t)=>(i=new URL(i+".js",t).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(t,a)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>i(e,n),o={module:{uri:n},exports:c,require:r};s[n]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/1966.1560d0f43ac9a41a.js",revision:"1560d0f43ac9a41a"},{url:"/_next/static/chunks/2900-1c471e20b1f84c4c.js",revision:"1c471e20b1f84c4c"},{url:"/_next/static/chunks/3794-cd98b5cd5fb65b19.js",revision:"cd98b5cd5fb65b19"},{url:"/_next/static/chunks/3899.cf10152d8085e352.js",revision:"cf10152d8085e352"},{url:"/_next/static/chunks/4bd1b696-bf5e0dbacfa5baef.js",revision:"bf5e0dbacfa5baef"},{url:"/_next/static/chunks/6585-31b952072a529d33.js",revision:"31b952072a529d33"},{url:"/_next/static/chunks/6622-1be3273582242e57.js",revision:"1be3273582242e57"},{url:"/_next/static/chunks/8500-41fa79ac743d83f1.js",revision:"41fa79ac743d83f1"},{url:"/_next/static/chunks/app/(dashboard)/administrator/page-91cba99603ea29ef.js",revision:"91cba99603ea29ef"},{url:"/_next/static/chunks/app/(dashboard)/events/page-5256a1760f698fdc.js",revision:"5256a1760f698fdc"},{url:"/_next/static/chunks/app/(dashboard)/layout-82e712147ce597af.js",revision:"82e712147ce597af"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-8823703af121bdbe.js",revision:"8823703af121bdbe"},{url:"/_next/static/chunks/app/(dashboard)/request/page-918972a23918d155.js",revision:"918972a23918d155"},{url:"/_next/static/chunks/app/(dashboard)/sga-spaces/page-fb0545d95e7660ab.js",revision:"fb0545d95e7660ab"},{url:"/_next/static/chunks/app/_global-error/page-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/_not-found/page-b09573fbbba3f839.js",revision:"b09573fbbba3f839"},{url:"/_next/static/chunks/app/api/administrator/archive/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/counts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/resend-invite/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/alerts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/display/%5BspaceId%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/checklist/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/my-rooms/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/complete/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/invalidate-otp/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/profile/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/revision-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/verify/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/command/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/connect/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/interaction/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/remaining-hours/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/by-ids/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/search/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/display/%5BspaceId%5D/page-415b8cd189fa7b18.js",revision:"415b8cd189fa7b18"},{url:"/_next/static/chunks/app/faq/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/layout-998eeaeab6160f77.js",revision:"998eeaeab6160f77"},{url:"/_next/static/chunks/app/legal/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/onboarding/page-07f581cfd59bd0d6.js",revision:"07f581cfd59bd0d6"},{url:"/_next/static/chunks/app/page-e2e6455dc296605e.js",revision:"e2e6455dc296605e"},{url:"/_next/static/chunks/app/reset-password/page-dce1b762e5bccf40.js",revision:"dce1b762e5bccf40"},{url:"/_next/static/chunks/app/signup/page-0539f393f05aca65.js",revision:"0539f393f05aca65"},{url:"/_next/static/chunks/app/slack/connect/page-60e22f90ab37aac7.js",revision:"60e22f90ab37aac7"},{url:"/_next/static/chunks/framework-a7f7b4d2dfa5296c.js",revision:"a7f7b4d2dfa5296c"},{url:"/_next/static/chunks/main-1dd03075465b2959.js",revision:"1dd03075465b2959"},{url:"/_next/static/chunks/main-app-2dfbcdcb699fb6b9.js",revision:"2dfbcdcb699fb6b9"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-5f32f9994882d039.js",revision:"5f32f9994882d039"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-c4a7f1b17b827393.js",revision:"c4a7f1b17b827393"},{url:"/_next/static/css/13748814dceb7d96.css",revision:"13748814dceb7d96"},{url:"/_next/static/css/a222c7856b03dcaa.css",revision:"a222c7856b03dcaa"},{url:"/_next/static/i5lWW-PIPAC8p5kyqmNFE/_buildManifest.js",revision:"ece20b789087de2fb9a552ae39d909ab"},{url:"/_next/static/i5lWW-PIPAC8p5kyqmNFE/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/media/36966cca54120369-s.p.woff2",revision:"25ea4a783c12103f175f5b157b7d96aa"},{url:"/_next/static/media/b7387a63dd068245-s.woff2",revision:"dea099b7d5a5ea45bd4367f8aeff62ab"},{url:"/_next/static/media/e1aab0933260df4d-s.woff2",revision:"207f8e9f3761dbd724063a177d906a99"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icons/icon-192x192.png",revision:"d5bd341d0d1d9ce5ddcb8dd2fd15155d"},{url:"/icons/icon-512x512.png",revision:"38a6f129deef1207d9731d7cc6619583"},{url:"/manifest.json",revision:"940524417a2dd4c41cf0c95e3fb6cf9b"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/opsemaillogo.png",revision:"083cc51c4d24fca1abf11c3b69625815"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const i=(i,t)=>(i=new URL(i+".js",t).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(t,a)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>i(e,n),o={module:{uri:n},exports:c,require:r};s[n]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/1966.1560d0f43ac9a41a.js",revision:"1560d0f43ac9a41a"},{url:"/_next/static/chunks/2900-1c471e20b1f84c4c.js",revision:"1c471e20b1f84c4c"},{url:"/_next/static/chunks/3794-cd98b5cd5fb65b19.js",revision:"cd98b5cd5fb65b19"},{url:"/_next/static/chunks/3899.cf10152d8085e352.js",revision:"cf10152d8085e352"},{url:"/_next/static/chunks/4bd1b696-bf5e0dbacfa5baef.js",revision:"bf5e0dbacfa5baef"},{url:"/_next/static/chunks/6585-31b952072a529d33.js",revision:"31b952072a529d33"},{url:"/_next/static/chunks/6622-1be3273582242e57.js",revision:"1be3273582242e57"},{url:"/_next/static/chunks/8500-41fa79ac743d83f1.js",revision:"41fa79ac743d83f1"},{url:"/_next/static/chunks/app/(dashboard)/administrator/page-91cba99603ea29ef.js",revision:"91cba99603ea29ef"},{url:"/_next/static/chunks/app/(dashboard)/events/page-5256a1760f698fdc.js",revision:"5256a1760f698fdc"},{url:"/_next/static/chunks/app/(dashboard)/layout-82e712147ce597af.js",revision:"82e712147ce597af"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-8823703af121bdbe.js",revision:"8823703af121bdbe"},{url:"/_next/static/chunks/app/(dashboard)/request/page-918972a23918d155.js",revision:"918972a23918d155"},{url:"/_next/static/chunks/app/(dashboard)/sga-spaces/page-fb0545d95e7660ab.js",revision:"fb0545d95e7660ab"},{url:"/_next/static/chunks/app/_global-error/page-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/_not-found/page-b09573fbbba3f839.js",revision:"b09573fbbba3f839"},{url:"/_next/static/chunks/app/api/administrator/archive/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/counts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/resend-invite/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/alerts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/display/%5BspaceId%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/checklist/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/my-rooms/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/complete/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/invalidate-otp/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/profile/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/revision-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/verify/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/command/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/connect/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/interaction/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/remaining-hours/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/by-ids/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/search/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/display/%5BspaceId%5D/page-415b8cd189fa7b18.js",revision:"415b8cd189fa7b18"},{url:"/_next/static/chunks/app/faq/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/layout-998eeaeab6160f77.js",revision:"998eeaeab6160f77"},{url:"/_next/static/chunks/app/legal/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/onboarding/page-07f581cfd59bd0d6.js",revision:"07f581cfd59bd0d6"},{url:"/_next/static/chunks/app/page-e2e6455dc296605e.js",revision:"e2e6455dc296605e"},{url:"/_next/static/chunks/app/reset-password/page-dce1b762e5bccf40.js",revision:"dce1b762e5bccf40"},{url:"/_next/static/chunks/app/signup/page-0539f393f05aca65.js",revision:"0539f393f05aca65"},{url:"/_next/static/chunks/app/slack/connect/page-60e22f90ab37aac7.js",revision:"60e22f90ab37aac7"},{url:"/_next/static/chunks/framework-a7f7b4d2dfa5296c.js",revision:"a7f7b4d2dfa5296c"},{url:"/_next/static/chunks/main-1dd03075465b2959.js",revision:"1dd03075465b2959"},{url:"/_next/static/chunks/main-app-2dfbcdcb699fb6b9.js",revision:"2dfbcdcb699fb6b9"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-5f32f9994882d039.js",revision:"5f32f9994882d039"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-c4a7f1b17b827393.js",revision:"c4a7f1b17b827393"},{url:"/_next/static/css/13748814dceb7d96.css",revision:"13748814dceb7d96"},{url:"/_next/static/css/a222c7856b03dcaa.css",revision:"a222c7856b03dcaa"},{url:"/_next/static/media/36966cca54120369-s.p.woff2",revision:"25ea4a783c12103f175f5b157b7d96aa"},{url:"/_next/static/media/b7387a63dd068245-s.woff2",revision:"dea099b7d5a5ea45bd4367f8aeff62ab"},{url:"/_next/static/media/e1aab0933260df4d-s.woff2",revision:"207f8e9f3761dbd724063a177d906a99"},{url:"/_next/static/nxKjdUM3-qI3bsmsSWNJy/_buildManifest.js",revision:"ece20b789087de2fb9a552ae39d909ab"},{url:"/_next/static/nxKjdUM3-qI3bsmsSWNJy/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icons/icon-192x192.png",revision:"d5bd341d0d1d9ce5ddcb8dd2fd15155d"},{url:"/icons/icon-512x512.png",revision:"38a6f129deef1207d9731d7cc6619583"},{url:"/manifest.json",revision:"940524417a2dd4c41cf0c95e3fb6cf9b"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/opsemaillogo.png",revision:"083cc51c4d24fca1abf11c3b69625815"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/supabase/migrations/20260826003000_multi_body_junction_surrogate_pk.sql b/supabase/migrations/20260826003000_multi_body_junction_surrogate_pk.sql new file mode 100644 index 0000000..488d9d6 --- /dev/null +++ b/supabase/migrations/20260826003000_multi_body_junction_surrogate_pk.sql @@ -0,0 +1,36 @@ +-- Multi-body bookings (issue #19) -- hotfix for PGRST201. +-- +-- 20260826000000 created booking_bodies and room_request_bodies with a composite +-- primary key on (parent_id, body_id). That is the textbook junction-table shape, and +-- PostgREST detects it as one: it began inferring a many-to-many relationship +-- `bookings <-> bodies` through booking_bodies, in ADDITION to the long-standing +-- many-to-one `bookings.body_id -> bodies.id`. +-- +-- With two candidate relationships, every `bodies(name)` embed became ambiguous and +-- PostgREST answered 300 Multiple Choices / PGRST201. Because the app's read routes do +-- `data || []` without checking the error, that surfaced as "No bookings found" +-- everywhere rather than as an error -- and it broke code that predates this feature, +-- since the mere existence of the tables was enough. +-- +-- board_memberships is the precedent already in this schema: also a junction table +-- (user_id + body_id), but with a surrogate id primary key and the pair merely UNIQUE. +-- That shape does not trigger m2m inference, which is why users <-> bodies embeds have +-- always been unambiguous. Match it. +-- +-- The unique constraint preserves the real invariant (a body appears at most once per +-- booking); only the index backing it changes. + +alter table public.booking_bodies drop constraint booking_bodies_pkey; +alter table public.booking_bodies add column id uuid not null default gen_random_uuid(); +alter table public.booking_bodies add constraint booking_bodies_pkey primary key (id); +alter table public.booking_bodies + add constraint booking_bodies_booking_id_body_id_key unique (booking_id, body_id); + +alter table public.room_request_bodies drop constraint room_request_bodies_pkey; +alter table public.room_request_bodies add column id uuid not null default gen_random_uuid(); +alter table public.room_request_bodies add constraint room_request_bodies_pkey primary key (id); +alter table public.room_request_bodies + add constraint room_request_bodies_request_id_body_id_key unique (request_id, body_id); + +-- PostgREST caches the schema; without this it keeps serving the ambiguous relationship. +notify pgrst, 'reload schema'; From 064ea152ab6173f4a772648ffdc1936982934669 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 22:57:06 -0400 Subject: [PATCH 6/6] Do not notify members about hidden bookings (#21) 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 --- app/(dashboard)/layout.tsx | 2 +- app/faq/page.tsx | 2 +- lib/booking-scope.ts | 27 +++++++++++++++++++++------ package-lock.json | 4 ++-- package.json | 2 +- public/sw.js | 2 +- 6 files changed, 27 insertions(+), 12 deletions(-) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 4ada843..0f69174 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -244,7 +244,7 @@ export default function DashboardLayout({ Chambers

NU Student Gov. Association

-

v1.12.4

+

v1.13.0

{userName && (

{getGreeting()},
{userName}

diff --git a/app/faq/page.tsx b/app/faq/page.tsx index eae437a..dea11cd 100644 --- a/app/faq/page.tsx +++ b/app/faq/page.tsx @@ -32,7 +32,7 @@ export default async function FaqPage() {
-

v1.13.0

+

v1.14.0

We don't exactly know yet! If there's anything you'd like to see, send a Slack DM to the Vice President of Operational Affairs ({vpName}) and the Digital Innovation Manager ({dimName}).

diff --git a/lib/booking-scope.ts b/lib/booking-scope.ts index a8e69bf..6536563 100644 --- a/lib/booking-scope.ts +++ b/lib/booking-scope.ts @@ -349,6 +349,14 @@ interface RecipientRow { * divisional members of the owning body, plus only Leadership of the peer bodies. A division * can be large, and mass-emailing all of it on every edit would be noise. * + * Hidden bookings (issue #21) override all of the above: a hidden booking is only visible to + * those who can manage it, so it must only ever notify them -- Leadership across the whole + * audience, for every scope. Otherwise an alert or email tells a member about a booking they + * cannot see, which is both confusing and a disclosure. + * + * `hidden` is looked up here rather than taken from the caller precisely so no route can forget + * to pass it; that omission is what caused #21. + * * This is the single place that policy lives; change it here and every booking route follows. * * `leadershipOnly` narrows to Leadership across the whole audience regardless of scope -- used for @@ -362,10 +370,17 @@ export async function resolveBookingRecipients( const bodyIds = await resolveBookingBodyIds(adminSupabase, row) if (bodyIds.length === 0) return [] - const { data } = await adminSupabase - .from('board_memberships') - .select('user_id, body_id, role, users(email, full_name, is_active)') - .in('body_id', bodyIds) + const [{ data }, { data: bookingRow }] = await Promise.all([ + adminSupabase + .from('board_memberships') + .select('user_id, body_id, role, users(email, full_name, is_active)') + .in('body_id', bodyIds), + adminSupabase.from('bookings').select('hidden').eq('id', row.id).maybeSingle(), + ]) + + // A hidden booking notifies only the people who can manage it, which is exactly the set + // canManageScoped() admits: Leadership anywhere in the booking's audience. + const leadershipOnly = !!opts.leadershipOnly || !!bookingRow?.hidden const rows = (data ?? []) as RecipientRow[] const byUser = new Map() @@ -374,11 +389,11 @@ export async function resolveBookingRecipients( const user = Array.isArray(m.users) ? m.users[0] : m.users if (!user?.is_active || !user.email) continue - if (opts.leadershipOnly && m.role !== 'Leadership') continue + if (leadershipOnly && m.role !== 'Leadership') continue // Divisional: peer bodies contribute only their leadership. if ( - !opts.leadershipOnly && + !leadershipOnly && row.scope === 'divisional' && m.body_id !== row.body_id && m.role !== 'Leadership' diff --git a/package-lock.json b/package-lock.json index ca4e55d..aa321b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chambers", - "version": "1.12.4", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chambers", - "version": "1.12.4", + "version": "1.13.0", "dependencies": { "@supabase/ssr": "^0.9.0", "@supabase/supabase-js": "^2.99.1", diff --git a/package.json b/package.json index 73f7c3c..85500e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chambers", - "version": "1.12.4", + "version": "1.13.0", "private": true, "scripts": { "dev": "next dev", diff --git a/public/sw.js b/public/sw.js index f47a5a2..60a0e02 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const i=(i,t)=>(i=new URL(i+".js",t).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(t,a)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>i(e,n),o={module:{uri:n},exports:c,require:r};s[n]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/1966.1560d0f43ac9a41a.js",revision:"1560d0f43ac9a41a"},{url:"/_next/static/chunks/2900-1c471e20b1f84c4c.js",revision:"1c471e20b1f84c4c"},{url:"/_next/static/chunks/3794-cd98b5cd5fb65b19.js",revision:"cd98b5cd5fb65b19"},{url:"/_next/static/chunks/3899.cf10152d8085e352.js",revision:"cf10152d8085e352"},{url:"/_next/static/chunks/4bd1b696-bf5e0dbacfa5baef.js",revision:"bf5e0dbacfa5baef"},{url:"/_next/static/chunks/6585-31b952072a529d33.js",revision:"31b952072a529d33"},{url:"/_next/static/chunks/6622-1be3273582242e57.js",revision:"1be3273582242e57"},{url:"/_next/static/chunks/8500-41fa79ac743d83f1.js",revision:"41fa79ac743d83f1"},{url:"/_next/static/chunks/app/(dashboard)/administrator/page-91cba99603ea29ef.js",revision:"91cba99603ea29ef"},{url:"/_next/static/chunks/app/(dashboard)/events/page-5256a1760f698fdc.js",revision:"5256a1760f698fdc"},{url:"/_next/static/chunks/app/(dashboard)/layout-82e712147ce597af.js",revision:"82e712147ce597af"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-8823703af121bdbe.js",revision:"8823703af121bdbe"},{url:"/_next/static/chunks/app/(dashboard)/request/page-918972a23918d155.js",revision:"918972a23918d155"},{url:"/_next/static/chunks/app/(dashboard)/sga-spaces/page-fb0545d95e7660ab.js",revision:"fb0545d95e7660ab"},{url:"/_next/static/chunks/app/_global-error/page-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/_not-found/page-b09573fbbba3f839.js",revision:"b09573fbbba3f839"},{url:"/_next/static/chunks/app/api/administrator/archive/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/counts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/resend-invite/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/alerts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/display/%5BspaceId%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/checklist/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/my-rooms/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/complete/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/invalidate-otp/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/profile/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/revision-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/verify/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/command/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/connect/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/interaction/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/remaining-hours/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/by-ids/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/search/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/display/%5BspaceId%5D/page-415b8cd189fa7b18.js",revision:"415b8cd189fa7b18"},{url:"/_next/static/chunks/app/faq/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/layout-998eeaeab6160f77.js",revision:"998eeaeab6160f77"},{url:"/_next/static/chunks/app/legal/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/onboarding/page-07f581cfd59bd0d6.js",revision:"07f581cfd59bd0d6"},{url:"/_next/static/chunks/app/page-e2e6455dc296605e.js",revision:"e2e6455dc296605e"},{url:"/_next/static/chunks/app/reset-password/page-dce1b762e5bccf40.js",revision:"dce1b762e5bccf40"},{url:"/_next/static/chunks/app/signup/page-0539f393f05aca65.js",revision:"0539f393f05aca65"},{url:"/_next/static/chunks/app/slack/connect/page-60e22f90ab37aac7.js",revision:"60e22f90ab37aac7"},{url:"/_next/static/chunks/framework-a7f7b4d2dfa5296c.js",revision:"a7f7b4d2dfa5296c"},{url:"/_next/static/chunks/main-1dd03075465b2959.js",revision:"1dd03075465b2959"},{url:"/_next/static/chunks/main-app-2dfbcdcb699fb6b9.js",revision:"2dfbcdcb699fb6b9"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-5f32f9994882d039.js",revision:"5f32f9994882d039"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-c4a7f1b17b827393.js",revision:"c4a7f1b17b827393"},{url:"/_next/static/css/13748814dceb7d96.css",revision:"13748814dceb7d96"},{url:"/_next/static/css/a222c7856b03dcaa.css",revision:"a222c7856b03dcaa"},{url:"/_next/static/media/36966cca54120369-s.p.woff2",revision:"25ea4a783c12103f175f5b157b7d96aa"},{url:"/_next/static/media/b7387a63dd068245-s.woff2",revision:"dea099b7d5a5ea45bd4367f8aeff62ab"},{url:"/_next/static/media/e1aab0933260df4d-s.woff2",revision:"207f8e9f3761dbd724063a177d906a99"},{url:"/_next/static/nxKjdUM3-qI3bsmsSWNJy/_buildManifest.js",revision:"ece20b789087de2fb9a552ae39d909ab"},{url:"/_next/static/nxKjdUM3-qI3bsmsSWNJy/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icons/icon-192x192.png",revision:"d5bd341d0d1d9ce5ddcb8dd2fd15155d"},{url:"/icons/icon-512x512.png",revision:"38a6f129deef1207d9731d7cc6619583"},{url:"/manifest.json",revision:"940524417a2dd4c41cf0c95e3fb6cf9b"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/opsemaillogo.png",revision:"083cc51c4d24fca1abf11c3b69625815"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const i=(i,t)=>(i=new URL(i+".js",t).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(t,a)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>i(e,n),o={module:{uri:n},exports:c,require:r};s[n]=Promise.all(t.map(e=>o[e]||r(e))).then(e=>(a(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/1966.1560d0f43ac9a41a.js",revision:"1560d0f43ac9a41a"},{url:"/_next/static/chunks/2900-1c471e20b1f84c4c.js",revision:"1c471e20b1f84c4c"},{url:"/_next/static/chunks/3794-cd98b5cd5fb65b19.js",revision:"cd98b5cd5fb65b19"},{url:"/_next/static/chunks/3899.cf10152d8085e352.js",revision:"cf10152d8085e352"},{url:"/_next/static/chunks/4bd1b696-bf5e0dbacfa5baef.js",revision:"bf5e0dbacfa5baef"},{url:"/_next/static/chunks/6585-31b952072a529d33.js",revision:"31b952072a529d33"},{url:"/_next/static/chunks/6622-1be3273582242e57.js",revision:"1be3273582242e57"},{url:"/_next/static/chunks/8500-41fa79ac743d83f1.js",revision:"41fa79ac743d83f1"},{url:"/_next/static/chunks/app/(dashboard)/administrator/page-91cba99603ea29ef.js",revision:"91cba99603ea29ef"},{url:"/_next/static/chunks/app/(dashboard)/events/page-5256a1760f698fdc.js",revision:"5256a1760f698fdc"},{url:"/_next/static/chunks/app/(dashboard)/layout-780d860950ce7425.js",revision:"780d860950ce7425"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-8823703af121bdbe.js",revision:"8823703af121bdbe"},{url:"/_next/static/chunks/app/(dashboard)/request/page-918972a23918d155.js",revision:"918972a23918d155"},{url:"/_next/static/chunks/app/(dashboard)/sga-spaces/page-fb0545d95e7660ab.js",revision:"fb0545d95e7660ab"},{url:"/_next/static/chunks/app/_global-error/page-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/_not-found/page-b09573fbbba3f839.js",revision:"b09573fbbba3f839"},{url:"/_next/static/chunks/app/api/administrator/archive/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/counts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/membership-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/resend-invite/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/administrator/users/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/alerts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/display/%5BspaceId%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/checklist/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/events/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/me/settings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/my-rooms/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/bodies/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/complete/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/invalidate-otp/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/memberships/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/onboarding/profile/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/revision-requests/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/request/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/signup/verify/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/command/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/connect/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/slack/interaction/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/blackouts/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/bookings/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/%5Bid%5D/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/limit-overrides/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/remaining-hours/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/spaces/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/by-ids/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/api/users/search/route-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/app/display/%5BspaceId%5D/page-415b8cd189fa7b18.js",revision:"415b8cd189fa7b18"},{url:"/_next/static/chunks/app/faq/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/layout-998eeaeab6160f77.js",revision:"998eeaeab6160f77"},{url:"/_next/static/chunks/app/legal/page-a392dfa9de95e8a7.js",revision:"a392dfa9de95e8a7"},{url:"/_next/static/chunks/app/onboarding/page-07f581cfd59bd0d6.js",revision:"07f581cfd59bd0d6"},{url:"/_next/static/chunks/app/page-e2e6455dc296605e.js",revision:"e2e6455dc296605e"},{url:"/_next/static/chunks/app/reset-password/page-dce1b762e5bccf40.js",revision:"dce1b762e5bccf40"},{url:"/_next/static/chunks/app/signup/page-0539f393f05aca65.js",revision:"0539f393f05aca65"},{url:"/_next/static/chunks/app/slack/connect/page-60e22f90ab37aac7.js",revision:"60e22f90ab37aac7"},{url:"/_next/static/chunks/framework-a7f7b4d2dfa5296c.js",revision:"a7f7b4d2dfa5296c"},{url:"/_next/static/chunks/main-1dd03075465b2959.js",revision:"1dd03075465b2959"},{url:"/_next/static/chunks/main-app-2dfbcdcb699fb6b9.js",revision:"2dfbcdcb699fb6b9"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-5f32f9994882d039.js",revision:"5f32f9994882d039"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-5385d5e29fc2289b.js",revision:"5385d5e29fc2289b"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-c4a7f1b17b827393.js",revision:"c4a7f1b17b827393"},{url:"/_next/static/css/13748814dceb7d96.css",revision:"13748814dceb7d96"},{url:"/_next/static/css/a222c7856b03dcaa.css",revision:"a222c7856b03dcaa"},{url:"/_next/static/gLgpQLjv4WDoRt8WrPufZ/_buildManifest.js",revision:"ece20b789087de2fb9a552ae39d909ab"},{url:"/_next/static/gLgpQLjv4WDoRt8WrPufZ/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/media/36966cca54120369-s.p.woff2",revision:"25ea4a783c12103f175f5b157b7d96aa"},{url:"/_next/static/media/b7387a63dd068245-s.woff2",revision:"dea099b7d5a5ea45bd4367f8aeff62ab"},{url:"/_next/static/media/e1aab0933260df4d-s.woff2",revision:"207f8e9f3761dbd724063a177d906a99"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icons/icon-192x192.png",revision:"d5bd341d0d1d9ce5ddcb8dd2fd15155d"},{url:"/icons/icon-512x512.png",revision:"38a6f129deef1207d9731d7cc6619583"},{url:"/manifest.json",revision:"940524417a2dd4c41cf0c95e3fb6cf9b"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/opsemaillogo.png",revision:"083cc51c4d24fca1abf11c3b69625815"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:t})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});