+
+
Semester *
{semesters.length === 0 ? (
@@ -255,6 +293,34 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T
Cancel
+
+
+
+
+
Open Requests
+ {visibleRequests.length === 0 ? (
+
No open requests
+ ) : (
+
+ {visibleRequests.map(r => (
+
+
+ {r.bodies?.name ?? '—'}
+ {new Date(r.created_at).toLocaleDateString()}
+
+
{r.purpose}
+ {(r.tabling_request_sessions ?? []).map((s, i) => (
+
+ {s.session_date ?? '—'}
+ {s.start_time?.slice(0, 5) ?? '—'} – {s.end_time?.slice(0, 5) ?? '—'}
+
+ ))}
+
+ ))}
+
+ )}
+
+
)
}
\ No newline at end of file
diff --git a/app/(dashboard)/administrator/weekly-form.tsx b/app/(dashboard)/administrator/weekly-form.tsx
index f2e8a93..7d9ea6a 100644
--- a/app/(dashboard)/administrator/weekly-form.tsx
+++ b/app/(dashboard)/administrator/weekly-form.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import TimePicker from './time-picker'
const STATUSES = [
@@ -25,6 +25,27 @@ interface Semester {
is_active: boolean
}
+interface PendingRequest {
+ id: string
+ type: string
+ purpose: string
+ status: string
+ created_at: string
+ body_id: string
+ bodies: { name: string } | null
+ room_request_details: Array<{
+ start_date: string | null
+ end_date: string | null
+ start_time: string | null
+ end_time: string | null
+ }> | null
+ tabling_request_sessions: Array<{
+ session_date: string | null
+ start_time: string | null
+ end_time: string | null
+ }> | null
+}
+
interface WeeklyFormProps {
bodies: Body[]
semesters: Semester[]
@@ -51,6 +72,22 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
+ const [pendingRequests, setPendingRequests] = useState
([])
+
+ useEffect(() => {
+ fetch('/api/administrator/requests')
+ .then(r => r.json())
+ .then(({ requests }) => {
+ setPendingRequests(
+ (requests ?? []).filter((r: PendingRequest) => r.status === 'Pending' && r.type === 'Weekly Room')
+ )
+ })
+ .catch(() => {})
+ }, [])
+
+ const visibleRequests = form.body_id
+ ? pendingRequests.filter(r => r.body_id === form.body_id)
+ : pendingRequests
const handleSubmit = async () => {
if (!semesterId) {
@@ -85,7 +122,8 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We
}
return (
-
+
+
Semester *
{semesters.length === 0 ? (
@@ -213,6 +251,34 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We
Cancel
+
+
+
+
+
Open Requests
+ {visibleRequests.length === 0 ? (
+
No open requests
+ ) : (
+
+ {visibleRequests.map(r => (
+
+
+ {r.bodies?.name ?? '—'}
+ {new Date(r.created_at).toLocaleDateString()}
+
+
{r.purpose}
+ {(r.room_request_details ?? []).map((d, i) => (
+
+
{d.start_date ?? '—'} – {d.end_date ?? '—'}
+
{d.start_time?.slice(0, 5) ?? '—'} – {d.end_time?.slice(0, 5) ?? '—'}
+
+ ))}
+
+ ))}
+
+ )}
+
+
)
}
\ No newline at end of file
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
index 2329e33..0abeecf 100644
--- a/app/(dashboard)/layout.tsx
+++ b/app/(dashboard)/layout.tsx
@@ -233,7 +233,7 @@ export default function DashboardLayout({
Chambers
NU Student Gov. Association
- v1.11.9
+ v1.12.0
{userName && (
{getGreeting()}, {userName}
diff --git a/app/(dashboard)/sga-spaces/page.tsx b/app/(dashboard)/sga-spaces/page.tsx
index 4bf2bdd..c77262e 100644
--- a/app/(dashboard)/sga-spaces/page.tsx
+++ b/app/(dashboard)/sga-spaces/page.tsx
@@ -154,11 +154,14 @@ export default function SGASpacesPage() {
const [limitHours, setLimitHours] = useState
(18)
const [minHoursAdvance, setMinHoursAdvance] = useState(24)
const [currentUserId, setCurrentUserId] = useState(null)
- const [canBook, setCanBook] = useState(false)
+ const [isAdmin, setIsAdmin] = useState(false)
+ const [isLeadership, setIsLeadership] = useState(false)
const [modalSlot, setModalSlot] = useState(null)
const [editBooking, setEditBooking] = useState(null)
const [calendarLoading, setCalendarLoading] = useState(false)
+ const canBook = isAdmin || isLeadership
+
const isTodayWeek = (() => {
const now = new Date()
const sun = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
@@ -170,14 +173,14 @@ export default function SGASpacesPage() {
const supabase = createClient()
supabase.auth.getUser().then(async ({ data: { user } }) => {
if (!user) return
- if (user.app_metadata?.is_admin) { setCanBook(true); return }
+ if (user.app_metadata?.is_admin) setIsAdmin(true)
const { data: memberships } = await supabase
.from('board_memberships')
.select('id')
.eq('user_id', user.id)
.eq('role', 'Leadership')
.limit(1)
- if (memberships && memberships.length > 0) setCanBook(true)
+ if (memberships && memberships.length > 0) setIsLeadership(true)
})
}, [])
@@ -351,6 +354,7 @@ export default function SGASpacesPage() {
blackouts={blackouts}
currentUserId={currentUserId ?? undefined}
minHoursAdvance={minHoursAdvance}
+ canBook={canBook}
onSlotClick={canBook ? (start, end) => setModalSlot({ start, end }) : () => {}}
onBookingClick={handleBookingClick}
/>
diff --git a/app/(dashboard)/sga-spaces/space-calendar.tsx b/app/(dashboard)/sga-spaces/space-calendar.tsx
index b56e4ac..aeb654b 100644
--- a/app/(dashboard)/sga-spaces/space-calendar.tsx
+++ b/app/(dashboard)/sga-spaces/space-calendar.tsx
@@ -26,6 +26,7 @@ interface SpaceCalendarProps {
blackouts: Blackout[]
currentUserId?: string
minHoursAdvance?: number
+ canBook?: boolean
onSlotClick: (startIso: string, endIso: string) => void
onBookingClick?: (booking: Booking) => void
}
@@ -81,6 +82,7 @@ export default function SpaceCalendar({
blackouts,
currentUserId,
minHoursAdvance = 24,
+ canBook = false,
onSlotClick,
onBookingClick,
}: SpaceCalendarProps) {
@@ -217,6 +219,10 @@ export default function SpaceCalendar({
// ── Mouse interaction ────────────────────────────────────────────────────────
const handleOverlayMouseMove = useCallback((e: React.MouseEvent, dayIdx: number) => {
+ if (!canBook) {
+ setOverlayCursor('default')
+ return
+ }
const slot = slotFromClientY(e.clientY)
if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) {
setOverlayCursor('default')
@@ -234,7 +240,7 @@ export default function SpaceCalendar({
setOverlayCursor('crosshair')
setHoveredBookingId(null)
}
- }, [slotFromClientY, isSlotBlocked, isSlotInNoticeZone, isSlotBooked, bookingsByDay, currentUserId])
+ }, [canBook, slotFromClientY, isSlotBlocked, isSlotInNoticeZone, isSlotBooked, bookingsByDay, currentUserId])
const handleColumnMouseDown = useCallback((e: React.MouseEvent, dayIdx: number) => {
e.preventDefault()
@@ -247,9 +253,10 @@ export default function SpaceCalendar({
}
return
}
+ if (!canBook) return
dragRef.current = { dayIdx, startSlot: slot, currentSlot: slot }
setDragPreview({ dayIdx, startSlot: slot, endSlot: slot + 1 })
- }, [slotFromClientY, isSlotBlocked, isSlotInNoticeZone, isSlotBooked, currentUserId, onBookingClick, bookingsByDay])
+ }, [canBook, slotFromClientY, isSlotBlocked, isSlotInNoticeZone, isSlotBooked, currentUserId, onBookingClick, bookingsByDay])
const clampEndSlot = useCallback((dayIdx: number, startSlot: number, rawEnd: number): number => {
let end = Math.max(startSlot + 1, rawEnd)
@@ -434,7 +441,7 @@ export default function SpaceCalendar({
)}
{/* Drag selection preview */}
- {dragPreview && dragPreview.dayIdx === dayIdx && (
+ {canBook && dragPreview && dragPreview.dayIdx === dayIdx && (
handleOverlayMouseMove(e, dayIdx)}
- onMouseLeave={() => { setOverlayCursor('crosshair'); setHoveredBookingId(null) }}
+ onMouseLeave={() => { setOverlayCursor(canBook ? 'crosshair' : 'default'); setHoveredBookingId(null) }}
onMouseDown={e => handleColumnMouseDown(e, dayIdx)}
/>
diff --git a/app/api/slack/command/route.ts b/app/api/slack/command/route.ts
new file mode 100644
index 0000000..5f42314
--- /dev/null
+++ b/app/api/slack/command/route.ts
@@ -0,0 +1,279 @@
+import { randomBytes } from 'crypto'
+import { createClient as createAdminClient } from '@supabase/supabase-js'
+import { verifySlackRequest } from '@/lib/slack-verify'
+import { checkRateLimit } from '@/lib/check-rate-limit'
+
+const adminSupabase = createAdminClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
+)
+
+function generateTimeOptions() {
+ const opts = []
+ for (let h = 7; h < 24; h++) {
+ for (let m = 0; m < 60; m += 15) {
+ const h12 = h % 12 || 12
+ const ampm = h < 12 ? 'AM' : 'PM'
+ const label = `${h12}:${String(m).padStart(2, '0')} ${ampm}`
+ const value = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+ opts.push({ text: { type: 'plain_text', text: label }, value })
+ }
+ }
+ return opts
+}
+
+function buildRoomModal(bodies: { id: string; name: string }[]) {
+ const timeOptions = generateTimeOptions()
+ return {
+ type: 'modal',
+ callback_id: 'chambers_room_request',
+ title: { type: 'plain_text', text: 'Room Booking Request' },
+ submit: { type: 'plain_text', text: 'Submit' },
+ close: { type: 'plain_text', text: 'Cancel' },
+ blocks: [
+ {
+ type: 'input',
+ block_id: 'body_block',
+ label: { type: 'plain_text', text: 'Body' },
+ element: {
+ type: 'static_select',
+ action_id: 'body_action',
+ placeholder: { type: 'plain_text', text: 'Select a body' },
+ options: bodies.map(b => ({
+ text: { type: 'plain_text', text: b.name },
+ value: b.id,
+ })),
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'purpose_block',
+ label: { type: 'plain_text', text: 'Purpose' },
+ element: {
+ type: 'plain_text_input',
+ action_id: 'purpose_action',
+ placeholder: { type: 'plain_text', text: 'What is this room for?' },
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'room_block',
+ label: { type: 'plain_text', text: 'Preferred Room' },
+ optional: true,
+ element: {
+ type: 'plain_text_input',
+ action_id: 'room_action',
+ placeholder: { type: 'plain_text', text: 'e.g. 209 Ell Hall' },
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'date_block',
+ label: { type: 'plain_text', text: 'Date' },
+ element: {
+ type: 'datepicker',
+ action_id: 'date_action',
+ placeholder: { type: 'plain_text', text: 'Select a date' },
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'start_time_block',
+ label: { type: 'plain_text', text: 'Start Time' },
+ element: {
+ type: 'static_select',
+ action_id: 'start_time_action',
+ placeholder: { type: 'plain_text', text: 'Select start time' },
+ options: timeOptions,
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'end_time_block',
+ label: { type: 'plain_text', text: 'End Time' },
+ element: {
+ type: 'static_select',
+ action_id: 'end_time_action',
+ placeholder: { type: 'plain_text', text: 'Select end time' },
+ options: timeOptions,
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'notes_block',
+ label: { type: 'plain_text', text: 'Additional Notes' },
+ optional: true,
+ element: {
+ type: 'plain_text_input',
+ action_id: 'notes_action',
+ multiline: true,
+ placeholder: { type: 'plain_text', text: 'Any additional details...' },
+ },
+ },
+ ],
+ }
+}
+
+function buildTablingModal(bodies: { id: string; name: string }[]) {
+ const timeOptions = generateTimeOptions()
+ return {
+ type: 'modal',
+ callback_id: 'chambers_table_request',
+ title: { type: 'plain_text', text: 'Tabling Request' },
+ submit: { type: 'plain_text', text: 'Submit' },
+ close: { type: 'plain_text', text: 'Cancel' },
+ blocks: [
+ {
+ type: 'input',
+ block_id: 'body_block',
+ label: { type: 'plain_text', text: 'Body' },
+ element: {
+ type: 'static_select',
+ action_id: 'body_action',
+ placeholder: { type: 'plain_text', text: 'Select a body' },
+ options: bodies.map(b => ({
+ text: { type: 'plain_text', text: b.name },
+ value: b.id,
+ })),
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'purpose_block',
+ label: { type: 'plain_text', text: 'Purpose' },
+ element: {
+ type: 'plain_text_input',
+ action_id: 'purpose_action',
+ placeholder: { type: 'plain_text', text: 'What is this tabling session for?' },
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'date_block',
+ label: { type: 'plain_text', text: 'Date' },
+ element: {
+ type: 'datepicker',
+ action_id: 'date_action',
+ placeholder: { type: 'plain_text', text: 'Select a date' },
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'start_time_block',
+ label: { type: 'plain_text', text: 'Start Time' },
+ element: {
+ type: 'static_select',
+ action_id: 'start_time_action',
+ placeholder: { type: 'plain_text', text: 'Select start time' },
+ options: timeOptions,
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'end_time_block',
+ label: { type: 'plain_text', text: 'End Time' },
+ element: {
+ type: 'static_select',
+ action_id: 'end_time_action',
+ placeholder: { type: 'plain_text', text: 'Select end time' },
+ options: timeOptions,
+ },
+ },
+ {
+ type: 'input',
+ block_id: 'notes_block',
+ label: { type: 'plain_text', text: 'Additional Notes' },
+ optional: true,
+ element: {
+ type: 'plain_text_input',
+ action_id: 'notes_action',
+ multiline: true,
+ placeholder: { type: 'plain_text', text: 'Any additional details...' },
+ },
+ },
+ ],
+ }
+}
+
+export async function POST(request: Request) {
+ const rawBody = await request.text()
+
+ const valid = await verifySlackRequest(
+ rawBody,
+ request.headers.get('X-Slack-Request-Timestamp'),
+ request.headers.get('X-Slack-Signature')
+ )
+ if (!valid) return new Response('Unauthorized', { status: 401 })
+
+ const params = new URLSearchParams(rawBody)
+ const command = params.get('command')
+ const triggerId = params.get('trigger_id')
+ const slackUserId = params.get('user_id')
+
+ if (!triggerId || !slackUserId) {
+ return new Response('Bad Request', { status: 400 })
+ }
+
+ const rateLimitRes = await checkRateLimit(`slack:${slackUserId}`)
+ if (rateLimitRes) return rateLimitRes
+
+ const { data: connection } = await adminSupabase
+ .from('slack_connections')
+ .select('chambers_user_id')
+ .eq('slack_user_id', slackUserId)
+ .maybeSingle()
+
+ if (!connection) {
+ const token = randomBytes(32).toString('hex')
+ const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString()
+
+ await adminSupabase
+ .from('slack_connect_tokens')
+ .delete()
+ .eq('slack_user_id', slackUserId)
+ .lt('expires_at', new Date().toISOString())
+
+ await adminSupabase
+ .from('slack_connect_tokens')
+ .insert({ token, slack_user_id: slackUserId, expires_at: expiresAt })
+
+ await fetch('https://slack.com/api/chat.postMessage', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+ },
+ body: JSON.stringify({
+ channel: slackUserId,
+ text: `To use Chambers from Slack, link your account here (expires in 15 minutes): https://chambers.northeasternsga.com/slack/connect?token=${token}`,
+ }),
+ })
+
+ return Response.json({
+ response_type: 'ephemeral',
+ text: "Check your DMs — we've sent you a link to connect your Chambers account.",
+ })
+ }
+
+ const { data: bodies } = await adminSupabase
+ .from('bodies')
+ .select('id, name')
+ .eq('is_active', true)
+ .order('name', { ascending: true })
+
+ const modal =
+ command === '/chambers-table'
+ ? buildTablingModal(bodies ?? [])
+ : buildRoomModal(bodies ?? [])
+
+ await fetch('https://slack.com/api/views.open', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+ },
+ body: JSON.stringify({ trigger_id: triggerId, view: modal }),
+ })
+
+ return new Response(null, { status: 200 })
+}
diff --git a/app/api/slack/connect/route.ts b/app/api/slack/connect/route.ts
new file mode 100644
index 0000000..8024400
--- /dev/null
+++ b/app/api/slack/connect/route.ts
@@ -0,0 +1,73 @@
+import { createClient as createAdminClient } from '@supabase/supabase-js'
+import { NextResponse } from 'next/server'
+import { createClient } from '@/lib/supabase/server'
+
+const adminSupabase = createAdminClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
+)
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const token = searchParams.get('token')
+
+ if (!token) {
+ return NextResponse.json({ error: 'Invalid or expired token.' }, { status: 400 })
+ }
+
+ const { data } = await adminSupabase
+ .from('slack_connect_tokens')
+ .select('slack_user_id')
+ .eq('token', token)
+ .gt('expires_at', new Date().toISOString())
+ .maybeSingle()
+
+ if (!data) {
+ return NextResponse.json({ error: 'Invalid or expired token.' }, { status: 400 })
+ }
+
+ return NextResponse.json({ slack_user_id: data.slack_user_id })
+}
+
+export async function POST(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { token } = await request.json()
+
+ if (!token) {
+ return NextResponse.json({ error: 'Missing required fields.' }, { status: 400 })
+ }
+
+ const { data: tokenRow } = await adminSupabase
+ .from('slack_connect_tokens')
+ .select('id, slack_user_id')
+ .eq('token', token)
+ .gt('expires_at', new Date().toISOString())
+ .maybeSingle()
+
+ if (!tokenRow) {
+ return NextResponse.json({ error: 'Invalid or expired token.' }, { status: 400 })
+ }
+
+ const { error: upsertError } = await adminSupabase
+ .from('slack_connections')
+ .upsert(
+ { slack_user_id: tokenRow.slack_user_id, chambers_user_id: user.id },
+ { onConflict: 'slack_user_id' }
+ )
+
+ if (upsertError) {
+ return NextResponse.json({ error: 'Failed to link account.' }, { status: 500 })
+ }
+
+ await adminSupabase
+ .from('slack_connect_tokens')
+ .delete()
+ .eq('id', tokenRow.id)
+
+ return NextResponse.json({ success: true })
+}
diff --git a/app/api/slack/interaction/route.ts b/app/api/slack/interaction/route.ts
new file mode 100644
index 0000000..18311b5
--- /dev/null
+++ b/app/api/slack/interaction/route.ts
@@ -0,0 +1,183 @@
+import { waitUntil } from '@vercel/functions'
+import { createClient as createAdminClient } from '@supabase/supabase-js'
+import { verifySlackRequest } from '@/lib/slack-verify'
+import { checkRateLimit } from '@/lib/check-rate-limit'
+
+const adminSupabase = createAdminClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
+)
+
+function getMinDateStr(days: number): string {
+ const d = new Date()
+ d.setUTCDate(d.getUTCDate() + days)
+ return d.toISOString().split('T')[0]
+}
+
+function blockError(errors: Record) {
+ return Response.json({ response_action: 'errors', errors })
+}
+
+export async function POST(request: Request) {
+ const rawBody = await request.text()
+
+ const valid = await verifySlackRequest(
+ rawBody,
+ request.headers.get('X-Slack-Request-Timestamp'),
+ request.headers.get('X-Slack-Signature')
+ )
+ if (!valid) return new Response('Unauthorized', { status: 401 })
+
+ const params = new URLSearchParams(rawBody)
+ const payloadRaw = params.get('payload')
+ if (!payloadRaw) return new Response(null, { status: 200 })
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const payload: any = JSON.parse(payloadRaw)
+
+ if (payload.type !== 'view_submission') {
+ return new Response(null, { status: 200 })
+ }
+
+ const callbackId: string = payload.view.callback_id
+ const isRoom = callbackId === 'chambers_room_request'
+ const isTabling = callbackId === 'chambers_table_request'
+
+ if (!isRoom && !isTabling) {
+ return new Response(null, { status: 200 })
+ }
+
+ const slackUserId: string = payload.user.id
+
+ const { data: connection } = await adminSupabase
+ .from('slack_connections')
+ .select('chambers_user_id')
+ .eq('slack_user_id', slackUserId)
+ .maybeSingle()
+
+ if (!connection) {
+ return blockError({ purpose_block: 'Your Slack account is not linked to Chambers. Please connect at https://chambers.northeasternsga.com/slack/connect' })
+ }
+
+ const rateLimitRes = await checkRateLimit(connection.chambers_user_id)
+ if (rateLimitRes) return rateLimitRes
+
+ const vals = payload.view.state.values
+ const body_id: string = vals.body_block.body_action.selected_option?.value
+ const purpose: string = vals.purpose_block.purpose_action.value
+
+ const { data: bodyExists } = await adminSupabase
+ .from('bodies')
+ .select('id')
+ .eq('id', body_id)
+ .eq('is_active', true)
+ .maybeSingle()
+
+ if (!bodyExists) {
+ return blockError({ body_block: 'Invalid body selected.' })
+ }
+ const room_name: string | null = vals.room_block?.room_action?.value || null
+ const date: string = vals.date_block.date_action.selected_date
+ const start_time: string = vals.start_time_block.start_time_action.selected_option?.value
+ const end_time: string = vals.end_time_block.end_time_action.selected_option?.value
+ const notes: string | null = vals.notes_block.notes_action.value || null
+
+ if (end_time <= start_time) {
+ return blockError({ end_time_block: 'End time must be after start time.' })
+ }
+
+ const { data: settings } = await adminSupabase
+ .from('app_settings')
+ .select('min_days_advance_room, min_days_advance_tabling')
+ .eq('id', 1)
+ .maybeSingle()
+
+ const minDaysRoom = settings?.min_days_advance_room ?? 0
+ const minDaysTabling = settings?.min_days_advance_tabling ?? 0
+
+ if (isRoom && minDaysRoom > 0) {
+ const minDate = getMinDateStr(minDaysRoom)
+ if (date < minDate) {
+ return blockError({
+ date_block: `Room bookings require at least ${minDaysRoom} day${minDaysRoom === 1 ? '' : 's'} advance notice. Please select a date of ${minDate} or later.`,
+ })
+ }
+ }
+
+ if (isTabling && minDaysTabling > 0) {
+ const minDate = getMinDateStr(minDaysTabling)
+ if (date < minDate) {
+ return blockError({
+ date_block: `Tabling bookings require at least ${minDaysTabling} day${minDaysTabling === 1 ? '' : 's'} advance notice. Please select a date of ${minDate} or later.`,
+ })
+ }
+ }
+
+ const type = isRoom ? 'One-Time Room' : 'Tabling'
+
+ const { data: roomRequest, error: requestError } = await adminSupabase
+ .from('room_requests')
+ .insert({
+ type,
+ body_id,
+ purpose,
+ notes: notes || null,
+ requested_by: connection.chambers_user_id,
+ status: 'Pending',
+ })
+ .select()
+ .single()
+
+ if (requestError) {
+ return blockError({ purpose_block: 'Something went wrong. Please try again.' })
+ }
+
+ if (isRoom) {
+ const { error: detailError } = await adminSupabase
+ .from('room_request_details')
+ .insert({
+ request_id: roomRequest.id,
+ room_name: room_name || null,
+ start_date: date,
+ start_time,
+ end_time,
+ end_date: null,
+ })
+
+ if (detailError) {
+ return blockError({ purpose_block: 'Something went wrong. Please try again.' })
+ }
+ }
+
+ if (isTabling) {
+ const { error: sessionError } = await adminSupabase
+ .from('tabling_request_sessions')
+ .insert({
+ request_id: roomRequest.id,
+ session_date: date,
+ start_time,
+ end_time,
+ })
+
+ if (sessionError) {
+ return blockError({ purpose_block: 'Something went wrong. Please try again.' })
+ }
+ }
+
+ waitUntil(
+ fetch('https://slack.com/api/chat.postEphemeral', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}`,
+ },
+ body: JSON.stringify({
+ channel: slackUserId,
+ user: slackUserId,
+ text: 'Your booking request has been submitted! You can view it at https://chambers.northeasternsga.com/request',
+ }),
+ }).catch(() => {})
+ )
+
+ return Response.json({ response_action: 'clear' })
+}
diff --git a/app/api/spaces/limit-overrides/route.ts b/app/api/spaces/limit-overrides/route.ts
index d2d194b..e6cc398 100644
--- a/app/api/spaces/limit-overrides/route.ts
+++ b/app/api/spaces/limit-overrides/route.ts
@@ -14,13 +14,24 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const { data, error } = await adminSupabase
+ const { data: overrides, error } = await adminSupabase
.from('space_weekly_limit_overrides')
- .select('*, users!space_weekly_limit_overrides_user_id_fkey(id, full_name, email)')
+ .select('*')
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
- return NextResponse.json(data)
+ if (!overrides || overrides.length === 0) return NextResponse.json([])
+
+ const userIds = overrides.map((o: { user_id: string }) => o.user_id)
+ const { data: users, error: usersError } = await adminSupabase
+ .from('users')
+ .select('id, full_name, email')
+ .in('id', userIds)
+
+ if (usersError) return NextResponse.json({ error: usersError.message }, { status: 500 })
+
+ const usersById = Object.fromEntries((users ?? []).map((u: { id: string; full_name: string; email: string }) => [u.id, u]))
+ return NextResponse.json(overrides.map((o: { user_id: string }) => ({ ...o, users: usersById[o.user_id] ?? null })))
}
export async function POST(request: Request) {
diff --git a/app/faq/page.tsx b/app/faq/page.tsx
index b61d702..09e3996 100644
--- a/app/faq/page.tsx
+++ b/app/faq/page.tsx
@@ -32,7 +32,7 @@ export default async function FaqPage() {
- v1.12.0
+ v1.13.0
We don't exactly know yet! If there's anything you'd like to see, send a Slack DM to the Vice President of Operational Affairs ({vpName}) and the Digital Innovation Manager ({dimName}).
diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx
index de46028..0cd5bf7 100644
--- a/app/onboarding/page.tsx
+++ b/app/onboarding/page.tsx
@@ -154,7 +154,7 @@ export default function OnboardingPage() {
return
}
- router.push('/my-rooms')
+ window.location.href = '/my-rooms'
}
const toggleBody = (id: string) => {
diff --git a/app/slack/connect/SlackConnectForm.tsx b/app/slack/connect/SlackConnectForm.tsx
new file mode 100644
index 0000000..86f9fe8
--- /dev/null
+++ b/app/slack/connect/SlackConnectForm.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+
+type Status = 'validating' | 'idle' | 'loading' | 'success' | 'error'
+
+export default function SlackConnectForm({ token }: { token: string }) {
+ const [status, setStatus] = useState('validating')
+ const [errorMessage, setErrorMessage] = useState('')
+
+ useEffect(() => {
+ fetch(`/api/slack/connect?token=${encodeURIComponent(token)}`)
+ .then(res => {
+ if (!res.ok) setStatus('error'), setErrorMessage('This link has expired or is invalid.')
+ else setStatus('idle')
+ })
+ .catch(() => {
+ setStatus('error')
+ setErrorMessage('Something went wrong. Please try again.')
+ })
+ }, [token])
+
+ async function handleConfirm() {
+ setStatus('loading')
+ try {
+ const res = await fetch('/api/slack/connect', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token }),
+ })
+ if (!res.ok) {
+ const data = await res.json()
+ setErrorMessage(data.error ?? 'Something went wrong. Please try again.')
+ setStatus('error')
+ } else {
+ setStatus('success')
+ }
+ } catch {
+ setErrorMessage('Something went wrong. Please try again.')
+ setStatus('error')
+ }
+ }
+
+ return (
+
+
+
+
+
Chambers
+
Connect Slack
+
+
+ {status === 'validating' && (
+
Verifying link...
+ )}
+
+ {status === 'idle' && (
+ <>
+
+ Click below to link your Slack account to your Chambers account.
+
+
+ Connect my Slack account
+
+ >
+ )}
+
+ {status === 'loading' && (
+
Linking your account...
+ )}
+
+ {status === 'success' && (
+
+ Your Slack account is now linked. You can close this tab.
+
+ )}
+
+ {status === 'error' && (
+
{errorMessage}
+ )}
+
+
+ )
+}
diff --git a/app/slack/connect/page.tsx b/app/slack/connect/page.tsx
new file mode 100644
index 0000000..d10d6f4
--- /dev/null
+++ b/app/slack/connect/page.tsx
@@ -0,0 +1,55 @@
+import { redirect } from 'next/navigation'
+import { createClient } from '@/lib/supabase/server'
+import SlackConnectForm from './SlackConnectForm'
+
+export default async function SlackConnectPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ token?: string }>
+}) {
+ const { token } = await searchParams
+
+ const supabase = await createClient()
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (!user) {
+ const redirectTo = token
+ ? `/?redirectTo=${encodeURIComponent(`/slack/connect?token=${token}`)}`
+ : '/'
+ redirect(redirectTo)
+ }
+
+ if (!token) {
+ return (
+
+
+ This connect link is invalid. Please use the link sent to you in Slack.
+
+
+ )
+ }
+
+ return
+}
+
+function PageShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ )
+}
diff --git a/lib/slack-verify.ts b/lib/slack-verify.ts
new file mode 100644
index 0000000..c2497ed
--- /dev/null
+++ b/lib/slack-verify.ts
@@ -0,0 +1,22 @@
+import { createHmac, timingSafeEqual } from 'crypto'
+
+export async function verifySlackRequest(
+ rawBody: string,
+ timestamp: string | null,
+ signature: string | null
+): Promise {
+ if (!timestamp || !signature) return false
+ const now = Math.floor(Date.now() / 1000)
+ if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false
+ const sigBase = `v0:${timestamp}:${rawBody}`
+ const expected =
+ 'v0=' +
+ createHmac('sha256', process.env.SLACK_SIGNING_SECRET!)
+ .update(sigBase)
+ .digest('hex')
+ try {
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
+ } catch {
+ return false
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 39398d8..015a6f6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,18 @@
{
"name": "chambers",
- "version": "1.11.9",
+ "version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chambers",
- "version": "1.11.9",
+ "version": "1.12.0",
"dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.99.1",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
+ "@vercel/functions": "^3.7.1",
"next": "16.1.6",
"next-pwa": "^5.6.0",
"react": "19.2.3",
@@ -3646,6 +3647,75 @@
"uncrypto": "^0.1.3"
}
},
+ "node_modules/@vercel/cli-config": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@vercel/cli-config/-/cli-config-0.2.0.tgz",
+ "integrity": "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "xdg-app-paths": "5",
+ "zod": "4.1.11"
+ }
+ },
+ "node_modules/@vercel/cli-config/node_modules/zod": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
+ "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@vercel/cli-exec": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@vercel/cli-exec/-/cli-exec-0.1.1.tgz",
+ "integrity": "sha512-LMRMEai3Z+BODyxGcU9+KiWrS/UElNiOLKiNRfGNt2Vu3NTEmXgFeXG9wBfocAnTe5yJCX/DY6k3k7S/LkPp/g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "execa": "5.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/@vercel/functions": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/@vercel/functions/-/functions-3.7.1.tgz",
+ "integrity": "sha512-7PsCL2Vz4MKz4t+Nxu8u4mu/t66y1xv9I0njb3EYoFoFCsXchOChT7YFgXZYFQiUXFEifBpnoLma4e+ep7cKKA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@vercel/oidc": "3.6.1"
+ },
+ "engines": {
+ "node": ">= 20"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-provider-web-identity": "*",
+ "ws": ">=8"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-provider-web-identity": {
+ "optional": true
+ },
+ "ws": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vercel/oidc": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.6.1.tgz",
+ "integrity": "sha512-8ipTFoiX3WBRrvXLjSrmgAiwtMDQk3EgSxe8N7v2rXBz39NBIIyoGXeVbJRoBcP8WEuVnvjvIQsggbGU7ZKrMw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@vercel/cli-config": "0.2.0",
+ "@vercel/cli-exec": "0.1.1",
+ "jose": "^5.9.6"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -4561,7 +4631,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -5511,6 +5580,29 @@
"node": ">=0.8.x"
}
},
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5848,6 +5940,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-symbol-description": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
@@ -6090,6 +6194,15 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
@@ -6641,7 +6754,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/iterator.prototype": {
@@ -6718,6 +6830,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7321,6 +7442,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -7520,6 +7650,18 @@
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"license": "MIT"
},
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -7648,6 +7790,21 @@
"wrappy": "1"
}
},
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7666,6 +7823,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/os-paths": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/os-paths/-/os-paths-4.4.0.tgz",
+ "integrity": "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0"
+ }
+ },
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -7774,7 +7940,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8529,7 +8694,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -8542,7 +8706,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8620,6 +8783,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -8842,6 +9011,15 @@
"node": ">=10"
}
},
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -9677,7 +9855,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -10085,6 +10262,31 @@
}
}
},
+ "node_modules/xdg-app-paths": {
+ "version": "5.5.1",
+ "resolved": "https://registry.npmjs.org/xdg-app-paths/-/xdg-app-paths-5.5.1.tgz",
+ "integrity": "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "os-paths": "^4.0.1",
+ "xdg-portable": "^7.2.0"
+ },
+ "engines": {
+ "node": ">= 6.0"
+ }
+ },
+ "node_modules/xdg-portable": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/xdg-portable/-/xdg-portable-7.3.0.tgz",
+ "integrity": "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "os-paths": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6.0"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 056c282..2b342cc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "chambers",
- "version": "1.11.9",
+ "version": "1.12.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,6 +13,7 @@
"@supabase/supabase-js": "^2.99.1",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
+ "@vercel/functions": "^3.7.1",
"next": "16.1.6",
"next-pwa": "^5.6.0",
"react": "19.2.3",