From 15919f9e2d02482e71322d7632b2699672ec618c Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Tue, 25 Aug 2026 16:51:40 -0400 Subject: [PATCH] Cut per-navigation latency across the dashboard Page changes were doing a full app cold boot. Root cause was the sidebar nav rendering plain tags, which forces a full document navigation and re-runs every mount-time auth effect on every click. Client: - layout: nav links use next/link, so navigation is client-side and routes prefetch. The layout/AuthGuard effects now run once per session instead of once per page change. - layout: AuthGuard scoped to the content area so the sidebar paints immediately rather than the whole app staying blank during the check. - guards: render a skeleton instead of returning null. - sga-spaces: drop the duplicate AuthGuard (the layout already provides one), and stop refetching remaining-hours on every calendar week change. - my-rooms: /api/my-rooms now returns leadershipBodyIds, removing a browser-side auth + board_memberships round trip. - administrator: share counts with the layout via context instead of fetching the same endpoint twice per load. - bookings-tab: stop refetching bodies/semesters on the "show all" toggle. - memoise the Supabase browser client instead of rebuilding it per render. Auth: - add lib/auth.ts getAuthedUser(), backed by getClaims(). This project signs with ES256, so the JWT is verified locally against a cached JWKS rather than making a network call to the Auth server on every check. Migrated all 82 getUser() call sites. Signatures are still verified -- this is not getSession(). - middleware no longer runs on /api/**; every route authenticates itself, so that hop was a discarded round trip per API call. - drop 4 users-table lookups for admin_role, which is already a JWT claim. Server: - waitUntil() for post-commit emails (space booking confirm/cancel, blackout cascades, admin booking updated/missed) so users stop waiting on Resend. - Promise.all independent queries in /api/me/settings and /api/request. - rate limiter: add ephemeralCache; split signupRateLimiter into its own module so ~40 routes stop constructing a second Redis client. Also includes the two RLS migrations applied earlier (auth initplan wrap, permissive policy consolidation). --- .gitignore | 3 + app/(dashboard)/adminguard.tsx | 10 +- .../administrator/booking-settings-tab.tsx | 3 +- .../administrator/bookings-tab.tsx | 13 +- app/(dashboard)/administrator/page.tsx | 22 +- app/(dashboard)/administrator/users-tab.tsx | 3 +- app/(dashboard)/authguard.tsx | 10 +- app/(dashboard)/counts-context.tsx | 38 ++++ app/(dashboard)/eventsguard.tsx | 10 +- app/(dashboard)/layout.tsx | 82 ++++--- app/(dashboard)/my-rooms/page.tsx | 16 +- app/(dashboard)/request/page.tsx | 5 +- app/(dashboard)/sga-spaces/page.tsx | 41 ++-- app/_components/LoginCard.tsx | 3 +- app/_components/skeleton.tsx | 19 ++ app/api/administrator/archive/route.ts | 3 +- app/api/administrator/audit-logs/route.ts | 3 +- app/api/administrator/bodies/route.ts | 7 +- .../administrator/bookings/cancel/route.ts | 3 +- .../administrator/bookings/one-time/route.ts | 104 +++++---- app/api/administrator/bookings/route.ts | 5 +- .../administrator/bookings/tabling/route.ts | 104 +++++---- .../administrator/bookings/weekly/route.ts | 104 +++++---- app/api/administrator/cancellations/route.ts | 5 +- app/api/administrator/counts/route.ts | 3 +- .../membership-requests/[id]/route.ts | 3 +- .../membership-requests/route.ts | 3 +- .../administrator/requests/bookings/route.ts | 3 +- app/api/administrator/requests/route.ts | 5 +- app/api/administrator/revisions/route.ts | 3 +- app/api/administrator/semesters/route.ts | 11 +- app/api/administrator/settings/route.ts | 5 +- .../administrator/users/memberships/route.ts | 7 +- .../users/resend-invite/route.ts | 3 +- app/api/administrator/users/route.ts | 15 +- app/api/alerts/route.ts | 5 +- app/api/cancellation-requests/route.ts | 3 +- app/api/events/checklist/route.ts | 3 +- app/api/events/route.ts | 3 +- app/api/me/memberships/route.ts | 5 +- app/api/me/requests/route.ts | 3 +- app/api/me/settings/route.ts | 47 ++-- app/api/my-rooms/route.ts | 14 +- app/api/onboarding/bodies/route.ts | 3 +- app/api/onboarding/complete/route.ts | 3 +- app/api/onboarding/invalidate-otp/route.ts | 3 +- app/api/onboarding/memberships/route.ts | 3 +- app/api/onboarding/profile/route.ts | 3 +- app/api/request/route.ts | 57 ++--- app/api/revision-requests/route.ts | 3 +- app/api/signup/request/route.ts | 2 +- app/api/slack/connect/route.ts | 3 +- app/api/spaces/blackouts/[id]/route.ts | 41 ++-- app/api/spaces/blackouts/route.ts | 44 ++-- app/api/spaces/bookings/[id]/route.ts | 64 +++--- app/api/spaces/bookings/route.ts | 50 +++-- app/api/spaces/limit-overrides/[id]/route.ts | 3 +- app/api/spaces/limit-overrides/route.ts | 5 +- app/api/spaces/remaining-hours/route.ts | 3 +- app/api/spaces/route.ts | 3 +- app/api/users/by-ids/route.ts | 3 +- app/api/users/search/route.ts | 3 +- app/slack/connect/page.tsx | 5 +- lib/auth.ts | 42 ++++ lib/rate-limit.ts | 11 +- lib/signup-rate-limit.ts | 12 ++ middleware.ts | 6 +- public/sw.js | 2 +- .../20260825000000_rls_auth_initplan_fix.sql | 92 ++++++++ ...nsolidate_multiple_permissive_policies.sql | 203 ++++++++++++++++++ 70 files changed, 986 insertions(+), 446 deletions(-) create mode 100644 app/(dashboard)/counts-context.tsx create mode 100644 lib/auth.ts create mode 100644 lib/signup-rate-limit.ts create mode 100644 supabase/migrations/20260825000000_rls_auth_initplan_fix.sql create mode 100644 supabase/migrations/20260825010000_consolidate_multiple_permissive_policies.sql diff --git a/.gitignore b/.gitignore index 9c8a03f..b1e30e0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* next-env.d.ts .vercel + +# Supabase CLI scratch +supabase/.temp/ diff --git a/app/(dashboard)/adminguard.tsx b/app/(dashboard)/adminguard.tsx index df2f97c..1a8cf2a 100644 --- a/app/(dashboard)/adminguard.tsx +++ b/app/(dashboard)/adminguard.tsx @@ -1,17 +1,19 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' +import { getAuthedUser } from '@/lib/auth' +import { PageSkeleton } from '@/app/_components/skeleton' export default function AdminGuard({ children }: { children: React.ReactNode }) { const [checking, setChecking] = useState(true) const router = useRouter() - const supabase = createClient() + const supabase = useMemo(() => createClient(), []) useEffect(() => { const checkAdmin = async () => { - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { router.push('/my-rooms') } else { @@ -21,7 +23,7 @@ export default function AdminGuard({ children }: { children: React.ReactNode }) checkAdmin() }, []) - if (checking) return null + if (checking) return return <>{children} } \ No newline at end of file diff --git a/app/(dashboard)/administrator/booking-settings-tab.tsx b/app/(dashboard)/administrator/booking-settings-tab.tsx index f7c1e68..67ea04f 100644 --- a/app/(dashboard)/administrator/booking-settings-tab.tsx +++ b/app/(dashboard)/administrator/booking-settings-tab.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { createBrowserClient } from '@supabase/ssr' +import { getAuthedUser } from '@/lib/auth' import { Skeleton } from '@/app/_components/skeleton' function BookingSettingsTabSkeleton() { @@ -87,7 +88,7 @@ export default function BookingSettingsTab() { process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) - supabase.auth.getUser().then(({ data: { user } }) => { + getAuthedUser(supabase).then((user) => { if ( user?.app_metadata?.admin_role === 'Vice President of Operational Affairs' || user?.app_metadata?.admin_role === 'Executive Vice President' || diff --git a/app/(dashboard)/administrator/bookings-tab.tsx b/app/(dashboard)/administrator/bookings-tab.tsx index 6c1dc2f..9d364f8 100644 --- a/app/(dashboard)/administrator/bookings-tab.tsx +++ b/app/(dashboard)/administrator/bookings-tab.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import BookingModal from './booking-modal' import AdminCancelModal from './admin-cancel-modal' import OneTimeForm from './one-time-form' @@ -218,10 +218,17 @@ export default function BookingsTab() { }) } + const staticDataLoaded = useRef(false) + useEffect(() => { fetchBookings(showAll) - fetchBodies() - fetchSemesters() + // Bodies and semesters don't depend on the "show all" toggle, so they only + // need fetching once per mount rather than on every toggle. + if (!staticDataLoaded.current) { + staticDataLoaded.current = true + fetchBodies() + fetchSemesters() + } }, [showAll]) const sortedWeekly = loading ? [] : [...weekly].sort((a, b) => { diff --git a/app/(dashboard)/administrator/page.tsx b/app/(dashboard)/administrator/page.tsx index 8c90b73..c56a8fc 100644 --- a/app/(dashboard)/administrator/page.tsx +++ b/app/(dashboard)/administrator/page.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState } from 'react' import AdminGuard from '../adminguard' +import { useCounts } from '../counts-context' import RequestsTab from './requests-tab' import CancellationsTab from './cancellations-tab' import BookingsTab from './bookings-tab' @@ -12,18 +13,9 @@ type Tab = 'Requests' | 'Cancellations' | 'Bookings' | 'SGA Spaces' | 'Advanced export default function AdministratorPage() { const [activeTab, setActiveTab] = useState('Bookings') - const [counts, setCounts] = useState({ requests: 0, cancellations: 0, revisions: 0, total: 0 }) - - useEffect(() => { - const fetchCounts = async () => { - const res = await fetch('/api/administrator/counts') - if (res.ok) { - const data = await res.json() - setCounts(data) - } - } - fetchCounts() - }, []) + // Shared with the layout's sidebar badge instead of refetching the same + // endpoint on every Administrator page load. + const { counts, refreshCounts } = useCounts() const tabBadge = (tab: Tab) => { if (tab === 'Requests') return counts.requests + counts.revisions @@ -58,8 +50,8 @@ export default function AdministratorPage() {
- {activeTab === 'Requests' && fetch('/api/administrator/counts').then(r => r.json()).then(d => setCounts(d))} />} - {activeTab === 'Cancellations' && fetch('/api/administrator/counts').then(r => r.json()).then(d => setCounts(d))} />} + {activeTab === 'Requests' && } + {activeTab === 'Cancellations' && } {activeTab === 'Bookings' && } {activeTab === 'SGA Spaces' && } {activeTab === 'Advanced Settings' && } diff --git a/app/(dashboard)/administrator/users-tab.tsx b/app/(dashboard)/administrator/users-tab.tsx index d14b178..1d154cc 100644 --- a/app/(dashboard)/administrator/users-tab.tsx +++ b/app/(dashboard)/administrator/users-tab.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { Skeleton } from '@/app/_components/skeleton' import { createClient } from '@/lib/supabase/client' +import { getAuthedUser } from '@/lib/auth' function UsersTabSkeleton() { return ( @@ -124,7 +125,7 @@ export default function UsersTab() { fetchBodies() fetchMembershipRequests() const supabase = createClient() - supabase.auth.getUser().then(({ data: { user } }) => { + getAuthedUser(supabase).then((user) => { setCurrentUserRole(user?.app_metadata?.admin_role ?? null) }) }, []) diff --git a/app/(dashboard)/authguard.tsx b/app/(dashboard)/authguard.tsx index 035587d..e099823 100644 --- a/app/(dashboard)/authguard.tsx +++ b/app/(dashboard)/authguard.tsx @@ -1,17 +1,19 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' +import { getAuthedUser } from '@/lib/auth' +import { PageSkeleton } from '@/app/_components/skeleton' export default function AuthGuard({ children }: { children: React.ReactNode }) { const [checking, setChecking] = useState(true) const router = useRouter() - const supabase = createClient() + const supabase = useMemo(() => createClient(), []) useEffect(() => { const checkAuth = async () => { - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { router.push('/') return @@ -39,7 +41,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) { checkAuth() }, []) - if (checking) return null + if (checking) return return <>{children} } \ No newline at end of file diff --git a/app/(dashboard)/counts-context.tsx b/app/(dashboard)/counts-context.tsx new file mode 100644 index 0000000..9bb2be4 --- /dev/null +++ b/app/(dashboard)/counts-context.tsx @@ -0,0 +1,38 @@ +'use client' + +import { createContext, useContext } from 'react' + +export type Counts = { + requests: number + cancellations: number + revisions: number + membership_requests: number + total: number +} + +export const EMPTY_COUNTS: Counts = { + requests: 0, + cancellations: 0, + revisions: 0, + membership_requests: 0, + total: 0, +} + +type CountsContextValue = { + counts: Counts + refreshCounts: () => void +} + +/** + * The dashboard layout already fetches /api/administrator/counts for the sidebar + * badge. Sharing it here stops the Administrator page from fetching the exact + * same endpoint a second time on every load. + */ +export const CountsContext = createContext({ + counts: EMPTY_COUNTS, + refreshCounts: () => {}, +}) + +export function useCounts() { + return useContext(CountsContext) +} diff --git a/app/(dashboard)/eventsguard.tsx b/app/(dashboard)/eventsguard.tsx index 8e852ac..6f85df2 100644 --- a/app/(dashboard)/eventsguard.tsx +++ b/app/(dashboard)/eventsguard.tsx @@ -1,17 +1,19 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' +import { getAuthedUser } from '@/lib/auth' +import { PageSkeleton } from '@/app/_components/skeleton' export default function EventsGuard({ children }: { children: React.ReactNode }) { const [checking, setChecking] = useState(true) const router = useRouter() - const supabase = createClient() + const supabase = useMemo(() => createClient(), []) useEffect(() => { const checkAccess = async () => { - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || (!user.app_metadata?.is_admin && !user.app_metadata?.iems_role)) { router.push('/my-rooms') } else { @@ -21,7 +23,7 @@ export default function EventsGuard({ children }: { children: React.ReactNode }) checkAccess() }, []) - if (checking) return null + if (checking) return return <>{children} } diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 80da6a4..b66325e 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,11 +1,13 @@ 'use client' -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useRouter, usePathname } from 'next/navigation' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' import AuthGuard from './authguard' import SettingsModal, { type Settings as SettingsData } from './settings-modal' +import { CountsContext, EMPTY_COUNTS, type Counts } from './counts-context' +import { getAuthedUser } from '@/lib/auth' function getGreeting() { const hour = new Date().getHours() @@ -22,7 +24,7 @@ export default function DashboardLayout({ const [isAdmin, setIsAdmin] = useState(false) const [isIEMS, setIsIEMS] = useState(false) const [isLeadership, setIsLeadership] = useState(false) - const [counts, setCounts] = useState({ requests: 0, cancellations: 0, total: 0 }) + const [counts, setCounts] = useState(EMPTY_COUNTS) const [userName, setUserName] = useState('') const [showIdleWarning, setShowIdleWarning] = useState(false) const [idleCountdown, setIdleCountdown] = useState(60) @@ -31,47 +33,50 @@ export default function DashboardLayout({ const [settingsCache, setSettingsCache] = useState(null) const router = useRouter() const pathname = usePathname() - const supabase = createClient() + // Memoised so the effect below has a stable dependency and we don't build a + // fresh GoTrue client on every render. + const supabase = useMemo(() => createClient(), []) const idleTimerRef = useRef | null>(null) const countdownIntervalRef = useRef | null>(null) + const fetchCounts = useCallback(async () => { + const res = await fetch('/api/administrator/counts') + if (res.ok) { + const data = await res.json() + setCounts(data) + } + }, []) + useEffect(() => { const checkUser = async () => { - const { data: { user } } = await supabase.auth.getUser() - if (user?.app_metadata?.is_admin) { + const user = await getAuthedUser(supabase) + if (!user) return + + if (user.app_metadata?.is_admin) { setIsAdmin(true) fetchCounts() } - if (user?.app_metadata?.iems_role) { + if (user.app_metadata?.iems_role) { setIsIEMS(true) } - const { data: profile } = await supabase - .from('users') - .select('full_name') - .eq('id', user?.id) - .single() - if (profile?.full_name) setUserName(profile.full_name) - - const { data: memberships } = await supabase - .from('board_memberships') - .select('role') - .eq('user_id', user?.id) - .eq('role', 'Leadership') - .limit(1) + // These two reads only need the user id, so run them together instead of + // back to back. + const [{ data: profile }, { data: memberships }] = await Promise.all([ + supabase.from('users').select('full_name').eq('id', user.id).single(), + supabase + .from('board_memberships') + .select('role') + .eq('user_id', user.id) + .eq('role', 'Leadership') + .limit(1), + ]) + if (profile?.full_name) setUserName(profile.full_name) if (memberships && memberships.length > 0) setIsLeadership(true) } checkUser() - }, []) - - const fetchCounts = async () => { - const res = await fetch('/api/administrator/counts') - if (res.ok) { - const data = await res.json() - setCounts(data) - } - } + }, [fetchCounts, supabase]) const handleLogout = async () => { localStorage.removeItem('chambers_last_active') @@ -158,10 +163,15 @@ export default function DashboardLayout({ } }, []) + const countsValue = useMemo( + () => ({ counts, refreshCounts: fetchCounts }), + [counts, fetchCounts] + ) + const navLink = (href: string, label: string, badge?: number) => { const isActive = pathname === href || pathname.startsWith(href + '/') return ( - setSidebarOpen(false)} className={`group relative flex items-center justify-between px-4 py-2.5 rounded-lg text-sm font-medium overflow-hidden transition-colors ${ @@ -179,12 +189,12 @@ export default function DashboardLayout({ {badge} ) : null} - + ) } return ( - + <> {showIdleWarning && (
@@ -287,10 +297,16 @@ export default function DashboardLayout({
- {children} + {/* Scoped to the content area so the sidebar paints immediately + instead of the whole app staying blank during the auth check. */} + + + {children} + +
{showSettings && setShowSettings(false)} cachedSettings={settingsCache} onSettingsLoaded={setSettingsCache} />} - + ) } \ No newline at end of file diff --git a/app/(dashboard)/my-rooms/page.tsx b/app/(dashboard)/my-rooms/page.tsx index 40b147b..962a055 100644 --- a/app/(dashboard)/my-rooms/page.tsx +++ b/app/(dashboard)/my-rooms/page.tsx @@ -5,7 +5,6 @@ import CancelModal from './cancel-modal' import RevisionModal from './revision-modal' import BookingDetailModal from './booking-detail-modal' import NotificationBell from './notification-bell' -import {createClient} from "@/lib/supabase/client" import { Skeleton } from '@/app/_components/skeleton' function MyRoomsSkeleton() { @@ -165,19 +164,12 @@ export default function MyRoomsPage() { const fetchBookings = async () => { setLoading(true) - const supabase = createClient() - const [res, { data: { user } }] = await Promise.all([ - fetch('/api/my-rooms'), - supabase.auth.getUser(), - ]) + // /api/my-rooms already resolves the caller's Leadership bodies, so this + // no longer needs a second round trip to auth + board_memberships. + const res = await fetch('/api/my-rooms') const data = await res.json() - const { data: memberships } = await supabase - .from('board_memberships') - .select('body_id') - .eq('user_id', user?.id) - .eq('role', 'Leadership') - setLeadershipBodyIds(memberships?.map(m => m.body_id) || []) + setLeadershipBodyIds(data.leadershipBodyIds || []) const flat: FlatBooking[] = [] diff --git a/app/(dashboard)/request/page.tsx b/app/(dashboard)/request/page.tsx index 76238f8..fdffcb1 100644 --- a/app/(dashboard)/request/page.tsx +++ b/app/(dashboard)/request/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' +import { getAuthedUser } from '@/lib/auth' import TimePicker from '../administrator/time-picker' import { Skeleton } from '@/app/_components/skeleton' @@ -187,8 +188,8 @@ export default function RequestPage() { useEffect(() => { const fetchBodies = async () => { const supabase = createClient() - const [{ data: { user } }, res] = await Promise.all([ - supabase.auth.getUser(), + const [user, res] = await Promise.all([ + getAuthedUser(supabase), fetch('/api/request'), ]) const isAdmin = user?.app_metadata?.is_admin ?? false diff --git a/app/(dashboard)/sga-spaces/page.tsx b/app/(dashboard)/sga-spaces/page.tsx index 65cca1c..89fe030 100644 --- a/app/(dashboard)/sga-spaces/page.tsx +++ b/app/(dashboard)/sga-spaces/page.tsx @@ -1,11 +1,11 @@ 'use client' import { useState, useEffect, useCallback } from 'react' -import AuthGuard from '../authguard' import SpaceCalendar from './space-calendar' import SpaceBookingModal from './space-booking-modal' import { Skeleton } from '@/app/_components/skeleton' import { createClient } from '@/lib/supabase/client' +import { getAuthedUser } from '@/lib/auth' interface Space { id: string @@ -171,7 +171,7 @@ export default function SGASpacesPage() { useEffect(() => { const supabase = createClient() - supabase.auth.getUser().then(async ({ data: { user } }) => { + getAuthedUser(supabase).then(async (user) => { if (!user) return if (user.app_metadata?.is_admin) setIsAdmin(true) const { data: memberships } = await supabase @@ -194,16 +194,22 @@ export default function SGASpacesPage() { .finally(() => setSpacesLoading(false)) }, []) + // Remaining hours only change when the user books or cancels -- not when the + // calendar week changes. Keying this off `bookings` refetched it on every week + // navigation and fired it twice on mount (once with the initial empty array). + const fetchRemainingHours = useCallback(async () => { + const res = await fetch('/api/spaces/remaining-hours') + if (!res.ok) return + const data = await res.json() + setRemainingHours(data.remaining) + setLimitHours(data.limit) + if (data.user_id) setCurrentUserId(data.user_id) + if (data.min_hours_advance != null) setMinHoursAdvance(data.min_hours_advance) + }, []) + useEffect(() => { - fetch('/api/spaces/remaining-hours') - .then(r => r.json()) - .then(data => { - setRemainingHours(data.remaining) - setLimitHours(data.limit) - if (data.user_id) setCurrentUserId(data.user_id) - if (data.min_hours_advance != null) setMinHoursAdvance(data.min_hours_advance) - }) - }, [bookings]) + fetchRemainingHours() + }, [fetchRemainingHours]) const fetchCalendarData = useCallback(async () => { if (!selectedSpaceId) return @@ -270,15 +276,11 @@ export default function SGASpacesPage() { const selectedSpace = spaces.find(s => s.id === selectedSpaceId) if (spacesLoading) { - return ( - - - - ) + return } return ( - + <>

SGA Spaces

@@ -372,6 +374,7 @@ export default function SGASpacesPage() { onSuccess={() => { setModalSlot(null) fetchCalendarData() + fetchRemainingHours() }} spaces={spaces} /> @@ -391,6 +394,7 @@ export default function SGASpacesPage() { onSuccess={() => { setEditBooking(null) fetchCalendarData() + fetchRemainingHours() }} onCancelBooking={editBooking.creatorId === currentUserId ? async () => { const res = await fetch(`/api/spaces/bookings/${editBooking.id}`, { method: 'DELETE' }) @@ -400,10 +404,11 @@ export default function SGASpacesPage() { } setEditBooking(null) fetchCalendarData() + fetchRemainingHours() } : undefined} /> )}
- + ) } diff --git a/app/_components/LoginCard.tsx b/app/_components/LoginCard.tsx index 74ddad7..9acbdac 100644 --- a/app/_components/LoginCard.tsx +++ b/app/_components/LoginCard.tsx @@ -5,6 +5,7 @@ import { createPortal } from 'react-dom' import { createClient } from '@/lib/supabase/client' import { useRouter } from 'next/navigation' import Link from 'next/link' +import { getAuthedUser } from '@/lib/auth' export default function LoginCard() { const [email, setEmail] = useState('') @@ -20,7 +21,7 @@ export default function LoginCard() { useEffect(() => { const checkAuth = async () => { - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (user) { const { data: profile } = await supabase .from('users') diff --git a/app/_components/skeleton.tsx b/app/_components/skeleton.tsx index 6c9fb46..4958d52 100644 --- a/app/_components/skeleton.tsx +++ b/app/_components/skeleton.tsx @@ -3,3 +3,22 @@ export function Skeleton({ className }: { className?: string }) {
) } + +/** + * Neutral placeholder rendered while a route guard verifies access. Guards + * previously returned null here, which left the content area blank for the + * whole auth round trip. + */ +export function PageSkeleton() { + return ( + + ) +} diff --git a/app/api/administrator/archive/route.ts b/app/api/administrator/archive/route.ts index ef67b96..ee5fb06 100644 --- a/app/api/administrator/archive/route.ts +++ b/app/api/administrator/archive/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/audit-logs/route.ts b/app/api/administrator/audit-logs/route.ts index eaa435d..f9dbdc5 100644 --- a/app/api/administrator/audit-logs/route.ts +++ b/app/api/administrator/audit-logs/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/bodies/route.ts b/app/api/administrator/bodies/route.ts index f2e1f39..d53ced7 100644 --- a/app/api/administrator/bodies/route.ts +++ b/app/api/administrator/bodies/route.ts @@ -1,11 +1,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -24,7 +25,7 @@ export async function GET() { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -46,7 +47,7 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/bookings/cancel/route.ts b/app/api/administrator/bookings/cancel/route.ts index 44f49c4..ecc2256 100644 --- a/app/api/administrator/bookings/cancel/route.ts +++ b/app/api/administrator/bookings/cancel/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/bookings/one-time/route.ts b/app/api/administrator/bookings/one-time/route.ts index bf11d27..0d2e0e8 100644 --- a/app/api/administrator/bookings/one-time/route.ts +++ b/app/api/administrator/bookings/one-time/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server' import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-reservation' import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -22,7 +24,7 @@ interface OneTimeSession { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -42,16 +44,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid semester.' }, { status: 400 }) } - const { data: userData } = await adminSupabase - .from('users') - .select('admin_role') - .eq('id', user.id) - .single() + // 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 // 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: userData?.admin_role ?? null, semester_id }) + .insert({ body_id, purpose, type: 'One-Time Room', created_by: user.id, creator_role: creatorRole, semester_id }) .select() .single() @@ -80,7 +80,7 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -154,24 +154,30 @@ export async function PATCH(request: Request) { ) } - 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[] - await sendBookingUpdatedEmail({ - bodyName, - roomOrTable: firstSession.room_name || 'N/A', - date: firstSession.booking_date, - startTime: firstSession.start_time, - endTime: firstSession.end_time, - status: firstSession.status, - recipients: emails, - }) - } catch (e) { - console.error('Booking updated email failed:', e) - } + // Notification only -- the booking is already written, so don't hold the + // admin's response open for a Resend round trip. + 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[] + await sendBookingUpdatedEmail({ + bodyName, + roomOrTable: firstSession.room_name || 'N/A', + date: firstSession.booking_date, + startTime: firstSession.start_time, + endTime: firstSession.end_time, + status: firstSession.status, + recipients: emails, + }) + } catch (e) { + console.error('Booking updated email failed:', e) + } + })() + ) // Resolve any pending revision request for this booking await adminSupabase @@ -181,27 +187,31 @@ export async function PATCH(request: Request) { .eq('status', 'Pending') if (firstSession.status === 'Missed') { - 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[] - - await sendMissedReservationEmail({ - bodyName, - date: formatDateLong(firstSession.booking_date), - startTime: firstSession.start_time, - endTime: firstSession.end_time, - contacts, - }) - } catch (e) { - console.error('Resend email failed:', e) - } + 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[] + + await sendMissedReservationEmail({ + bodyName, + date: formatDateLong(firstSession.booking_date), + startTime: firstSession.start_time, + endTime: firstSession.end_time, + contacts, + }) + } catch (e) { + console.error('Resend email failed:', e) + } + })() + ) } return NextResponse.json({ success: true }) diff --git a/app/api/administrator/bookings/route.ts b/app/api/administrator/bookings/route.ts index d7fccfc..dfe359a 100644 --- a/app/api/administrator/bookings/route.ts +++ b/app/api/administrator/bookings/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -89,7 +90,7 @@ export async function GET(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/bookings/tabling/route.ts b/app/api/administrator/bookings/tabling/route.ts index da5423e..bd09cf7 100644 --- a/app/api/administrator/bookings/tabling/route.ts +++ b/app/api/administrator/bookings/tabling/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server' import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-reservation' import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -13,7 +15,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -33,16 +35,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid semester.' }, { status: 400 }) } - const { data: userData } = await adminSupabase - .from('users') - .select('admin_role') - .eq('id', user.id) - .single() + // 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 // Create parent booking const { data: booking, error: bookingError } = await adminSupabase .from('bookings') - .insert({ body_id, purpose, type: 'Tabling', created_by: user.id, creator_role: userData?.admin_role ?? null, semester_id }) + .insert({ body_id, purpose, type: 'Tabling', created_by: user.id, creator_role: creatorRole, semester_id }) .select() .single() @@ -96,7 +96,7 @@ interface Session { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -176,24 +176,30 @@ export async function PATCH(request: Request) { ) } - 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[] - await sendBookingUpdatedEmail({ - bodyName, - roomOrTable: sessions[0]?.location || 'N/A', - date: sessions[0]?.session_date ?? '', - startTime: sessions[0]?.start_time ?? '', - endTime: sessions[0]?.end_time ?? '', - status: statusSummary, - recipients: emails, - }) - } catch (e) { - console.error('Booking updated email failed:', e) - } + // Notification only -- the booking is already written, so don't hold the + // admin's response open for a Resend round trip. + 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[] + await sendBookingUpdatedEmail({ + bodyName, + roomOrTable: sessions[0]?.location || 'N/A', + date: sessions[0]?.session_date ?? '', + startTime: sessions[0]?.start_time ?? '', + endTime: sessions[0]?.end_time ?? '', + status: statusSummary, + recipients: emails, + }) + } catch (e) { + console.error('Booking updated email failed:', e) + } + })() + ) // Resolve any pending revision request for this booking await adminSupabase @@ -203,27 +209,31 @@ export async function PATCH(request: Request) { .eq('status', 'Pending') if (sessions.some((s: Session) => s.status === 'Missed')) { - 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[] - - await sendMissedReservationEmail({ - bodyName, - date: formatDateLong(sessions[0].session_date), - startTime: sessions[0].start_time, - endTime: sessions[0].end_time, - contacts, - }) - } catch (e) { - console.error('Resend email failed:', e) - } + 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[] + + await sendMissedReservationEmail({ + bodyName, + date: formatDateLong(sessions[0].session_date), + startTime: sessions[0].start_time, + endTime: sessions[0].end_time, + contacts, + }) + } catch (e) { + console.error('Resend email failed:', e) + } + })() + ) } return NextResponse.json({ success: true }) diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 8ba6e28..51e0fe6 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server' import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-reservation' import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -26,7 +28,7 @@ function getWeeklyDates(startDate: string, endDate: string): string[] { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -46,16 +48,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid semester.' }, { status: 400 }) } - const { data: userData } = await adminSupabase - .from('users') - .select('admin_role') - .eq('id', user.id) - .single() + // 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 // 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: userData?.admin_role ?? null, semester_id }) + .insert({ body_id, purpose, type: 'Weekly Room', created_by: user.id, creator_role: creatorRole, semester_id }) .select() .single() @@ -89,7 +89,7 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -176,24 +176,30 @@ export async function PATCH(request: Request) { ) } - 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[] - await sendBookingUpdatedEmail({ - bodyName, - roomOrTable: room_name || 'N/A', - date: start_date, - startTime: start_time, - endTime: end_time, - status, - recipients: emails, - }) - } catch (e) { - console.error('Booking updated email failed:', e) - } + // Notification only -- the booking is already written, so don't hold the + // admin's response open for a Resend round trip. + 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[] + await sendBookingUpdatedEmail({ + bodyName, + roomOrTable: room_name || 'N/A', + date: start_date, + startTime: start_time, + endTime: end_time, + status, + recipients: emails, + }) + } catch (e) { + console.error('Booking updated email failed:', e) + } + })() + ) // Resolve any pending revision request for this booking await adminSupabase @@ -204,27 +210,31 @@ export async function PATCH(request: Request) { const isMissed = status === 'Missed' || occurrences.some((o: { status: string | null }) => o.status === 'Missed') if (isMissed) { - 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[] - - await sendMissedReservationEmail({ - bodyName, - date: formatDateLong(start_date), - startTime: start_time, - endTime: end_time, - contacts, - }) - } catch (e) { - console.error('Resend email failed:', e) - } + 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[] + + await sendMissedReservationEmail({ + bodyName, + date: formatDateLong(start_date), + startTime: start_time, + endTime: end_time, + contacts, + }) + } catch (e) { + console.error('Resend email failed:', e) + } + })() + ) } return NextResponse.json({ success: true }) diff --git a/app/api/administrator/cancellations/route.ts b/app/api/administrator/cancellations/route.ts index b08e18f..668646d 100644 --- a/app/api/administrator/cancellations/route.ts +++ b/app/api/administrator/cancellations/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -97,7 +98,7 @@ export async function GET() { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/counts/route.ts b/app/api/administrator/counts/route.ts index 5b9465a..39896a9 100644 --- a/app/api/administrator/counts/route.ts +++ b/app/api/administrator/counts/route.ts @@ -1,11 +1,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/membership-requests/[id]/route.ts b/app/api/administrator/membership-requests/[id]/route.ts index d84d692..d8ce2ba 100644 --- a/app/api/administrator/membership-requests/[id]/route.ts +++ b/app/api/administrator/membership-requests/[id]/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/membership-requests/route.ts b/app/api/administrator/membership-requests/route.ts index 6e68132..d93223a 100644 --- a/app/api/administrator/membership-requests/route.ts +++ b/app/api/administrator/membership-requests/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/requests/bookings/route.ts b/app/api/administrator/requests/bookings/route.ts index b11759b..d7e0d50 100644 --- a/app/api/administrator/requests/bookings/route.ts +++ b/app/api/administrator/requests/bookings/route.ts @@ -1,11 +1,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/requests/route.ts b/app/api/administrator/requests/route.ts index b8af38b..c4e7e9e 100644 --- a/app/api/administrator/requests/route.ts +++ b/app/api/administrator/requests/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -36,7 +37,7 @@ export async function GET() { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/revisions/route.ts b/app/api/administrator/revisions/route.ts index fee59a8..5ac3acf 100644 --- a/app/api/administrator/revisions/route.ts +++ b/app/api/administrator/revisions/route.ts @@ -1,11 +1,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/semesters/route.ts b/app/api/administrator/semesters/route.ts index d58a2dc..ca1c78b 100644 --- a/app/api/administrator/semesters/route.ts +++ b/app/api/administrator/semesters/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -29,7 +30,7 @@ export async function GET() { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -55,7 +56,7 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -87,7 +88,7 @@ export async function PATCH(request: Request) { export async function DELETE(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -97,7 +98,7 @@ export async function DELETE(request: Request) { 'Executive Vice President', 'Information Manager', ] - if (!semesterManagers.includes(user.app_metadata?.admin_role)) { + if (!semesterManagers.includes(user.app_metadata?.admin_role ?? '')) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } diff --git a/app/api/administrator/settings/route.ts b/app/api/administrator/settings/route.ts index 6c8bd15..2b430a7 100644 --- a/app/api/administrator/settings/route.ts +++ b/app/api/administrator/settings/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -33,7 +34,7 @@ export async function GET() { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/users/memberships/route.ts b/app/api/administrator/users/memberships/route.ts index ac7b69d..dfc74f8 100644 --- a/app/api/administrator/users/memberships/route.ts +++ b/app/api/administrator/users/memberships/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -33,7 +34,7 @@ export async function POST(request: Request) { export async function DELETE(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -56,7 +57,7 @@ export async function DELETE(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/users/resend-invite/route.ts b/app/api/administrator/users/resend-invite/route.ts index 3d75da1..b02dd9f 100644 --- a/app/api/administrator/users/resend-invite/route.ts +++ b/app/api/administrator/users/resend-invite/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { randomBytes, createHash } from 'crypto' import { sendOtpInviteEmail } from '@/lib/emails/otp-invite' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -13,7 +14,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/administrator/users/route.ts b/app/api/administrator/users/route.ts index 9ae2a8e..49fa17b 100644 --- a/app/api/administrator/users/route.ts +++ b/app/api/administrator/users/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { randomBytes, createHash } from 'crypto' import { sendOtpInviteEmail } from '@/lib/emails/otp-invite' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -20,7 +21,7 @@ const ROLE_EDITORS = [ export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -46,7 +47,7 @@ export async function GET() { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -120,7 +121,7 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -132,12 +133,8 @@ export async function PATCH(request: Request) { const { id } = body if ('admin_role' in body || 'iems_role' in body) { - const { data: requesterRow } = await adminSupabase - .from('users') - .select('admin_role') - .eq('id', user.id) - .single() - if (!requesterRow || !ROLE_EDITORS.includes(requesterRow.admin_role ?? '')) { + // admin_role is a claim on the verified JWT; no users-table lookup needed. + if (!ROLE_EDITORS.includes(user.app_metadata?.admin_role ?? '')) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } diff --git a/app/api/alerts/route.ts b/app/api/alerts/route.ts index e89a07a..faa2494 100644 --- a/app/api/alerts/route.ts +++ b/app/api/alerts/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -9,7 +10,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { data } = await adminSupabase @@ -24,7 +25,7 @@ export async function GET() { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const body = await request.json() diff --git a/app/api/cancellation-requests/route.ts b/app/api/cancellation-requests/route.ts index da10738..f553523 100644 --- a/app/api/cancellation-requests/route.ts +++ b/app/api/cancellation-requests/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) diff --git a/app/api/events/checklist/route.ts b/app/api/events/checklist/route.ts index 03a60d9..8275fed 100644 --- a/app/api/events/checklist/route.ts +++ b/app/api/events/checklist/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || (!user.app_metadata?.is_admin && !user.app_metadata?.iems_role)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 7be4738..67208b1 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,11 +1,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || (!user.app_metadata?.is_admin && !user.app_metadata?.iems_role)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/me/memberships/route.ts b/app/api/me/memberships/route.ts index 630109d..95f733c 100644 --- a/app/api/me/memberships/route.ts +++ b/app/api/me/memberships/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) @@ -46,7 +47,7 @@ export async function POST(request: Request) { export async function DELETE(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) diff --git a/app/api/me/requests/route.ts b/app/api/me/requests/route.ts index 0fb4a63..2163c30 100644 --- a/app/api/me/requests/route.ts +++ b/app/api/me/requests/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) diff --git a/app/api/me/settings/route.ts b/app/api/me/settings/route.ts index c810821..975b1e1 100644 --- a/app/api/me/settings/route.ts +++ b/app/api/me/settings/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,33 +12,39 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { data: profile, error } = await adminSupabase - .from('users') - .select('full_name, email_preferences, admin_role, iems_role, board_memberships(id, role, bodies(id, name, division))') - .eq('id', user.id) - .single() + // These three reads are independent of each other; running them serially cost + // two extra round trips per settings-modal open. + const [ + { data: profile, error }, + { data: pendingRequests }, + { data: allBodies }, + ] = await Promise.all([ + adminSupabase + .from('users') + .select('full_name, email_preferences, admin_role, iems_role, board_memberships(id, role, bodies(id, name, division))') + .eq('id', user.id) + .single(), + adminSupabase + .from('membership_requests') + .select('id, bodies(id, name, division)') + .eq('user_id', user.id) + .eq('status', 'pending'), + adminSupabase + .from('bodies') + .select('id, name, division, body_open') + .eq('is_active', true) + .neq('division', 'Non-Divisional') + .order('name', { ascending: true }), + ]) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { data: pendingRequests } = await adminSupabase - .from('membership_requests') - .select('id, bodies(id, name, division)') - .eq('user_id', user.id) - .eq('status', 'pending') - - const { data: allBodies } = await adminSupabase - .from('bodies') - .select('id, name, division, body_open') - .eq('is_active', true) - .neq('division', 'Non-Divisional') - .order('name', { ascending: true }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any const activeMemberBodyIds = new Set((profile.board_memberships ?? []).map((m: any) => (m.bodies as { id: string } | null)?.id).filter(Boolean)) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -61,7 +68,7 @@ export async function GET() { export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) diff --git a/app/api/my-rooms/route.ts b/app/api/my-rooms/route.ts index 5e2c193..7d33861 100644 --- a/app/api/my-rooms/route.ts +++ b/app/api/my-rooms/route.ts @@ -1,11 +1,12 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' +import { getAuthedUser } from '@/lib/auth' export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) @@ -27,7 +28,7 @@ export async function GET() { ]) if (!memberships || memberships.length === 0) { - return NextResponse.json({ bookings: [] }) + return NextResponse.json({ bookings: [], leadershipBodyIds: [] }) } const bodyIds = memberships.map(m => m.body_id) @@ -36,7 +37,12 @@ export async function GET() { ) if (!activeSemester) { - return NextResponse.json({ oneTimeBookings: [], weeklyBookings: [], tablingBookings: [] }) + return NextResponse.json({ + oneTimeBookings: [], + weeklyBookings: [], + tablingBookings: [], + leadershipBodyIds: [...leadershipBodyIds], + }) } // Fetch all booking types in parallel @@ -90,5 +96,7 @@ export async function GET() { oneTimeBookings: visible(oneTimeBookings || []), weeklyBookings: visible(weeklyBookings || []), tablingBookings: visible(tablingBookings || []), + // Returned so the client doesn't have to re-query board_memberships itself. + leadershipBodyIds: [...leadershipBodyIds], }) } \ No newline at end of file diff --git a/app/api/onboarding/bodies/route.ts b/app/api/onboarding/bodies/route.ts index ce6fba2..e6be9c8 100644 --- a/app/api/onboarding/bodies/route.ts +++ b/app/api/onboarding/bodies/route.ts @@ -1,10 +1,11 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/onboarding/complete/route.ts b/app/api/onboarding/complete/route.ts index 69d8280..19cf55f 100644 --- a/app/api/onboarding/complete/route.ts +++ b/app/api/onboarding/complete/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -10,7 +11,7 @@ const adminSupabase = createAdminClient( export async function PATCH() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/onboarding/invalidate-otp/route.ts b/app/api/onboarding/invalidate-otp/route.ts index a9dd47f..1f53f36 100644 --- a/app/api/onboarding/invalidate-otp/route.ts +++ b/app/api/onboarding/invalidate-otp/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -10,7 +11,7 @@ const adminSupabase = createAdminClient( export async function POST() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/onboarding/memberships/route.ts b/app/api/onboarding/memberships/route.ts index acfd215..7e715c7 100644 --- a/app/api/onboarding/memberships/route.ts +++ b/app/api/onboarding/memberships/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -10,7 +11,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/onboarding/profile/route.ts b/app/api/onboarding/profile/route.ts index b6464ec..b53dee4 100644 --- a/app/api/onboarding/profile/route.ts +++ b/app/api/onboarding/profile/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -10,7 +11,7 @@ const adminSupabase = createAdminClient( export async function PATCH(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/request/route.ts b/app/api/request/route.ts index 1a38edb..8a49b6c 100644 --- a/app/api/request/route.ts +++ b/app/api/request/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,39 +12,45 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { data: settings } = await supabase - .from('app_settings') - .select('min_days_advance_room, min_days_advance_tabling') - .eq('id', 1) - .maybeSingle() + const isAdmin = !!user.app_metadata?.is_admin + + // 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([ + supabase + .from('app_settings') + .select('min_days_advance_room, min_days_advance_tabling') + .eq('id', 1) + .maybeSingle(), + isAdmin + ? // Admins get every active body + supabase + .from('bodies') + .select('id, name') + .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)') + .eq('user_id', user.id) + .eq('role', 'Leadership'), + ]) const minDaysRoom = settings?.min_days_advance_room ?? 0 const minDaysTabling = settings?.min_days_advance_tabling ?? 0 - // If user is admin, return all active bodies instead - if (user.app_metadata?.is_admin) { - const { data: allBodies } = await supabase - .from('bodies') - .select('id, name') - .eq('is_active', true) - .order('name', { ascending: true }) - return NextResponse.json({ bodies: allBodies || [], minDaysRoom, minDaysTabling }) - } - - // Get bodies where user has Leadership role - const { data: memberships } = await supabase - .from('board_memberships') - .select('body_id, bodies(id, name)') - .eq('user_id', user.id) - .eq('role', 'Leadership') - - const bodies = memberships?.map(m => m.bodies).filter(Boolean) || [] + const bodies = isAdmin + ? bodiesResult.data ?? [] + : ((bodiesResult.data ?? []) as { bodies: unknown }[]) + .map(m => m.bodies) + .filter(Boolean) return NextResponse.json({ bodies, minDaysRoom, minDaysTabling }) } @@ -51,7 +58,7 @@ export async function GET() { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) diff --git a/app/api/revision-requests/route.ts b/app/api/revision-requests/route.ts index 0b2f3bd..c9b905e 100644 --- a/app/api/revision-requests/route.ts +++ b/app/api/revision-requests/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' 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' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -11,7 +12,7 @@ const adminSupabase = createAdminClient( export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) diff --git a/app/api/signup/request/route.ts b/app/api/signup/request/route.ts index d422998..4bf2e3d 100644 --- a/app/api/signup/request/route.ts +++ b/app/api/signup/request/route.ts @@ -1,7 +1,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { randomBytes, createHash } from 'crypto' -import { signupRateLimiter } from '@/lib/rate-limit' +import { signupRateLimiter } from '@/lib/signup-rate-limit' import { sendSignupOtpEmail } from '@/lib/emails/signup-otp' const adminSupabase = createAdminClient( diff --git a/app/api/slack/connect/route.ts b/app/api/slack/connect/route.ts index 8024400..4e55e0e 100644 --- a/app/api/slack/connect/route.ts +++ b/app/api/slack/connect/route.ts @@ -1,6 +1,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -31,7 +32,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/spaces/blackouts/[id]/route.ts b/app/api/spaces/blackouts/[id]/route.ts index 6cc0d70..c4589a1 100644 --- a/app/api/spaces/blackouts/[id]/route.ts +++ b/app/api/spaces/blackouts/[id]/route.ts @@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -27,27 +29,30 @@ async function cascadeCancelBookings(spaceId: string | null, startTime: string, await adminSupabase.from('space_bookings').delete().in('id', affected.map((b: { id: string }) => b.id)) - await Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => { - const creatorEmail = emailMap.get(b.creator_id) - if (!creatorEmail) return - const ccEmails = (b.attendee_ids ?? []) - .map((id: string) => emailMap.get(id)) - .filter((e): e is string => !!e && e !== creatorEmail) - await sendSpaceBookingCancelledEmail({ - bookingId: b.id, - title: b.title, - spaceName: (Array.isArray(b.spaces) ? b.spaces[0]?.name : null) ?? 'SGA Space', - startTime: b.start_time, - endTime: b.end_time, - to: creatorEmail, - cc: ccEmails, - }) - })) + // Bookings are already deleted; notifying is a post-commit side effect. + waitUntil( + Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => { + const creatorEmail = emailMap.get(b.creator_id) + if (!creatorEmail) return + const ccEmails = (b.attendee_ids ?? []) + .map((id: string) => emailMap.get(id)) + .filter((e): e is string => !!e && e !== creatorEmail) + await sendSpaceBookingCancelledEmail({ + bookingId: b.id, + title: b.title, + spaceName: (Array.isArray(b.spaces) ? b.spaces[0]?.name : null) ?? 'SGA Space', + startTime: b.start_time, + endTime: b.end_time, + to: creatorEmail, + cc: ccEmails, + }) + })).catch(e => console.error('Blackout cascade emails failed:', e)) + ) } export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -85,7 +90,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/spaces/blackouts/route.ts b/app/api/spaces/blackouts/route.ts index ce1b456..1b97b64 100644 --- a/app/api/spaces/blackouts/route.ts +++ b/app/api/spaces/blackouts/route.ts @@ -3,6 +3,8 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -16,7 +18,7 @@ function minutesOf(iso: string): number { export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -34,7 +36,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -94,23 +96,27 @@ export async function POST(request: Request) { .delete() .in('id', affected.map((b: { id: string }) => b.id)) - // Send cancellation emails - await Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => { - const creatorEmail = emailMap.get(b.creator_id) - if (!creatorEmail) return - const ccEmails = (b.attendee_ids ?? []) - .map((id: string) => emailMap.get(id)) - .filter((e): e is string => !!e && e !== creatorEmail) - await sendSpaceBookingCancelledEmail({ - bookingId: b.id, - title: b.title, - spaceName: (Array.isArray(b.spaces) ? b.spaces[0]?.name : null) ?? 'SGA Space', - startTime: b.start_time, - endTime: b.end_time, - to: creatorEmail, - cc: ccEmails, - }) - })) + // The bookings are already deleted above, so the notifications are a + // post-commit side effect. Previously the admin's request blocked on one + // Resend call per affected booking, which could run into seconds. + waitUntil( + Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => { + const creatorEmail = emailMap.get(b.creator_id) + if (!creatorEmail) return + const ccEmails = (b.attendee_ids ?? []) + .map((id: string) => emailMap.get(id)) + .filter((e): e is string => !!e && e !== creatorEmail) + await sendSpaceBookingCancelledEmail({ + bookingId: b.id, + title: b.title, + spaceName: (Array.isArray(b.spaces) ? b.spaces[0]?.name : null) ?? 'SGA Space', + startTime: b.start_time, + endTime: b.end_time, + to: creatorEmail, + cc: ccEmails, + }) + })).catch(e => console.error('Blackout cascade emails failed:', e)) + ) } } catch (e) { console.error('Blackout cascade cancellation failed:', e) diff --git a/app/api/spaces/bookings/[id]/route.ts b/app/api/spaces/bookings/[id]/route.ts index c2ed2be..fe94af8 100644 --- a/app/api/spaces/bookings/[id]/route.ts +++ b/app/api/spaces/bookings/[id]/route.ts @@ -3,6 +3,8 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const DEFAULT_WEEKLY_HOURS = 18 @@ -43,7 +45,7 @@ const adminSupabase = createAdminClient( export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) @@ -147,7 +149,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { id } = await params @@ -169,33 +171,37 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ const { error: deleteError } = await adminSupabase.from('space_bookings').delete().eq('id', id) if (deleteError) return NextResponse.json({ error: deleteError.message }, { status: 500 }) - // Send cancellation email to creator + attendees - try { - const allUserIds: string[] = [...new Set([booking.creator_id, ...(booking.attendee_ids ?? [])])] - const { data: emailUsers } = await adminSupabase - .from('users') - .select('id, email') - .in('id', allUserIds) - const userMap = new Map((emailUsers ?? []).map((u: { id: string; email: string }) => [u.id, u.email])) - const creatorEmail = userMap.get(booking.creator_id) - const ccEmails = (booking.attendee_ids ?? []) - .map((id: string) => userMap.get(id)) - .filter((e: string | undefined): e is string => !!e && e !== creatorEmail) - const spaceName = (booking.spaces as { name: string } | null)?.name ?? 'SGA Space' - if (creatorEmail) { - await sendSpaceBookingCancelledEmail({ - bookingId: id, - title: booking.title, - spaceName, - startTime: booking.start_time, - endTime: booking.end_time, - to: creatorEmail, - cc: ccEmails, - }) - } - } catch (e) { - console.error('Space booking cancellation email failed:', e) - } + // The row is already deleted, so notifying is a post-commit side effect. + waitUntil( + (async () => { + try { + const allUserIds: string[] = [...new Set([booking.creator_id, ...(booking.attendee_ids ?? [])])] + const { data: emailUsers } = await adminSupabase + .from('users') + .select('id, email') + .in('id', allUserIds) + const userMap = new Map((emailUsers ?? []).map((u: { id: string; email: string }) => [u.id, u.email])) + const creatorEmail = userMap.get(booking.creator_id) + const ccEmails = (booking.attendee_ids ?? []) + .map((id: string) => userMap.get(id)) + .filter((e: string | undefined): e is string => !!e && e !== creatorEmail) + const spaceName = (booking.spaces as { name: string } | null)?.name ?? 'SGA Space' + if (creatorEmail) { + await sendSpaceBookingCancelledEmail({ + bookingId: id, + title: booking.title, + spaceName, + startTime: booking.start_time, + endTime: booking.end_time, + to: creatorEmail, + cc: ccEmails, + }) + } + } catch (e) { + console.error('Space booking cancellation email failed:', e) + } + })() + ) return NextResponse.json({ success: true }) } diff --git a/app/api/spaces/bookings/route.ts b/app/api/spaces/bookings/route.ts index ae25a5d..76774db 100644 --- a/app/api/spaces/bookings/route.ts +++ b/app/api/spaces/bookings/route.ts @@ -3,6 +3,8 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/check-rate-limit' import { sendSpaceBookingConfirmedEmail } from '@/lib/emails/space-booking-confirmed' +import { getAuthedUser } from '@/lib/auth' +import { waitUntil } from '@vercel/functions' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -47,7 +49,7 @@ function touchesDeadZone(startIso: string, endIso: string): boolean { export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { searchParams } = new URL(request.url) @@ -98,7 +100,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const rateLimitRes = await checkRateLimit(user.id) @@ -205,25 +207,31 @@ export async function POST(request: Request) { if (insertError) return NextResponse.json({ error: insertError.message }, { status: 500 }) - // Send confirmation email - try { - const allUserIds: string[] = [user.id, ...(attendee_ids ?? [])] - const [{ data: space }, { data: emailUsers }] = await Promise.all([ - adminSupabase.from('spaces').select('name').eq('id', space_id).single(), - adminSupabase.from('users').select('email').in('id', allUserIds), - ]) - const emails = (emailUsers ?? []).map((u: { email: string }) => u.email).filter(Boolean) - await sendSpaceBookingConfirmedEmail({ - bookingId: booking.id, - title, - spaceName: space?.name ?? 'SGA Space', - startTime: start_time, - endTime: end_time, - recipients: emails, - }) - } catch (e) { - console.error('Space booking confirmation email failed:', e) - } + // The booking is already committed, so the confirmation email is a post-commit + // side effect. Run it after the response instead of making the user wait on + // two more queries plus a Resend call. + waitUntil( + (async () => { + try { + const allUserIds: string[] = [user.id, ...(attendee_ids ?? [])] + const [{ data: space }, { data: emailUsers }] = await Promise.all([ + adminSupabase.from('spaces').select('name').eq('id', space_id).single(), + adminSupabase.from('users').select('email').in('id', allUserIds), + ]) + const emails = (emailUsers ?? []).map((u: { email: string }) => u.email).filter(Boolean) + await sendSpaceBookingConfirmedEmail({ + bookingId: booking.id, + title, + spaceName: space?.name ?? 'SGA Space', + startTime: start_time, + endTime: end_time, + recipients: emails, + }) + } catch (e) { + console.error('Space booking confirmation email failed:', e) + } + })() + ) return NextResponse.json({ success: true, booking }) } diff --git a/app/api/spaces/limit-overrides/[id]/route.ts b/app/api/spaces/limit-overrides/[id]/route.ts index 7ccbc7e..5d97a1a 100644 --- a/app/api/spaces/limit-overrides/[id]/route.ts +++ b/app/api/spaces/limit-overrides/[id]/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -9,7 +10,7 @@ const adminSupabase = createAdminClient( export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/spaces/limit-overrides/route.ts b/app/api/spaces/limit-overrides/route.ts index e6cc398..6d305f0 100644 --- a/app/api/spaces/limit-overrides/route.ts +++ b/app/api/spaces/limit-overrides/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -9,7 +10,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -36,7 +37,7 @@ export async function GET() { export async function POST(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user || !user.app_metadata?.is_admin) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/app/api/spaces/remaining-hours/route.ts b/app/api/spaces/remaining-hours/route.ts index 018b26d..46ab720 100644 --- a/app/api/spaces/remaining-hours/route.ts +++ b/app/api/spaces/remaining-hours/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -22,7 +23,7 @@ function getWeekBounds(): { weekStart: string; weekEnd: string } { export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { weekStart, weekEnd } = getWeekBounds() diff --git a/app/api/spaces/route.ts b/app/api/spaces/route.ts index f7af4ab..8fc65c3 100644 --- a/app/api/spaces/route.ts +++ b/app/api/spaces/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -9,7 +10,7 @@ const adminSupabase = createAdminClient( export async function GET() { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { data, error } = await adminSupabase diff --git a/app/api/users/by-ids/route.ts b/app/api/users/by-ids/route.ts index 5ceff73..0a5f0c4 100644 --- a/app/api/users/by-ids/route.ts +++ b/app/api/users/by-ids/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -9,7 +10,7 @@ const adminSupabase = createAdminClient( export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { searchParams } = new URL(request.url) diff --git a/app/api/users/search/route.ts b/app/api/users/search/route.ts index f605358..98d4a69 100644 --- a/app/api/users/search/route.ts +++ b/app/api/users/search/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -9,7 +10,7 @@ const adminSupabase = createAdminClient( export async function GET(request: Request) { const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { searchParams } = new URL(request.url) diff --git a/app/slack/connect/page.tsx b/app/slack/connect/page.tsx index d10d6f4..eae59fe 100644 --- a/app/slack/connect/page.tsx +++ b/app/slack/connect/page.tsx @@ -1,5 +1,6 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' +import { getAuthedUser } from '@/lib/auth' import SlackConnectForm from './SlackConnectForm' export default async function SlackConnectPage({ @@ -10,9 +11,7 @@ export default async function SlackConnectPage({ const { token } = await searchParams const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() + const user = await getAuthedUser(supabase) if (!user) { const redirectTo = token diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..4fa7f9a --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,42 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export type AuthedUser = { + id: string + email?: string + app_metadata: { + is_admin?: boolean + iems_role?: string + admin_role?: string + [key: string]: unknown + } +} + +/** + * Verifies the caller's JWT and returns a user-shaped object, or null when + * there is no valid session. + * + * Uses getClaims() rather than getUser(). This project signs tokens with ES256 + * (asymmetric), so getClaims() verifies the signature locally against a cached + * JWKS instead of making a network round trip to the Auth server on every call. + * The signature is still cryptographically verified -- this is not the same as + * trusting getSession(), which does no verification at all. + * + * The return shape intentionally mirrors the parts of getUser()'s `user` that + * this app actually reads (`id` and `app_metadata`), so call sites stay + * unchanged apart from the call itself. + */ +export async function getAuthedUser( + supabase: SupabaseClient +): Promise { + const { data, error } = await supabase.auth.getClaims() + if (error || !data?.claims) return null + + const claims = data.claims + if (typeof claims.sub !== 'string' || !claims.sub) return null + + return { + id: claims.sub, + email: typeof claims.email === 'string' ? claims.email : undefined, + app_metadata: (claims.app_metadata ?? {}) as AuthedUser['app_metadata'], + } +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index ece570e..af33f27 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -1,15 +1,12 @@ import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' +// `ephemeralCache` lets a warm instance answer from memory for an identifier it +// has already seen, instead of paying an Upstash REST round trip on every +// request. Upstash is still the source of truth across instances. export const rateLimiter = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(60, '1 m'), analytics: false, -}) - -export const signupRateLimiter = new Ratelimit({ - redis: Redis.fromEnv(), - limiter: Ratelimit.slidingWindow(5, '10 m'), - analytics: false, - prefix: 'signup', + ephemeralCache: new Map(), }) diff --git a/lib/signup-rate-limit.ts b/lib/signup-rate-limit.ts new file mode 100644 index 0000000..06b801b --- /dev/null +++ b/lib/signup-rate-limit.ts @@ -0,0 +1,12 @@ +import { Ratelimit } from '@upstash/ratelimit' +import { Redis } from '@upstash/redis' + +// Kept in its own module so the ~40 routes that only need `rateLimiter` don't +// construct a second Redis client and Ratelimit instance at module load. +export const signupRateLimiter = new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(5, '10 m'), + analytics: false, + prefix: 'signup', + ephemeralCache: new Map(), +}) diff --git a/middleware.ts b/middleware.ts index 38452e3..1b7d765 100644 --- a/middleware.ts +++ b/middleware.ts @@ -36,6 +36,10 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + // `api` is excluded: this middleware exists only to refresh the auth cookie + // on document/RSC navigations. Every route under /api authenticates itself, + // so running it there added a second Supabase Auth round trip per API call + // whose result was discarded. + '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } \ No newline at end of file diff --git a/public/sw.js b/public/sw.js index cf53432..e97ccbf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,a={};const s=(s,n)=>(s=new URL(s+".js",n).href,a[s]||new Promise(a=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=a,document.head.appendChild(e)}else e=s,importScripts(s),a()}).then(()=>{let e=a[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,t)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(a[i])return;let c={};const d=e=>s(e,i),r={module:{uri:i},exports:c,require:d};a[i]=Promise.all(n.map(e=>r[e]||d(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/DRspNaESiz79IyDDey8tp/_buildManifest.js",revision:"a71b9798923db53aea97a6d2c16a9912"},{url:"/_next/static/DRspNaESiz79IyDDey8tp/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/4bd1b696-096d35a2bd1da3af.js",revision:"096d35a2bd1da3af"},{url:"/_next/static/chunks/622-f5965b4c44624dc1.js",revision:"f5965b4c44624dc1"},{url:"/_next/static/chunks/794-37dad9bbc14b04b8.js",revision:"37dad9bbc14b04b8"},{url:"/_next/static/chunks/899.1813981119fa1f8a.js",revision:"1813981119fa1f8a"},{url:"/_next/static/chunks/966.1775eb621d8d3e09.js",revision:"1775eb621d8d3e09"},{url:"/_next/static/chunks/app/(dashboard)/layout-726b41afed4691dd.js",revision:"726b41afed4691dd"},{url:"/_next/static/chunks/app/(dashboard)/management/page-f1a5256ad4223b44.js",revision:"f1a5256ad4223b44"},{url:"/_next/static/chunks/app/(dashboard)/my-rooms/page-9174d475ff31c868.js",revision:"9174d475ff31c868"},{url:"/_next/static/chunks/app/(dashboard)/request/page-8fe9736b63cfbe70.js",revision:"8fe9736b63cfbe70"},{url:"/_next/static/chunks/app/_global-error/page-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/_not-found/page-6e4ade5a735b7a9c.js",revision:"6e4ade5a735b7a9c"},{url:"/_next/static/chunks/app/api/alerts/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/cancellation-requests/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/archive/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/audit-logs/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/bodies/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/bookings/cancel/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/bookings/one-time/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/bookings/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/bookings/tabling/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/bookings/weekly/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/cancellations/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/counts/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/requests/bookings/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/requests/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/revisions/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/semesters/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/settings/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/users/memberships/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/administrator/users/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/my-rooms/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/request/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/api/revision-requests/route-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/app/layout-ab763f2f318f1d42.js",revision:"ab763f2f318f1d42"},{url:"/_next/static/chunks/app/page-816f55a8d37ccfe9.js",revision:"816f55a8d37ccfe9"},{url:"/_next/static/chunks/framework-75892d61b920805f.js",revision:"75892d61b920805f"},{url:"/_next/static/chunks/main-8451f84c5cabcfcd.js",revision:"8451f84c5cabcfcd"},{url:"/_next/static/chunks/main-app-e5f0690dd7528b23.js",revision:"e5f0690dd7528b23"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-f61c7b76ebfe41b6.js",revision:"f61c7b76ebfe41b6"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-f07092aea17dc5d8.js",revision:"f07092aea17dc5d8"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-6c052340e4eb52a9.js",revision:"6c052340e4eb52a9"},{url:"/_next/static/css/2abb523ed2e8dfdc.css",revision:"2abb523ed2e8dfdc"},{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:"b02b8b45ec579ed5f57d9792723894c2"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{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:a,event:s,state:n})=>a&&"opaqueredirect"===a.type?new Response(a.body,{status:200,statusText:"OK",headers:a.headers}):a}]}),"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 a=e.pathname;return!a.startsWith("/api/auth/")&&!!a.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,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")}); diff --git a/supabase/migrations/20260825000000_rls_auth_initplan_fix.sql b/supabase/migrations/20260825000000_rls_auth_initplan_fix.sql new file mode 100644 index 0000000..32f5d69 --- /dev/null +++ b/supabase/migrations/20260825000000_rls_auth_initplan_fix.sql @@ -0,0 +1,92 @@ +-- Wrap direct auth.uid()/auth.jwt() calls in RLS policies as (select auth.uid())/(select auth.jwt()) +-- so Postgres evaluates them once per query instead of once per row (auth_rls_initplan advisor). +-- No other policy logic (is_admin(), is_spaces_admin(), is_active, OR/AND structure) is changed. + +-- audit_logs +alter policy "Admins can read audit logs" on public.audit_logs + using ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true); + +-- board_memberships +alter policy "memberships_select_own" on public.board_memberships + using (user_id = (select auth.uid())); + +-- event_tracking +alter policy "event_tracking_delete" on public.event_tracking + using ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true); + +alter policy "event_tracking_insert" on public.event_tracking + with check ( + ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true) + or ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'iems_role'::text) is not null) + ); + +alter policy "event_tracking_select" on public.event_tracking + using ( + ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true) + or ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'iems_role'::text) is not null) + ); + +alter policy "event_tracking_update" on public.event_tracking + using ( + ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true) + or ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'iems_role'::text) is not null) + ); + +-- revision_requests +alter policy "Admins can update revision requests" on public.revision_requests + using ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true); + +alter policy "Users can insert their own revision requests" on public.revision_requests + with check ((select auth.uid()) = requested_by); + +alter policy "Users can view their own revision requests" on public.revision_requests + using ( + ((select auth.uid()) = requested_by) + or ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true) + ); + +-- semesters +alter policy "Admins can create semesters" on public.semesters + with check ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true); + +alter policy "Admins can delete semesters" on public.semesters + using ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true); + +alter policy "Admins can update semesters" on public.semesters + using ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true) + with check ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true); + +alter policy "Authenticated users can view semesters" on public.semesters + using ( + (is_active = true) + or ((((select auth.jwt()) -> 'app_metadata'::text) ->> 'is_admin'::text)::boolean = true) + ); + +-- slack_connections +alter policy "slack_connections_select_own" on public.slack_connections + using (chambers_user_id = (select auth.uid())); + +-- space_bookings +alter policy "space_bookings: own insert" on public.space_bookings + with check (creator_id = (select auth.uid())); + +alter policy "space_bookings: own or admin delete" on public.space_bookings + using ((creator_id = (select auth.uid())) or is_spaces_admin()); + +alter policy "space_bookings: own or admin update" on public.space_bookings + using ((creator_id = (select auth.uid())) or is_spaces_admin()); + +-- space_weekly_limit_overrides +alter policy "space_weekly_limit_overrides: own or admin read" on public.space_weekly_limit_overrides + using ((user_id = (select auth.uid())) or is_spaces_admin()); + +-- user_alerts +alter policy "users can read own alerts" on public.user_alerts + using ((select auth.uid()) = user_id); + +alter policy "users can update own alerts" on public.user_alerts + using ((select auth.uid()) = user_id); + +-- users +alter policy "users_select_own" on public.users + using (id = (select auth.uid())); diff --git a/supabase/migrations/20260825010000_consolidate_multiple_permissive_policies.sql b/supabase/migrations/20260825010000_consolidate_multiple_permissive_policies.sql new file mode 100644 index 0000000..b47094b --- /dev/null +++ b/supabase/migrations/20260825010000_consolidate_multiple_permissive_policies.sql @@ -0,0 +1,203 @@ +-- Consolidate pairs of permissive RLS policies (same table/command/role) into a single +-- policy per pair, OR-ing their conditions together. Postgres already combines multiple +-- permissive policies for the same command with OR, so this is behavior-preserving -- +-- it just avoids evaluating two separate policy expressions per row on every query. +-- Addresses the `multiple_permissive_policies` performance advisor warnings. + +-- board_memberships: SELECT +drop policy "memberships_select_admin" on public.board_memberships; +drop policy "memberships_select_own" on public.board_memberships; +create policy "memberships_select_admin_or_own" on public.board_memberships + for select to authenticated + using (is_admin() or (user_id = (select auth.uid()))); + +-- bookings: SELECT +drop policy "bookings_select_admin" on public.bookings; +drop policy "bookings_select_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)); + +-- cancellation_requests: INSERT +drop policy "cancel_requests_insert_admin" on public.cancellation_requests; +drop policy "cancel_requests_insert_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) + ) + ); + +-- cancellation_requests: SELECT +drop policy "cancel_requests_select_admin" on public.cancellation_requests; +drop policy "cancel_requests_select_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) + ) + ); + +-- one_time_room_bookings: SELECT +drop policy "one_time_select_admin" on public.one_time_room_bookings; +drop policy "one_time_select_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) + ) + ); + +-- room_request_details: INSERT +drop policy "request_details_insert_admin" on public.room_request_details; +drop policy "request_details_insert_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) + ) + ); + +-- room_request_details: SELECT +drop policy "request_details_select_admin" on public.room_request_details; +drop policy "request_details_select_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) + ) + ); + +-- room_requests: INSERT +drop policy "requests_insert_admin" on public.room_requests; +drop policy "requests_insert_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)); + +-- room_requests: SELECT +drop policy "requests_select_admin" on public.room_requests; +drop policy "requests_select_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)); + +-- slack_connections: SELECT (role: public, matches original which also targeted public) +drop policy "slack_connections_select_admin" on public.slack_connections; +drop policy "slack_connections_select_own" on public.slack_connections; +create policy "slack_connections_select_admin_or_own" on public.slack_connections + for select + using (is_admin() or (chambers_user_id = (select auth.uid()))); + +-- tabling_bookings: SELECT +drop policy "tabling_select_admin" on public.tabling_bookings; +drop policy "tabling_select_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) + ) + ); + +-- tabling_request_sessions: INSERT +drop policy "tabling_req_sessions_insert_admin" on public.tabling_request_sessions; +drop policy "tabling_req_sessions_insert_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) + ) + ); + +-- tabling_request_sessions: SELECT +drop policy "tabling_req_sessions_select_admin" on public.tabling_request_sessions; +drop policy "tabling_req_sessions_select_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) + ) + ); + +-- tabling_sessions: SELECT +drop policy "tabling_sessions_select_admin" on public.tabling_sessions; +drop policy "tabling_sessions_select_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) + ) + ); + +-- users: SELECT +drop policy "users_select_admin" on public.users; +drop policy "users_select_own" on public.users; +create policy "users_select_admin_or_own" on public.users + for select to authenticated + using (is_admin() or (id = (select auth.uid()))); + +-- weekly_room_bookings: SELECT +drop policy "weekly_select_admin" on public.weekly_room_bookings; +drop policy "weekly_select_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) + ) + ); + +-- weekly_room_occurrences: SELECT +drop policy "occurrences_select_admin" on public.weekly_room_occurrences; +drop policy "occurrences_select_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) + ) + );