diff --git a/app/(dashboard)/administrator/page.tsx b/app/(dashboard)/administrator/page.tsx index c56a8fc..5830735 100644 --- a/app/(dashboard)/administrator/page.tsx +++ b/app/(dashboard)/administrator/page.tsx @@ -28,12 +28,12 @@ export default function AdministratorPage() {

Administrator

-
+
{(['Bookings', 'SGA Spaces', 'Cancellations', 'Requests', 'Advanced Settings'] as Tab[]).map(tab => (

NU Student Gov. Association

-

v1.12.3

+

v1.12.4

{userName && (

{getGreeting()},
{userName}

diff --git a/app/(dashboard)/my-rooms/booking-detail-modal.tsx b/app/(dashboard)/my-rooms/booking-detail-modal.tsx index e33c8b6..1f24a3d 100644 --- a/app/(dashboard)/my-rooms/booking-detail-modal.tsx +++ b/app/(dashboard)/my-rooms/booking-detail-modal.tsx @@ -1,36 +1,7 @@ 'use client' import BookingModal from '../administrator/booking-modal' - -const statusTextColors: Record = { - 'Reserved': 'text-[#4ade80]', - 'Alternate Room': 'text-[#4285f4]', - 'Alternate Time': 'text-[#4285f4]', - 'Waitlisted': 'text-[#f87171]', - 'Unavailable': 'text-[#f87171]', - 'Pending Cancellation': 'text-[#fb923c]', - 'Cancelled': 'text-[#c084fc]', - 'Virtual': 'text-[#22d3ee]', - 'Missed': 'text-[#a78bfa]', - 'Repurposed': 'text-white', - 'Tentative': 'text-[#fef08a]', -} - -interface FlatBooking { - id: string - bookingId: string - bodyId: string - type: 'One-Time Room' | 'Weekly Room' | 'Tabling' - bodyName: string - purpose: string - location: string - date: string - startTime: string - endTime: string - status: string - reservationCode: string | null - senateType: string | null -} +import { type FlatBooking, statusTextColors, senateTypeBadgeColors, DEFAULT_SENATE_BADGE } from './shared' interface BookingDetailModalProps { booking: FlatBooking @@ -73,7 +44,7 @@ export default function BookingDetailModal({ booking, isLeadership, onClose, onC

{booking.bodyName}

{booking.senateType && ( - {booking.senateType} + {booking.senateType} )}
diff --git a/app/(dashboard)/my-rooms/calendar-view.tsx b/app/(dashboard)/my-rooms/calendar-view.tsx new file mode 100644 index 0000000..b2ea09b --- /dev/null +++ b/app/(dashboard)/my-rooms/calendar-view.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + type FlatBooking, + statusBarColors, + statusTextColors, + formatTime, +} from './shared' + +interface CalendarViewProps { + bookings: FlatBooking[] + onSelect: (booking: FlatBooking) => void +} + +const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const MAX_VISIBLE_PER_DAY = 3 + +function toDateKey(year: number, month: number, day: number) { + return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` +} + +function todayKey() { + const now = new Date() + return toDateKey(now.getFullYear(), now.getMonth(), now.getDate()) +} + +export default function CalendarView({ bookings, onSelect }: CalendarViewProps) { + const [cursor, setCursor] = useState(() => { + const now = new Date() + return { year: now.getFullYear(), month: now.getMonth() } + }) + const [selectedDate, setSelectedDate] = useState(null) + + const byDate = useMemo(() => { + const map = new Map() + for (const b of bookings) { + if (!map.has(b.date)) map.set(b.date, []) + map.get(b.date)!.push(b) + } + return map + }, [bookings]) + + const cells = useMemo(() => { + const { year, month } = cursor + const firstOfMonth = new Date(year, month, 1) + const startOffset = firstOfMonth.getDay() + const daysInMonth = new Date(year, month + 1, 0).getDate() + const total = Math.ceil((startOffset + daysInMonth) / 7) * 7 + return Array.from({ length: total }, (_, i) => { + const day = i - startOffset + 1 + if (day < 1 || day > daysInMonth) return null + return { day, dateKey: toDateKey(year, month, day) } + }) + }, [cursor]) + + const monthLabel = new Date(cursor.year, cursor.month, 1).toLocaleDateString('en-US', { + month: 'long', year: 'numeric', + }) + + const goToMonth = (delta: number) => { + setCursor(prev => { + const d = new Date(prev.year, prev.month + delta, 1) + return { year: d.getFullYear(), month: d.getMonth() } + }) + setSelectedDate(null) + } + + const goToToday = () => { + const now = new Date() + setCursor({ year: now.getFullYear(), month: now.getMonth() }) + setSelectedDate(todayKey()) + } + + const selectedBookings = selectedDate ? byDate.get(selectedDate) ?? [] : [] + + return ( +
+
+
+ + +

{monthLabel}

+
+ +
+ +
+ {WEEKDAY_LABELS.map(label => ( +
+ {label} +
+ ))} + {cells.map((cell, i) => { + if (!cell) return
+ const dayBookings = byDate.get(cell.dateKey) ?? [] + const isToday = cell.dateKey === todayKey() + const isSelected = cell.dateKey === selectedDate + return ( + + ) + })} +
+ + {selectedDate && ( +
+

+ {new Date(selectedDate + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })} +

+ {selectedBookings.length === 0 ? ( +

No bookings on this day.

+ ) : ( +
+ {selectedBookings.map(b => ( +
onSelect(b)} className="flex items-center gap-4 px-5 py-3.5 hover:bg-[#1a4d8a] transition-colors cursor-pointer"> +
+
+

{b.bodyName}

+

{b.location} · {formatTime(b.startTime)} – {formatTime(b.endTime)}

+
+ {b.status} +
+ ))} +
+ )} +
+ )} +
+ ) +} diff --git a/app/(dashboard)/my-rooms/page.tsx b/app/(dashboard)/my-rooms/page.tsx index 962a055..0bee047 100644 --- a/app/(dashboard)/my-rooms/page.tsx +++ b/app/(dashboard)/my-rooms/page.tsx @@ -1,11 +1,22 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import CancelModal from './cancel-modal' import RevisionModal from './revision-modal' import BookingDetailModal from './booking-detail-modal' import NotificationBell from './notification-bell' +import CalendarView from './calendar-view' import { Skeleton } from '@/app/_components/skeleton' +import { + type FlatBooking, + statusColors, + statusBarColors, + statusTextColors, + senateTypeBadgeColors, + DEFAULT_SENATE_BADGE, + formatTime, + formatDate, +} from './shared' function MyRoomsSkeleton() { return ( @@ -57,78 +68,7 @@ function MyRoomsSkeleton() { } type Filter = 1 | 3 | 7 - -const statusColors: Record = { - 'Reserved': 'bg-[#0f3d20] border-[#22c55e]', - 'Alternate Room': 'bg-[#0e2f4f] border-[#4285f4]', - 'Alternate Time': 'bg-[#0e2f4f] border-[#4285f4]', - 'Waitlisted': 'bg-[#3d0f0f] border-[#ef4444]', - 'Unavailable': 'bg-[#3d0f0f] border-[#ef4444]', - 'Pending Cancellation': 'bg-[#3d2200] border-[#f97316]', - 'Cancelled': 'bg-[#2a1042] border-[#a855f7]', - 'Virtual': 'bg-[#062f3b] border-[#06b6d4]', - 'Missed': 'bg-[#1a1a2e] border-[#a78bfa]', - 'Repurposed': 'bg-[#1a1a1a] border-white', - 'Tentative': 'bg-[#2d2800] border-[#fef08a]', -} - -const statusBarColors: Record = { - 'Reserved': 'bg-[#22c55e]', - 'Alternate Room': 'bg-[#4285f4]', - 'Alternate Time': 'bg-[#4285f4]', - 'Waitlisted': 'bg-[#ef4444]', - 'Unavailable': 'bg-[#ef4444]', - 'Pending Cancellation': 'bg-[#f97316]', - 'Cancelled': 'bg-[#a855f7]', - 'Virtual': 'bg-[#06b6d4]', - 'Missed': 'bg-[#a78bfa]', - 'Repurposed': 'bg-white', - 'Tentative': 'bg-[#fef08a]', -} - -const statusTextColors: Record = { - 'Reserved': 'text-[#4ade80]', - 'Alternate Room': 'text-[#4285f4]', - 'Alternate Time': 'text-[#4285f4]', - 'Waitlisted': 'text-[#f87171]', - 'Unavailable': 'text-[#f87171]', - 'Pending Cancellation': 'text-[#fb923c]', - 'Cancelled': 'text-[#c084fc]', - 'Virtual': 'text-[#22d3ee]', - 'Missed': 'text-[#a78bfa]', - 'Repurposed': 'text-white', - 'Tentative': 'text-[#fef08a]', -} - -interface FlatBooking { - id: string - bookingId: string //parent booking id - bodyId: string - type: 'One-Time Room' | 'Weekly Room' | 'Tabling' - bodyName: string - purpose: string - location: string - date: string - startTime: string - endTime: string - status: string - reservationCode: string | null - senateType: string | null -} - -function formatTime(time: string) { - const [h, m] = time.split(':') - const hour = parseInt(h) - const ampm = hour >= 12 ? 'PM' : 'AM' - const displayHour = hour % 12 || 12 - return `${displayHour}:${m} ${ampm}` -} - -function formatDate(date: string) { - return new Date(date + 'T00:00:00').toLocaleDateString('en-US', { - weekday: 'short', month: 'short', day: 'numeric' - }) -} +type ViewMode = 'list' | 'calendar' function isWithinDays(dateStr: string, days: number) { const now = new Date() @@ -144,6 +84,10 @@ export default function MyRoomsPage() { const [loading, setLoading] = useState(true) const [leadershipBodyIds, setLeadershipBodyIds] = useState([]) const [detailBooking, setDetailBooking] = useState(null) + const [viewMode, setViewMode] = useState('calendar') + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('All') + const [senateTypePreferences, setSenateTypePreferences] = useState>({}) const [cancellingBooking, setCancellingBooking] = useState<{ id: string type: 'One-Time Room' | 'Weekly Room' | 'Tabling' @@ -170,6 +114,7 @@ export default function MyRoomsPage() { const data = await res.json() setLeadershipBodyIds(data.leadershipBodyIds || []) + setSenateTypePreferences(data.senateTypePreferences || {}) const flat: FlatBooking[] = [] @@ -248,10 +193,34 @@ export default function MyRoomsPage() { useEffect(() => { fetchBookings() - // eslint-disable-next-line react-hooks/exhaustive-deps + // The Senate session-type preference lives in the Settings modal, which can + // be opened over this page; refetch so a change there is reflected here. + window.addEventListener('chambers:senate-prefs-updated', fetchBookings) + return () => window.removeEventListener('chambers:senate-prefs-updated', fetchBookings) }, []) - const filteredUpcoming = all.filter(b => isWithinDays(b.date, filter)) + const passesSenateFilter = (b: FlatBooking) => + b.bodyName !== 'Senate' || !b.senateType || (senateTypePreferences[b.senateType] ?? true) + + const filteredUpcoming = all.filter(b => isWithinDays(b.date, filter) && passesSenateFilter(b)) + + const statusOptions = useMemo( + () => ['All', ...Array.from(new Set(all.map(b => b.status))).sort()], + [all] + ) + + const visibleAll = useMemo(() => { + const q = search.trim().toLowerCase() + return all.filter(b => { + if (!passesSenateFilter(b)) return false + if (statusFilter !== 'All' && b.status !== statusFilter) return false + if (q && !(b.location.toLowerCase().includes(q) || b.purpose.toLowerCase().includes(q) || b.bodyName.toLowerCase().includes(q))) return false + return true + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [all, search, statusFilter, senateTypePreferences]) + + const filtersActive = search.trim() !== '' || statusFilter !== 'All' if (loading) return @@ -298,7 +267,7 @@ export default function MyRoomsPage() {

{formatTime(b.startTime)} – {formatTime(b.endTime)}

{b.senateType && ( - {b.senateType} + {b.senateType} )}
@@ -319,56 +288,109 @@ export default function MyRoomsPage() { {/* All Bookings */}
-

All Bookings

+
+

All Bookings

+
+ {(['calendar', 'list'] as ViewMode[]).map(mode => ( + + ))} +
+
+ {all.length === 0 ? (

No bookings found.

- ) : (() => { - const bodyMap = new Map() - for (const b of all) { - if (!bodyMap.has(b.bodyId)) bodyMap.set(b.bodyId, { bodyName: b.bodyName, bookings: [] }) - bodyMap.get(b.bodyId)!.bookings.push(b) - } - const groups = Array.from(bodyMap.entries()).map(([bodyId, { bodyName, bookings }]) => ({ - bodyId, bodyName, bookings, - isLeadership: leadershipBodyIds.includes(bodyId), - })) - groups.sort((a, b) => { - if (a.isLeadership !== b.isLeadership) return a.isLeadership ? -1 : 1 - return a.bodyName.localeCompare(b.bodyName) - }) - return ( -
- {groups.map(group => ( -
-

- {group.bodyName} - {group.isLeadership && ( - (Leadership) - )} -

-
- {group.bookings.map(b => ( -
setDetailBooking(b)} className="flex items-center gap-4 px-5 py-3.5 hover:bg-[#1a4d8a] transition-colors cursor-pointer"> -
-
-
-

{b.location}

- {b.senateType && ( - {b.senateType} - )} + ) : ( + <> +
+ setSearch(e.target.value)} + placeholder="Search by room, purpose, or body…" + className="flex-1 min-w-[200px] px-3 py-2 rounded-lg bg-[#0e2f4f] border border-[#1e5080] text-sm text-[#f0f6ff] placeholder:text-[#4a6b8a] focus:outline-none focus:border-[#4285f4]" + /> + + {filtersActive && ( + + )} +
+ + {visibleAll.length === 0 ? ( +

No bookings match your filters.

+ ) : viewMode === 'calendar' ? ( + + ) : (() => { + const bodyMap = new Map() + for (const b of visibleAll) { + if (!bodyMap.has(b.bodyId)) bodyMap.set(b.bodyId, { bodyName: b.bodyName, bookings: [] }) + bodyMap.get(b.bodyId)!.bookings.push(b) + } + const groups = Array.from(bodyMap.entries()).map(([bodyId, { bodyName, bookings }]) => ({ + bodyId, bodyName, bookings, + isLeadership: leadershipBodyIds.includes(bodyId), + })) + groups.sort((a, b) => { + if (a.isLeadership !== b.isLeadership) return a.isLeadership ? -1 : 1 + return a.bodyName.localeCompare(b.bodyName) + }) + return ( +
+ {groups.map(group => ( +
+

+ {group.bodyName} + {group.isLeadership && ( + (Leadership) + )} +

+
+ {group.bookings.map(b => ( +
setDetailBooking(b)} className="flex items-center gap-4 px-5 py-3.5 hover:bg-[#1a4d8a] transition-colors cursor-pointer"> +
+
+
+

{b.location}

+ {b.senateType && ( + {b.senateType} + )} +
+

{formatDate(b.date)} · {formatTime(b.startTime)} – {formatTime(b.endTime)}

+
+ {b.type === 'One-Time Room' ? 'One-Time/Multiple Room' : b.type} + {b.status}
-

{formatDate(b.date)} · {formatTime(b.startTime)} – {formatTime(b.endTime)}

-
- {b.type === 'One-Time Room' ? 'One-Time/Multiple Room' : b.type} - {b.status} + ))}
- ))} -
+
+ ))}
- ))} -
- ) - })()} + ) + })()} + + )}
{detailBooking && ( ) -} \ No newline at end of file +} diff --git a/app/(dashboard)/my-rooms/shared.ts b/app/(dashboard)/my-rooms/shared.ts new file mode 100644 index 0000000..fee721c --- /dev/null +++ b/app/(dashboard)/my-rooms/shared.ts @@ -0,0 +1,82 @@ +export interface FlatBooking { + id: string + bookingId: string //parent booking id + bodyId: string + type: 'One-Time Room' | 'Weekly Room' | 'Tabling' + bodyName: string + purpose: string + location: string + date: string + startTime: string + endTime: string + status: string + reservationCode: string | null + senateType: string | null +} + +export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const + +export const statusColors: Record = { + 'Reserved': 'bg-[#0f3d20] border-[#22c55e]', + 'Alternate Room': 'bg-[#0e2f4f] border-[#4285f4]', + 'Alternate Time': 'bg-[#0e2f4f] border-[#4285f4]', + 'Waitlisted': 'bg-[#3d0f0f] border-[#ef4444]', + 'Unavailable': 'bg-[#3d0f0f] border-[#ef4444]', + 'Pending Cancellation': 'bg-[#3d2200] border-[#f97316]', + 'Cancelled': 'bg-[#2a1042] border-[#a855f7]', + 'Virtual': 'bg-[#062f3b] border-[#06b6d4]', + 'Missed': 'bg-[#1a1a2e] border-[#a78bfa]', + 'Repurposed': 'bg-[#1a1a1a] border-white', + 'Tentative': 'bg-[#2d2800] border-[#fef08a]', +} + +export const statusBarColors: Record = { + 'Reserved': 'bg-[#22c55e]', + 'Alternate Room': 'bg-[#4285f4]', + 'Alternate Time': 'bg-[#4285f4]', + 'Waitlisted': 'bg-[#ef4444]', + 'Unavailable': 'bg-[#ef4444]', + 'Pending Cancellation': 'bg-[#f97316]', + 'Cancelled': 'bg-[#a855f7]', + 'Virtual': 'bg-[#06b6d4]', + 'Missed': 'bg-[#a78bfa]', + 'Repurposed': 'bg-white', + 'Tentative': 'bg-[#fef08a]', +} + +export const statusTextColors: Record = { + 'Reserved': 'text-[#4ade80]', + 'Alternate Room': 'text-[#4285f4]', + 'Alternate Time': 'text-[#4285f4]', + 'Waitlisted': 'text-[#f87171]', + 'Unavailable': 'text-[#f87171]', + 'Pending Cancellation': 'text-[#fb923c]', + 'Cancelled': 'text-[#c084fc]', + 'Virtual': 'text-[#22d3ee]', + 'Missed': 'text-[#a78bfa]', + 'Repurposed': 'text-white', + 'Tentative': 'text-[#fef08a]', +} + +// Muted, tinted pills (matching statusColors' style) instead of a solid block, +// distinct per session type so they stay legible when grouped together. +export const senateTypeBadgeColors: Record = { + 'Full Body': 'bg-[#2a1042] text-[#c084fc] border border-[#a855f7]/40', + 'Weekly': 'bg-[#0e2f4f] text-[#93c5fd] border border-[#4285f4]/40', + 'Office Hours': 'bg-[#062f3b] text-[#22d3ee] border border-[#06b6d4]/40', +} +export const DEFAULT_SENATE_BADGE = 'bg-[#1e3a5f] text-[#93b8d8] border border-[#2d5f8f]/40' + +export function formatTime(time: string) { + const [h, m] = time.split(':') + const hour = parseInt(h) + const ampm = hour >= 12 ? 'PM' : 'AM' + const displayHour = hour % 12 || 12 + return `${displayHour}:${m} ${ampm}` +} + +export function formatDate(date: string) { + return new Date(date + 'T00:00:00').toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric' + }) +} diff --git a/app/(dashboard)/settings-modal.tsx b/app/(dashboard)/settings-modal.tsx index e9bc69d..48c53b1 100644 --- a/app/(dashboard)/settings-modal.tsx +++ b/app/(dashboard)/settings-modal.tsx @@ -25,6 +25,7 @@ interface AvailableBody { export interface Settings { full_name: string email_preferences: Record + senate_type_preferences: Record admin_role: string | null iems_role: string | null memberships: Membership[] @@ -32,6 +33,8 @@ export interface Settings { available_bodies: AvailableBody[] } +export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const + interface SettingsModalProps { onClose: () => void cachedSettings?: Settings | null @@ -126,8 +129,23 @@ export default function SettingsModal({ onClose, cachedSettings, onSettingsLoade }) } + const toggleSenateType = async (type: string, value: boolean) => { + if (!settings) return + const updated = { ...settings.senate_type_preferences, [type]: value } + setSettings(prev => prev ? { ...prev, senate_type_preferences: updated } : prev) + await fetch('/api/me/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ senate_type_preferences: updated }), + }) + // My Rooms only re-reads this preference when it (re)fetches its bookings, so + // nudge it to refresh if it's open underneath this modal. + window.dispatchEvent(new Event('chambers:senate-prefs-updated')) + } + const eligibleKeys = settings ? getPrefsForRole(settings.admin_role, settings.iems_role) : [] const selectedBody = settings?.available_bodies.find(b => b.id === selectedBodyId) + const isSenateMember = settings?.memberships.some(m => m.bodies?.name === 'Senate') ?? false return (
@@ -248,6 +266,26 @@ export default function SettingsModal({ onClose, cachedSettings, onSettingsLoade )}
+ {/* Senate Session Types */} + {!loading && isSenateMember && ( +
+

Senate Session Types Shown in My Rooms

+
+ {SENATE_TYPES.map(type => ( + + ))} +
+
+ )} + {/* Email Notifications */} {!loading && eligibleKeys.length > 0 && (
diff --git a/app/api/me/settings/route.ts b/app/api/me/settings/route.ts index 975b1e1..08de6ff 100644 --- a/app/api/me/settings/route.ts +++ b/app/api/me/settings/route.ts @@ -27,7 +27,7 @@ export async function GET() { ] = await Promise.all([ adminSupabase .from('users') - .select('full_name, email_preferences, admin_role, iems_role, board_memberships(id, role, bodies(id, name, division))') + .select('full_name, email_preferences, senate_type_preferences, admin_role, iems_role, board_memberships(id, role, bodies(id, name, division))') .eq('id', user.id) .single(), adminSupabase @@ -57,6 +57,7 @@ export async function GET() { return NextResponse.json({ full_name: profile.full_name, email_preferences: profile.email_preferences, + senate_type_preferences: profile.senate_type_preferences, admin_role: profile.admin_role, iems_role: profile.iems_role, memberships: profile.board_memberships ?? [], @@ -75,7 +76,7 @@ export async function PATCH(request: Request) { if (rateLimitRes) return rateLimitRes const body = await request.json() - const { full_name, email_preferences } = body + const { full_name, email_preferences, senate_type_preferences } = body if (full_name !== undefined && (typeof full_name !== 'string' || full_name.trim() === '')) { return NextResponse.json({ error: 'full_name must be a non-empty string' }, { status: 400 }) @@ -88,9 +89,17 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'email_preferences must be a plain object' }, { status: 400 }) } + if ( + senate_type_preferences !== undefined && + (typeof senate_type_preferences !== 'object' || Array.isArray(senate_type_preferences) || senate_type_preferences === null) + ) { + return NextResponse.json({ error: 'senate_type_preferences must be a plain object' }, { status: 400 }) + } + const updates: Record = {} if (full_name !== undefined) updates.full_name = full_name.trim() if (email_preferences !== undefined) updates.email_preferences = email_preferences + if (senate_type_preferences !== undefined) updates.senate_type_preferences = senate_type_preferences if (Object.keys(updates).length === 0) { return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }) diff --git a/app/api/my-rooms/route.ts b/app/api/my-rooms/route.ts index 7d33861..0b72b0c 100644 --- a/app/api/my-rooms/route.ts +++ b/app/api/my-rooms/route.ts @@ -14,8 +14,8 @@ export async function GET() { const isAdmin = !!user.app_metadata?.is_admin - // Get user's body memberships and active semester in parallel - const [{ data: memberships }, { data: activeSemester }] = await Promise.all([ + // Get user's body memberships, active semester, and Senate type preferences in parallel + const [{ data: memberships }, { data: activeSemester }, { data: profile }] = await Promise.all([ supabase .from('board_memberships') .select('body_id, role') @@ -25,10 +25,17 @@ export async function GET() { .select('id') .eq('is_active', true) .single(), + supabase + .from('users') + .select('senate_type_preferences') + .eq('id', user.id) + .single(), ]) + const senateTypePreferences = profile?.senate_type_preferences ?? {} + if (!memberships || memberships.length === 0) { - return NextResponse.json({ bookings: [], leadershipBodyIds: [] }) + return NextResponse.json({ bookings: [], leadershipBodyIds: [], senateTypePreferences }) } const bodyIds = memberships.map(m => m.body_id) @@ -42,6 +49,7 @@ export async function GET() { weeklyBookings: [], tablingBookings: [], leadershipBodyIds: [...leadershipBodyIds], + senateTypePreferences, }) } @@ -98,5 +106,6 @@ export async function GET() { tablingBookings: visible(tablingBookings || []), // Returned so the client doesn't have to re-query board_memberships itself. leadershipBodyIds: [...leadershipBodyIds], + senateTypePreferences, }) } \ No newline at end of file diff --git a/app/api/spaces/blackouts/[id]/route.ts b/app/api/spaces/blackouts/[id]/route.ts index c4589a1..8adf091 100644 --- a/app/api/spaces/blackouts/[id]/route.ts +++ b/app/api/spaces/blackouts/[id]/route.ts @@ -44,7 +44,7 @@ async function cascadeCancelBookings(spaceId: string | null, startTime: string, startTime: b.start_time, endTime: b.end_time, to: creatorEmail, - cc: ccEmails, + bcc: ccEmails, }) })).catch(e => console.error('Blackout cascade emails failed:', e)) ) diff --git a/app/api/spaces/blackouts/route.ts b/app/api/spaces/blackouts/route.ts index 1b97b64..f33edb9 100644 --- a/app/api/spaces/blackouts/route.ts +++ b/app/api/spaces/blackouts/route.ts @@ -113,7 +113,7 @@ export async function POST(request: Request) { startTime: b.start_time, endTime: b.end_time, to: creatorEmail, - cc: ccEmails, + bcc: ccEmails, }) })).catch(e => console.error('Blackout cascade emails failed:', e)) ) diff --git a/app/api/spaces/bookings/[id]/route.ts b/app/api/spaces/bookings/[id]/route.ts index fe94af8..5f684a5 100644 --- a/app/api/spaces/bookings/[id]/route.ts +++ b/app/api/spaces/bookings/[id]/route.ts @@ -194,7 +194,7 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ startTime: booking.start_time, endTime: booking.end_time, to: creatorEmail, - cc: ccEmails, + bcc: ccEmails, }) } } catch (e) { diff --git a/lib/emails/booking-updated.ts b/lib/emails/booking-updated.ts index 778628f..da1d6d4 100644 --- a/lib/emails/booking-updated.ts +++ b/lib/emails/booking-updated.ts @@ -37,7 +37,8 @@ export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams) await resend.emails.send({ from: process.env.RESEND_FROM_EMAIL!, - to: recipients, + to: process.env.RESEND_FROM_EMAIL!, + bcc: recipients, subject: 'Chambers \u2014 Your Booking Has Been Updated', text: `Your ${sBodyName} booking has been updated by a Chambers administrator. diff --git a/lib/emails/space-booking-cancelled.ts b/lib/emails/space-booking-cancelled.ts index f3241be..14e3321 100644 --- a/lib/emails/space-booking-cancelled.ts +++ b/lib/emails/space-booking-cancelled.ts @@ -8,7 +8,7 @@ interface SpaceBookingCancelledParams { startTime: string // ISO timestamptz endTime: string // ISO timestamptz to: string - cc?: string[] + bcc?: string[] } function formatDateTime(iso: string): string { @@ -77,7 +77,7 @@ function buildCancelIcs(bookingId: string, title: string, spaceName: string, sta } export async function sendSpaceBookingCancelledEmail(params: SpaceBookingCancelledParams) { - const { bookingId, title, spaceName, startTime, endTime, to, cc } = params + const { bookingId, title, spaceName, startTime, endTime, to, bcc } = params if (!to) return const sTitle = sanitize(title) @@ -85,7 +85,7 @@ export async function sendSpaceBookingCancelledEmail(params: SpaceBookingCancell await resend.emails.send({ from: process.env.RESEND_FROM_EMAIL!, to, - ...(cc?.length ? { cc } : {}), + ...(bcc?.length ? { bcc } : {}), subject: `Chambers — SGA Space Booking Cancelled: ${sTitle}`, text: `Your SGA Space booking has been cancelled. diff --git a/lib/emails/space-booking-confirmed.ts b/lib/emails/space-booking-confirmed.ts index ed952b1..debc705 100644 --- a/lib/emails/space-booking-confirmed.ts +++ b/lib/emails/space-booking-confirmed.ts @@ -89,7 +89,8 @@ export async function sendSpaceBookingConfirmedEmail(params: SpaceBookingConfirm await resend.emails.send({ from: process.env.RESEND_FROM_EMAIL!, - to: recipients, + to: process.env.RESEND_FROM_EMAIL!, + bcc: recipients, subject: `Chambers \u2014 SGA Space Booking Confirmed: ${sTitle}`, text: `Your SGA Space booking has been confirmed. diff --git a/package-lock.json b/package-lock.json index c8af67d..ca4e55d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chambers", - "version": "1.12.3", + "version": "1.12.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chambers", - "version": "1.12.3", + "version": "1.12.4", "dependencies": { "@supabase/ssr": "^0.9.0", "@supabase/supabase-js": "^2.99.1", diff --git a/package.json b/package.json index b6ae32d..73f7c3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chambers", - "version": "1.12.3", + "version": "1.12.4", "private": true, "scripts": { "dev": "next dev", diff --git a/supabase/migrations/20260825020000_add_senate_type_preferences.sql b/supabase/migrations/20260825020000_add_senate_type_preferences.sql new file mode 100644 index 0000000..d55fd1e --- /dev/null +++ b/supabase/migrations/20260825020000_add_senate_type_preferences.sql @@ -0,0 +1,6 @@ +-- Per-user preference for which Senate session types (Full Body, Weekly, Office +-- Hours) show up in My Rooms. Mirrors email_preferences: a jsonb map of type -> +-- boolean, where a missing key defaults to true (visible) so existing users see +-- everything until they opt out of a type. +alter table public.users + add column senate_type_preferences jsonb not null default '{}'::jsonb;