diff --git a/app/(dashboard)/sga-spaces/space-booking-modal.tsx b/app/(dashboard)/sga-spaces/space-booking-modal.tsx index 46c092d..4af54b3 100644 --- a/app/(dashboard)/sga-spaces/space-booking-modal.tsx +++ b/app/(dashboard)/sga-spaces/space-booking-modal.tsx @@ -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) @@ -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) { @@ -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 (
@@ -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 && (
diff --git a/app/api/spaces/series/[id]/route.ts b/app/api/spaces/series/[id]/route.ts index 971b716..17ead0a 100644 --- a/app/api/spaces/series/[id]/route.ts +++ b/app/api/spaces/series/[id]/route.ts @@ -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( @@ -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 }) @@ -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) { @@ -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)) @@ -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 @@ -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') @@ -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, @@ -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 }) @@ -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). @@ -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'