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 (
+
+
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+
+
+ )
+}
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)
+ )
+ );