diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f88acd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + branches: + - main + - dev + push: + branches: + - dev + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + OPS_EMAIL: ${{ secrets.OPS_EMAIL }} + RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL }} + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + DISPLAY_KEY: ${{ secrets.DISPLAY_KEY }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_SIGNING_SECRET: ${{ secrets.SLACK_SIGNING_SECRET }} \ No newline at end of file diff --git a/app/(dashboard)/administrator/booking-modal.tsx b/app/(dashboard)/administrator/booking-modal.tsx index ca26e1a..775db33 100644 --- a/app/(dashboard)/administrator/booking-modal.tsx +++ b/app/(dashboard)/administrator/booking-modal.tsx @@ -9,7 +9,7 @@ interface BookingModalProps { export default function BookingModal({ title, onClose, children }: BookingModalProps) { return (
-
+

{title}

diff --git a/app/(dashboard)/administrator/one-time-form.tsx b/app/(dashboard)/administrator/one-time-form.tsx index 74a29ed..578f091 100644 --- a/app/(dashboard)/administrator/one-time-form.tsx +++ b/app/(dashboard)/administrator/one-time-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 OneTimeSession { room_name: string booking_date: string @@ -57,6 +78,22 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O const [sessions, setSessions] = useState([emptySession()]) 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 === 'One-Time Room') + ) + }) + .catch(() => {}) + }, []) + + const visibleRequests = form.body_id + ? pendingRequests.filter(r => r.body_id === form.body_id) + : pendingRequests const updateSession = (index: number, field: keyof OneTimeSession, value: string) => { setSessions(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s)) @@ -99,7 +136,8 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O const labelCls = "block text-xs font-medium text-[#93b8d8] mb-1" return ( -
+
+
{semesters.length === 0 ? ( @@ -244,6 +282,34 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O 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.start_time?.slice(0, 5) ?? '—'} – {d.end_time?.slice(0, 5) ?? '—'} +
+ ))} +
+ ))} +
+ )} +
+
) } diff --git a/app/(dashboard)/administrator/tabling-form.tsx b/app/(dashboard)/administrator/tabling-form.tsx index 12235a1..3557a7e 100644 --- a/app/(dashboard)/administrator/tabling-form.tsx +++ b/app/(dashboard)/administrator/tabling-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import TimePicker from './time-picker' const STATUSES = [ @@ -33,6 +33,27 @@ interface Session { status: string } +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 TablingFormProps { bodies: Body[] semesters: Semester[] @@ -62,6 +83,22 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T const [sessions, setSessions] = useState([emptySession()]) 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 === 'Tabling') + ) + }) + .catch(() => {}) + }, []) + + const visibleRequests = form.body_id + ? pendingRequests.filter(r => r.body_id === form.body_id) + : pendingRequests const updateSession = (index: number, field: keyof Session, value: string) => { setSessions(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s)) @@ -109,7 +146,8 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T } return ( -
+
+
{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 ( -
+
+
{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. +

+ + + )} + + {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 ( +
+
+
+
+

Chambers

+
+ {children} +
+
+ ) +} 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",