Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/(dashboard)/bookings/sga-spaces-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface SpaceBooking {
start_time: string
end_time: string
attendee_ids: string[]
external_attendees?: string[] | null
creator_name: string | null
/** The weekly series this booking is one week of (issue #112). */
series_id: string | null
Expand Down Expand Up @@ -308,7 +309,7 @@ function AdminBookingsPanel({ spaces }: { spaces: Space[] }) {
<td className="px-4 py-3 text-[#93b8d8]">{b.creator_name ?? '—'}</td>
<td className="px-4 py-3 text-[#93b8d8] whitespace-nowrap">{formatDateTime(b.start_time)}</td>
<td className="px-4 py-3 text-[#93b8d8] whitespace-nowrap">{formatDateTime(b.end_time)}</td>
<td className="px-4 py-3 text-[#93b8d8]">{(b.attendee_ids ?? []).length + 1}</td>
<td className="px-4 py-3 text-[#93b8d8]">{(b.attendee_ids ?? []).length + (b.external_attendees ?? []).length + 1}</td>
<td className="px-4 py-3">
<button
onClick={() => cancelBooking(b.id)}
Expand Down
4 changes: 4 additions & 0 deletions app/(dashboard)/sga-spaces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface Booking {
start_time: string
end_time: string
attendee_ids: string[]
external_attendees?: string[] | null
creator_name: string | null
/** The weekly series this booking is one week of (issue #112). */
series_id: string | null
Expand Down Expand Up @@ -51,6 +52,7 @@ interface EditBooking {
start: string
end: string
attendees: { id: string; full_name: string; email: string }[]
externalAttendees: string[]
seriesId: string | null
}

Expand Down Expand Up @@ -285,6 +287,7 @@ export default function SGASpacesPage() {
start: booking.start_time,
end: booking.end_time,
attendees,
externalAttendees: booking.external_attendees ?? [],
seriesId: booking.series_id ?? null,
})
}, [spaces])
Expand Down Expand Up @@ -495,6 +498,7 @@ export default function SGASpacesPage() {
editBookingId={editBooking.id}
initialTitle={editBooking.title}
initialAttendees={editBooking.attendees}
initialExternalAttendees={editBooking.externalAttendees}
spaces={spaces}
minHoursAdvance={minHoursAdvance}
onClose={() => setEditBooking(null)}
Expand Down
87 changes: 81 additions & 6 deletions app/(dashboard)/sga-spaces/space-booking-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import TimePicker from '../bookings/time-picker'
import DateField from '@/app/_components/date-field'
import { advanceNoticeError } from '@/lib/spaces-advance-notice'
import { SERIES_CONFLICT_LABELS, addDays, weekdayOf, type SeriesConflict } from '@/lib/space-series'
import { isSgaEmail } from '@/lib/spaces-email'

interface User {
id: string
Expand All @@ -29,6 +30,8 @@ interface SpaceBookingModalProps {
editBookingId?: string
initialTitle?: string
initialAttendees?: User[]
/** Attendees without a Chambers account, by address (issue #132). */
initialExternalAttendees?: string[]
onCancelBooking?: () => Promise<void>
spaces?: Space[]
/**
Expand Down Expand Up @@ -56,6 +59,7 @@ interface SeriesInfo {
space_id: string
title: string
attendee_ids: string[]
external_attendees: string[]
start_time: string
end_time: string
ends_on: string
Expand Down Expand Up @@ -99,6 +103,7 @@ export default function SpaceBookingModal({
editBookingId,
initialTitle = '',
initialAttendees = [],
initialExternalAttendees = [],
onCancelBooking,
spaces,
busySpaceIds,
Expand All @@ -118,6 +123,7 @@ export default function SpaceBookingModal({
const [startTime, setStartTime] = useState(initStartTime)
const [endTime, setEndTime] = useState(initEndTime)
const [attendees, setAttendees] = useState<User[]>(initialAttendees)
const [externalAttendees, setExternalAttendees] = useState<string[]>(initialExternalAttendees)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<User[]>([])
const [searchLoading, setSearchLoading] = useState(false)
Expand Down Expand Up @@ -149,6 +155,7 @@ export default function SpaceBookingModal({
const [conflicts, setConflicts] = useState<{ key: string; list: SeriesConflict[]; applicable: number } | null>(null)
const formKey = JSON.stringify([
scope, selectedSpaceId, title.trim(), date, startTime, endTime, repeat, until, attendees.map(a => a.id),
externalAttendees,
])
const activeConflicts = conflicts?.key === formKey ? conflicts : null

Expand Down Expand Up @@ -188,6 +195,31 @@ export default function SpaceBookingModal({
setAttendees(prev => prev.filter(a => a.id !== id))
}

/**
* Someone without a Chambers account (issue #132), added by their university
* address. Offered only when the search found no Chambers user with that exact
* address -- someone who has an account should be added as themselves, so
* their own choice of inbox applies.
*/
const typedEmail = searchQuery.trim().toLowerCase()
const canAddExternal =
isSgaEmail(typedEmail) &&
!searchLoading &&
!externalAttendees.includes(typedEmail) &&
!attendees.some(a => a.email.toLowerCase() === typedEmail) &&
!searchResults.some(u => u.email.toLowerCase() === typedEmail)

const addExternalAttendee = () => {
if (!canAddExternal) return
setExternalAttendees(prev => [...prev, typedEmail])
setSearchQuery('')
setSearchResults([])
}

const removeExternalAttendee = (email: string) => {
setExternalAttendees(prev => prev.filter(e => e !== email))
}

/**
* Switches between editing this week and editing the series, loading each
* one's own values into the form. The series' values can differ from this
Expand All @@ -207,6 +239,7 @@ export default function SpaceBookingModal({
setStartTime(initStartTime)
setEndTime(initEndTime)
setAttendees(initialAttendees)
setExternalAttendees(initialExternalAttendees)
return
}

Expand Down Expand Up @@ -238,6 +271,7 @@ export default function SpaceBookingModal({
setEndTime(info.end_time)
setUntil(info.ends_on)
setAttendees(seriesAttendees)
setExternalAttendees(info.external_attendees ?? [])
setScope('series')
} finally {
setSeriesLoading(false)
Expand Down Expand Up @@ -286,6 +320,7 @@ export default function SpaceBookingModal({
setSubmitting(true)
try {
const attendee_ids = attendees.map(a => a.id)
const external_attendees = externalAttendees
let res: Response

if (editingSeries && seriesId) {
Expand All @@ -294,7 +329,7 @@ export default function SpaceBookingModal({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(), start_time: startTime, end_time: endTime, until, attendee_ids,
skip_conflicts: skipConflicts,
external_attendees, skip_conflicts: skipConflicts,
}),
})
} else if (creatingSeries) {
Expand All @@ -303,7 +338,7 @@ export default function SpaceBookingModal({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
space_id: selectedSpaceId, title: title.trim(), date, start_time: startTime, end_time: endTime, until,
attendee_ids, skip_conflicts: skipConflicts,
attendee_ids, external_attendees, skip_conflicts: skipConflicts,
}),
})
} else {
Expand All @@ -313,12 +348,12 @@ export default function SpaceBookingModal({
? await fetch(`/api/spaces/bookings/${editBookingId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: selectedSpaceId, title: title.trim(), start_time, end_time, attendee_ids }),
body: JSON.stringify({ space_id: selectedSpaceId, title: title.trim(), start_time, end_time, attendee_ids, external_attendees }),
})
: await fetch('/api/spaces/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ space_id: selectedSpaceId, title: title.trim(), start_time, end_time, attendee_ids }),
body: JSON.stringify({ space_id: selectedSpaceId, title: title.trim(), start_time, end_time, attendee_ids, external_attendees }),
})
}

Expand Down Expand Up @@ -523,8 +558,15 @@ export default function SpaceBookingModal({
placeholder="Search by name or email..."
className={inputCls}
autoComplete="off"
onKeyDown={e => {
// Enter adds a typed address rather than submitting the form.
if (e.key === 'Enter' && canAddExternal) {
e.preventDefault()
addExternalAttendee()
}
}}
/>
{(searchResults.length > 0 || searchLoading) && (
{(searchResults.length > 0 || searchLoading || canAddExternal) && (
<div className="absolute z-10 mt-1 w-full bg-[#0f2a4a] border border-[#1e5080] rounded-lg shadow-xl overflow-hidden">
{searchLoading && (
<div className="px-3 py-2 text-sm text-[#93b8d8]">Searching…</div>
Expand All @@ -540,12 +582,26 @@ export default function SpaceBookingModal({
<div className="text-xs text-[#93b8d8]">{u.email}</div>
</button>
))}
{canAddExternal && (
<button
type="button"
onClick={addExternalAttendee}
className="w-full text-left px-3 py-2 hover:bg-white/10 transition-colors border-t border-[#1e5080] first:border-t-0"
>
<div className="text-sm text-[#f0f6ff] font-medium">Add {typedEmail}</div>
<div className="text-xs text-[#93b8d8]">No Chambers account — they&apos;ll get the invite by email</div>
</button>
)}
</div>
)}
</div>

<p className="text-xs text-[#6a96bb] mt-1">
Not on Chambers? Type their @northeastern.edu email.
</p>

{/* Attendee chips */}
{attendees.length > 0 && (
{(attendees.length > 0 || externalAttendees.length > 0) && (
<div className="flex flex-wrap gap-2 mt-2">
{attendees.map(a => (
<div
Expand All @@ -564,6 +620,25 @@ export default function SpaceBookingModal({
</button>
</div>
))}
{externalAttendees.map(email => (
<div
key={email}
className="flex items-center gap-1.5 bg-[#0f2a4a] border border-dashed border-[#1e5080] rounded-full pl-3 pr-2 py-1"
title="No Chambers account"
>
<span className="text-xs text-[#f0f6ff]">{email}</span>
<button
type="button"
onClick={() => removeExternalAttendee(email)}
aria-label={`Remove ${email}`}
className="text-[#93b8d8] hover:text-[#c8102e] transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
))}
</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions app/(dashboard)/sga-spaces/space-calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface Booking {
start_time: string
end_time: string
attendee_ids: string[]
external_attendees?: string[] | null
creator_name: string | null
series_id: string | null
}
Expand Down
14 changes: 12 additions & 2 deletions app/api/display/[spaceId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const adminSupabase = createAdminClient(
process.env.SUPABASE_SERVICE_ROLE_KEY!
)

function guestLabel(count: number): string[] {
if (count === 0) return []
return [count === 1 ? 'Guest' : `${count} guests`]
}

export async function GET(
request: Request,
{ params }: { params: Promise<{ spaceId: string }> }
Expand Down Expand Up @@ -44,7 +49,7 @@ export async function GET(
.single(),
adminSupabase
.from('space_bookings')
.select('id, title, start_time, end_time, creator_id, attendee_ids')
.select('id, title, start_time, end_time, creator_id, attendee_ids, external_attendees')
.eq('space_id', spaceId)
.gte('start_time', todayStart.toISOString())
.lt('start_time', todayEnd.toISOString())
Expand Down Expand Up @@ -93,7 +98,12 @@ export async function GET(
start_time: b.start_time,
end_time: b.end_time,
creator_name: userMap[b.creator_id] ?? null,
attendee_names: (b.attendee_ids ?? []).map((id: string) => userMap[id]).filter(Boolean),
// External attendees (issue #132) are counted, not named: this screen hangs
// outside the room, and their addresses are not for passers-by.
attendee_names: [
...(b.attendee_ids ?? []).map((id: string) => userMap[id]).filter(Boolean),
...guestLabel((b.external_attendees ?? []).length),
],
})),
})
}
12 changes: 6 additions & 6 deletions app/api/spaces/blackouts/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email'
import { attendeeKeys, cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email'
import { waitUntil } from '@vercel/functions'

const adminSupabase = createAdminClient(
Expand All @@ -14,7 +14,7 @@ const adminSupabase = createAdminClient(
async function cascadeCancelBookings(spaceId: string | null, startTime: string, endTime: string) {
let q = adminSupabase
.from('space_bookings')
.select('id, title, start_time, end_time, creator_id, attendee_ids, spaces(name)')
.select('id, title, start_time, end_time, creator_id, attendee_ids, external_attendees, spaces(name)')
.lt('start_time', endTime)
.gt('end_time', startTime)
if (spaceId) q = q.eq('space_id', spaceId)
Expand All @@ -25,17 +25,17 @@ async function cascadeCancelBookings(spaceId: string | null, startTime: string,
// Wherever each affected person chose to receive SGA Spaces emails (issue #109).
const addresses = await resolveSpacesAddresses(
adminSupabase,
affected.flatMap((b: { creator_id: string; attendee_ids: string[] }) =>
[b.creator_id, ...(b.attendee_ids ?? [])]
affected.flatMap((b: { creator_id: string; attendee_ids: string[]; external_attendees: string[] | null }) =>
[b.creator_id, ...attendeeKeys(b)]
)
)

await adminSupabase.from('space_bookings').delete().in('id', affected.map((b: { id: string }) => b.id))

// Bookings are already deleted; notifying is a post-commit side effect.
waitUntil(
Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => {
const { to, bcc } = cancellationAddressing(addresses, b.creator_id, b.attendee_ids)
Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; external_attendees: string[] | null; spaces: { name: string }[] | null }) => {
const { to, bcc } = cancellationAddressing(addresses, b.creator_id, attendeeKeys(b))
await sendSpaceBookingCancelledEmail({
bookingId: b.id,
title: b.title,
Expand Down
12 changes: 6 additions & 6 deletions app/api/spaces/blackouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NextResponse } from 'next/server'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email'
import { attendeeKeys, cancellationAddressing, resolveSpacesAddresses } from '@/lib/spaces-email'
import { waitUntil } from '@vercel/functions'

const adminSupabase = createAdminClient(
Expand Down Expand Up @@ -71,7 +71,7 @@ export async function POST(request: Request) {
try {
let bookingsQuery = adminSupabase
.from('space_bookings')
.select('id, title, start_time, end_time, creator_id, attendee_ids, spaces(name)')
.select('id, title, start_time, end_time, creator_id, attendee_ids, external_attendees, spaces(name)')
.lt('start_time', end_time)
.gt('end_time', start_time)

Expand All @@ -85,8 +85,8 @@ export async function POST(request: Request) {
// to receive SGA Spaces emails (issue #109).
const addresses = await resolveSpacesAddresses(
adminSupabase,
affected.flatMap((b: { creator_id: string; attendee_ids: string[] }) =>
[b.creator_id, ...(b.attendee_ids ?? [])]
affected.flatMap((b: { creator_id: string; attendee_ids: string[]; external_attendees: string[] | null }) =>
[b.creator_id, ...attendeeKeys(b)]
)
)

Expand All @@ -100,8 +100,8 @@ export async function POST(request: Request) {
// post-commit side effect. Previously the admin's request blocked on one
// Resend call per affected booking, which could run into seconds.
waitUntil(
Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; spaces: { name: string }[] | null }) => {
const { to, bcc } = cancellationAddressing(addresses, b.creator_id, b.attendee_ids)
Promise.all(affected.map(async (b: { id: string; title: string; start_time: string; end_time: string; creator_id: string; attendee_ids: string[]; external_attendees: string[] | null; spaces: { name: string }[] | null }) => {
const { to, bcc } = cancellationAddressing(addresses, b.creator_id, attendeeKeys(b))
await sendSpaceBookingCancelledEmail({
bookingId: b.id,
title: b.title,
Expand Down
Loading
Loading