From 3fc9e3ccfa1f7243f5b78040b3a9bc7d31d6cd70 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 16 Sep 2026 18:03:05 -0400 Subject: [PATCH] fix: let a whole weekly SGA Space booking change space (#127) Editing the whole series now offers the Location selector. Every upcoming week that can take the move goes to the new space, checked the way a week moved on its own already was -- all of its time is newly claimed there, so overlap, blackouts and advance notice apply. Weeks that can't move keep their time and space and are listed, as with a time change, and the series itself records the new space so weeks added by extending it land there too. Co-Authored-By: Claude Opus 5 --- .../sga-spaces/space-booking-modal.tsx | 21 ++++++----- app/api/spaces/series/[id]/route.ts | 36 ++++++++++++------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/app/(dashboard)/sga-spaces/space-booking-modal.tsx b/app/(dashboard)/sga-spaces/space-booking-modal.tsx index 7610bf9..46e5439 100644 --- a/app/(dashboard)/sga-spaces/space-booking-modal.tsx +++ b/app/(dashboard)/sga-spaces/space-booking-modal.tsx @@ -233,6 +233,7 @@ export default function SpaceBookingModal({ } setSeries(info) + setSelectedSpaceId(info.space_id) setTitle(info.title) setStartTime(info.start_time) setEndTime(info.end_time) @@ -293,8 +294,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, - skip_conflicts: skipConflicts, + space_id: selectedSpaceId, title: title.trim(), start_time: startTime, end_time: endTime, until, + attendee_ids, skip_conflicts: skipConflicts, }), }) } else if (creatingSeries) { @@ -349,6 +350,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 (
@@ -399,12 +401,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 b4c3e70..6c3ae10 100644 --- a/app/api/spaces/series/[id]/route.ts +++ b/app/api/spaces/series/[id]/route.ts @@ -33,7 +33,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( @@ -153,7 +155,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, skip_conflicts } = await request.json() + const { title, start_time, end_time, until, attendee_ids, skip_conflicts, space_id } = await request.json() if (typeof title !== 'string' || !title.trim()) { return NextResponse.json({ error: 'Title is required.' }, { status: 400 }) @@ -163,6 +165,13 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id } const attendees: string[] = Array.isArray(attendee_ids) ? attendee_ids.filter((a: unknown) => typeof a === 'string') : [] + // 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) { @@ -216,12 +225,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)) @@ -239,9 +248,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 @@ -249,7 +259,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .update({ title: cleanTitle, attendee_ids: attendees, - ...(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') @@ -264,7 +274,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, @@ -284,7 +294,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, start_time, end_time, ends_on: until }) + .update({ space_id: spaceId, title: cleanTitle, attendee_ids: attendees, start_time, end_time, ends_on: until }) .eq('id', id) if (seriesError) return NextResponse.json({ error: seriesError.message }, { status: 500 }) @@ -299,8 +309,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), ]) const previousAttendees = new Set([ @@ -311,7 +321,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const currentIds = [series.creator_id, ...attendees] 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'