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
21 changes: 12 additions & 9 deletions app/(dashboard)/sga-spaces/space-booking-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export default function SpaceBookingModal({
}

setSeries(info)
setSelectedSpaceId(info.space_id)
setTitle(info.title)
setStartTime(info.start_time)
setEndTime(info.end_time)
Expand Down Expand Up @@ -328,8 +329,8 @@ export default function SpaceBookingModal({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(), start_time: startTime, end_time: endTime, until, attendee_ids,
external_attendees, skip_conflicts: skipConflicts,
space_id: selectedSpaceId, title: title.trim(), start_time: startTime, end_time: endTime, until,
attendee_ids, external_attendees, skip_conflicts: skipConflicts,
}),
})
} else if (creatingSeries) {
Expand Down Expand Up @@ -384,6 +385,7 @@ export default function SpaceBookingModal({
const seriesMaxDate = editingSeries ? series?.semester_end_date ?? undefined : semesterEndDate ?? undefined
const cancelLabel = editingSeries ? 'Cancel all upcoming weeks' : seriesId ? 'Cancel this week' : 'Cancel this booking'
const canCancel = editingSeries ? !!onCancelSeries : !!onCancelBooking
const busyIds = editingSeries ? undefined : busySpaceIds

return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={onClose}>
Expand Down Expand Up @@ -434,12 +436,13 @@ export default function SpaceBookingModal({
)}

{/*
Location selector. Offered when editing one booking -- or one week of
a series -- as well as when creating, so a meeting can follow a room
change instead of being cancelled and rebooked. Not for a series edit,
which keeps the series' space.
Location selector. Offered when creating and when editing -- one
booking, one week of a series, or the whole series (issue #127) -- so
a meeting can follow a room change instead of being cancelled and
rebooked. The busy markers describe the one time picked, so they are
left off a series, whose weeks are checked on the server.
*/}
{!editingSeries && spaces && spaces.length > 1 && (
{(!editingSeries || series) && spaces && spaces.length > 1 && (
<div>
<label className={labelCls}>Location</label>
<select
Expand All @@ -448,10 +451,10 @@ export default function SpaceBookingModal({
className={inputCls}
>
{[...spaces]
.sort((a, b) => Number(!!busySpaceIds?.includes(a.id)) - Number(!!busySpaceIds?.includes(b.id)))
.sort((a, b) => Number(!!busyIds?.includes(a.id)) - Number(!!busyIds?.includes(b.id)))
.map(s => (
<option key={s.id} value={s.id}>
{s.name} (cap. {s.capacity}){busySpaceIds?.includes(s.id) ? ' — booked at the time you picked' : ''}
{s.name} (cap. {s.capacity}){busyIds?.includes(s.id) ? ' — booked at the time you picked' : ''}
</option>
))}
</select>
Expand Down
36 changes: 23 additions & 13 deletions app/api/spaces/series/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import { loadActiveSemesterEnd, loadPlanContext } from '@/lib/space-series-data'
*
* A series edit overwrites each upcoming week with the series' values, including
* weeks that had been edited on their own. That is the rule chosen for #112: the
* series is what you see.
* series is what you see. That includes the space (issue #127): moving a series
* moves every upcoming week that can take the move, so a meeting can follow a
* room change without being cancelled and rebooked.
*/

const adminSupabase = createAdminClient(
Expand Down Expand Up @@ -162,7 +164,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json({ error: 'This weekly booking has been cancelled.' }, { status: 400 })
}

const { title, start_time, end_time, until, attendee_ids, external_attendees, skip_conflicts } = await request.json()
const { title, start_time, end_time, until, attendee_ids, external_attendees, skip_conflicts, space_id } = await request.json()

if (typeof title !== 'string' || !title.trim()) {
return NextResponse.json({ error: 'Title is required.' }, { status: 400 })
Expand All @@ -174,6 +176,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const externals = parseExternalAttendees(external_attendees)
if (!externals) return NextResponse.json({ error: EXTERNAL_ATTENDEES_ERROR }, { status: 400 })

// Omitted means the series stays where it is.
const spaceId: string = typeof space_id === 'string' && space_id ? space_id : series.space_id
if (spaceId !== series.space_id) {
const { data: space } = await adminSupabase.from('spaces').select('id').eq('id', spaceId).maybeSingle()
if (!space) return NextResponse.json({ error: 'That space does not exist.' }, { status: 400 })
}

// The time pattern is validated once on the first date; every week shares it.
const sample = intervalFor(series.starts_on, start_time, end_time)
if (new Date(sample.start).getUTCMinutes() % 15 !== 0 || new Date(sample.end).getUTCMinutes() % 15 !== 0) {
Expand Down Expand Up @@ -227,12 +236,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

const weeks = [...moving, ...adding]
const ctx = await loadPlanContext(adminSupabase, {
spaceId: series.space_id,
spaceId,
creatorId: series.creator_id,
from: weeks[0].interval.start,
to: weeks.reduce((max, w) => (w.interval.end > max ? w.interval.end : max), weeks[0].interval.end),
})
const { ok, conflicts } = planSeries({ ...ctx, spaceId: series.space_id, weeks })
const { ok, conflicts } = planSeries({ ...ctx, spaceId, weeks })

const movingDates = new Set(moving.map(w => w.date))
const unchanged: SeriesConflict[] = conflicts.filter(c => movingDates.has(c.date))
Expand All @@ -250,9 +259,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const okByDate = new Map(ok.map(w => [w.date, w]))

// Every kept week takes the title and attendees. Only the weeks the plan
// accepted take the new time -- and the series' space, since a week moved to
// another space on its own was checked here as moving back. The rest keep
// their time and space, as the email says.
// accepted take the new time and space -- a week sitting in any other space,
// whether the series is moving or the week was moved on its own, was checked
// as claiming all of its time here. The rest keep their time and space, as
// the email says.
const updates = await Promise.all(kept.map(r => {
const planned = okByDate.get(r.start_time.slice(0, 10))
return adminSupabase
Expand All @@ -261,7 +271,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
title: cleanTitle,
attendee_ids: attendees,
external_attendees: externals,
...(planned ? { start_time: planned.interval.start, end_time: planned.interval.end, space_id: series.space_id } : {}),
...(planned ? { start_time: planned.interval.start, end_time: planned.interval.end, space_id: spaceId } : {}),
})
.eq('id', r.id)
.select('id, start_time, end_time, space_id')
Expand All @@ -276,7 +286,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const { data, error } = await adminSupabase
.from('space_bookings')
.insert(toInsert.map(w => ({
space_id: series.space_id,
space_id: spaceId,
creator_id: series.creator_id,
title: cleanTitle,
start_time: w.interval.start,
Expand All @@ -297,7 +307,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

const { error: seriesError } = await adminSupabase
.from('space_booking_series')
.update({ title: cleanTitle, attendee_ids: attendees, external_attendees: externals, start_time, end_time, ends_on: until })
.update({ space_id: spaceId, title: cleanTitle, attendee_ids: attendees, external_attendees: externals, start_time, end_time, ends_on: until })
.eq('id', id)
if (seriesError) return NextResponse.json({ error: seriesError.message }, { status: 500 })

Expand All @@ -312,8 +322,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const withSpace = (r: { id: string; start_time: string; end_time: string; space_id: string }) =>
({ ...toWeek(r), spaceId: r.space_id })
const [finalWeeks, removedWeeks] = await Promise.all([
nameOtherSpaces(finalRows.map(withSpace), series.space_id),
nameOtherSpaces(removed.map(withSpace), series.space_id),
nameOtherSpaces(finalRows.map(withSpace), spaceId),
nameOtherSpaces(removed.map(withSpace), spaceId),
])

// Chambers users and external addresses alike, as keys (see attendeeKeys).
Expand All @@ -326,7 +336,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id

const currentIds = [series.creator_id, ...currentAttendees]
const [{ data: space }, addresses] = await Promise.all([
adminSupabase.from('spaces').select('name').eq('id', series.space_id).single(),
adminSupabase.from('spaces').select('name').eq('id', spaceId).single(),
resolveSpacesAddresses(adminSupabase, [...currentIds, ...droppedAttendees]),
])
const spaceName = space?.name ?? 'SGA Space'
Expand Down
Loading