diff --git a/app/(dashboard)/administrator/bookings-tab.tsx b/app/(dashboard)/administrator/bookings-tab.tsx index df9ad07..70ba92d 100644 --- a/app/(dashboard)/administrator/bookings-tab.tsx +++ b/app/(dashboard)/administrator/bookings-tab.tsx @@ -77,6 +77,12 @@ interface WeeklyBooking extends BookingBase { status: string | null reservation_code: string | null senate_type: string | null + /** Overrides the booking's purpose for this date; null inherits (issue #55). */ + purpose: string | null + /** Overrides the booking's hidden flag; null inherits (issue #55). */ + hidden: boolean | null + /** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */ + is_event: boolean }[] }[] | null } diff --git a/app/(dashboard)/administrator/edit-weekly-form.tsx b/app/(dashboard)/administrator/edit-weekly-form.tsx index 965d756..2679efa 100644 --- a/app/(dashboard)/administrator/edit-weekly-form.tsx +++ b/app/(dashboard)/administrator/edit-weekly-form.tsx @@ -35,6 +35,12 @@ interface Occurrence { status: string | null reservation_code: string | null senate_type: string | null + /** Overrides bookings.purpose for this date; null inherits (issue #55). */ + purpose: string | null + /** Overrides bookings.hidden; null inherits, false forces visible (issue #55). */ + hidden: boolean | null + /** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */ + is_event: boolean } interface EditWeeklyFormProps { @@ -42,6 +48,8 @@ interface EditWeeklyFormProps { id: string body_id: string purpose: string + /** Only read to label what "Default" means on each occurrence's overrides. */ + hidden: boolean scope: BookingScope division: Division | null booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null @@ -133,10 +141,14 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on // Still keyed on the owning body, which stays populated for every scope. const isSenate = bodies.find(b => b.id === scopeValue.body_id)?.name === 'Senate' + // What an occurrence inherits when its visibility override is left on Default. + const bookingHidden = booking.hidden if (!w) return null - const updateOccurrence = (id: string, field: keyof Occurrence, value: string | null) => { + // `boolean` in the value union for the visibility override, which is the only + // non-string field here. + const updateOccurrence = (id: string, field: keyof Occurrence, value: string | boolean | null) => { setOccurrences(prev => prev.map(o => o.id === id ? { ...o, [field]: value } : o)) } @@ -149,6 +161,11 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on status: null, reservation_code: null, senate_type: null, + purpose: null, + hidden: null, + // is_event is deliberately not reset: it is not an override, it is a + // statement that this date is an event, and clearing the room and time + // overrides does not stop it being one. } : o)) } @@ -248,7 +265,9 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on

Occurrences

{getWeeklyDates(form.start_date, form.end_date).map(date => { const occ = occurrences.find(o => o.occurrence_date === date) - const hasOverride = occ && (occ.room_name || occ.start_time || occ.end_time || occ.status || occ.reservation_code) + // `hidden != null` because false is an override, not an absence. + const hasOverride = occ && (occ.room_name || occ.start_time || occ.end_time || occ.status + || occ.reservation_code || occ.purpose || occ.hidden != null) const isExpanded = expandedOcc === date return ( @@ -323,6 +342,55 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on /> +
+ + updateOccurrence(occ.id, 'purpose', e.target.value || null)} + className={inputCls} + /> +
+ +
+ + {/* A select rather than a checkbox because there are three + states, not two: inherit, forced visible, forced hidden. + Forced-visible is what lets a single week of a hidden + series be published, and a checkbox cannot express the + difference between that and inheriting a visible parent. */} + +
+ + + {isSenate && (
diff --git a/app/(dashboard)/administrator/weekly-booking-grid.tsx b/app/(dashboard)/administrator/weekly-booking-grid.tsx index 4c280fb..f689a18 100644 --- a/app/(dashboard)/administrator/weekly-booking-grid.tsx +++ b/app/(dashboard)/administrator/weekly-booking-grid.tsx @@ -12,6 +12,12 @@ interface WeeklyOccurrence { status: string | null reservation_code: string | null senate_type: string | null + /** Overrides the booking's purpose for this date; null inherits (issue #55). */ + purpose: string | null + /** Overrides the booking's hidden flag; null inherits (issue #55). */ + hidden: boolean | null + /** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */ + is_event: boolean } interface WeeklyBooking { diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx index 750f967..9b6f699 100644 --- a/app/(dashboard)/events/page.tsx +++ b/app/(dashboard)/events/page.tsx @@ -6,7 +6,17 @@ import { Skeleton } from '@/app/_components/skeleton' import { usePendingActionsWatch } from '../pending-actions-watch' interface EventBooking { + /** + * A booking event uses the booking's id. A weekly occurrence event uses + * `:`, because every flagged week of one series would + * otherwise share an id -- and this keys both the checklist state and the + * pending-actions highlighting. + */ id: string + /** The real booking id, set only on occurrence rows where `id` is synthetic. */ + booking_id?: string + /** Set when this row is a single flagged week rather than a whole booking. */ + occurrence_date: string | null purpose: string type: string created_at: string @@ -111,7 +121,14 @@ function BookingDetails({ booking }: { booking: EventBooking }) { {sessions.map((w, i) => (
1 ? 'border-t border-[#1e5080] pt-1 first:border-0 first:pt-0' : ''}> {w.room_name &&

Room: {w.room_name}

} -

Dates: {formatDate(w.start_date)} – {formatDate(w.end_date)}

+ {/* An occurrence event is one week, so a "Dates: Sep 1 – Sep 1" range + would be noise. The row carries occurrence_date precisely so this + can say Date instead. */} + {booking.occurrence_date ? ( +

Date: {formatDate(booking.occurrence_date)}

+ ) : ( +

Dates: {formatDate(w.start_date)} – {formatDate(w.end_date)}

+ )}

Time: {formatTime(w.start_time)} – {formatTime(w.end_time)}

))} @@ -167,23 +184,32 @@ export default function EventsPage() { fetchEvents() }, []) - const updateStep = async (bookingId: string, step: 'event_management_form' | 'engage_form', checked: boolean) => { + // Takes the row rather than an id: the checklist is keyed by the row's id + // (synthetic for an occurrence event) while the save has to address the real + // booking plus, for an occurrence, its date. + const updateStep = async (row: EventBooking, step: 'event_management_form' | 'engage_form', checked: boolean) => { + const key = row.id // Optimistic update, rolled back below if the save fails. setChecklist(prev => ({ ...prev, - [bookingId]: { ...prev[bookingId], [step]: checked }, + [key]: { ...prev[key], [step]: checked }, })) const res = await fetch('/api/events/checklist', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ booking_id: bookingId, step, checked }), + body: JSON.stringify({ + booking_id: row.booking_id ?? row.id, + occurrence_date: row.occurrence_date, + step, + checked, + }), }) if (!res.ok) { setChecklist(prev => ({ ...prev, - [bookingId]: { ...prev[bookingId], [step]: !checked }, + [key]: { ...prev[key], [step]: !checked }, })) } } @@ -261,14 +287,14 @@ export default function EventsPage() { checked={steps.event_management_form} dueDate={b.event_management_form_due} danger={isActionDanger(`event-form:${b.id}:mgmt`)} - onChange={checked => updateStep(b.id, 'event_management_form', checked)} + onChange={checked => updateStep(b, 'event_management_form', checked)} /> updateStep(b.id, 'engage_form', checked)} + onChange={checked => updateStep(b, 'engage_form', checked)} />
diff --git a/app/(dashboard)/my-rooms/shared.ts b/app/(dashboard)/my-rooms/shared.ts index 13b11db..dead400 100644 --- a/app/(dashboard)/my-rooms/shared.ts +++ b/app/(dashboard)/my-rooms/shared.ts @@ -255,7 +255,11 @@ export function flattenMyRooms(data: MyRoomsResponse, today: string): FlatBookin bookingId: b.id, type: 'Weekly Room', bodyName: b.bodies?.name || '', - purpose: b.purpose, + // `??` rather than the `||` used by the fields below: an occurrence may + // deliberately override the series purpose, and only null means inherit. + // Empty strings are normalised to null when written (see the weekly PATCH + // handler), so they cannot reach here and read as an intentional blank. + purpose: occ.purpose ?? b.purpose, location: occ.room_name || w.room_name, date: occ.occurrence_date, startTime: occ.start_time || w.start_time, diff --git a/app/api/administrator/bookings/route.ts b/app/api/administrator/bookings/route.ts index a13aee5..ceca428 100644 --- a/app/api/administrator/bookings/route.ts +++ b/app/api/administrator/bookings/route.ts @@ -56,7 +56,7 @@ export async function GET(request: Request) { booking_bodies(body_id, bodies(name)), creator_role, weekly_room_bookings(id, room_name, start_date, end_date, start_time, end_time, status, reservation_code, - weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type) + weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type, purpose, hidden, is_event) ) `) .eq('type', 'Weekly Room') diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 8627adf..7b5d6d8 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/route.ts @@ -6,6 +6,7 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { waitUntil } from '@vercel/functions' + import { loadScopeContext, validateScopeSelection, @@ -14,6 +15,27 @@ import { type ScopedRow, } from '@/lib/booking-scope' +/** + * One occurrence as the editor submits it (issue #55). + * + * Everything but the date and is_event is an override -- of the parent series, + * or of the booking above it for purpose and hidden -- where null means inherit. + * is_event is not an override: a weekly event is marked on the week it happens, + * so the occurrence is authoritative and has nothing to inherit from. + */ +interface OccurrenceInput { + occurrence_date: string + room_name: string | null + start_time: string | null + end_time: string | null + status: string | null + reservation_code: string | null + senate_type: string | null + purpose: string | null + hidden: boolean | null + is_event: boolean +} + const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! @@ -163,7 +185,7 @@ export async function PATCH(request: Request) { const dates = getWeeklyDates(start_date, end_date) const newOccurrences = dates.map(date => { - const existing = occurrences.find((o: { occurrence_date: string; room_name: string | null; start_time: string | null; end_time: string | null; status: string | null; reservation_code: string | null; senate_type: string | null }) => o.occurrence_date === date) + const existing = occurrences.find((o: OccurrenceInput) => o.occurrence_date === date) return { weekly_booking_id: weekly_id, occurrence_date: date, @@ -173,6 +195,18 @@ export async function PATCH(request: Request) { status: existing?.status || null, reservation_code: existing?.reservation_code || null, senate_type: existing?.senate_type ?? null, + // Issue #55. Both inherit from the booking when null. + // + // purpose is trimmed, and an empty string collapses to null -- clearing the + // field in the editor means "inherit", not "this week has a blank purpose". + purpose: existing?.purpose?.trim() || null, + // `?? null`, not `|| null`: false is meaningful here. It forces an + // occurrence visible even when its series is hidden, and `||` would + // silently turn that back into inherit. + hidden: existing?.hidden ?? null, + // Not an override: the occurrence is where a weekly event is marked, so an + // absent value is simply "not an event" rather than "inherit". + is_event: existing?.is_event ?? false, } }) @@ -206,8 +240,11 @@ export async function PATCH(request: Request) { const recipients = await resolveBookingRecipients(adminSupabase, scopedRow) if (recipients.length && auditLog) { + // `hidden != null` rather than a truthiness test: an occurrence forced + // visible (false) has been changed just as much as one forced hidden. const changedOcc = newOccurrences.find( o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code + || o.purpose || o.hidden != null ) ?? newOccurrences[0] await adminSupabase.from('user_alerts').insert( recipients.map(r => ({ diff --git a/app/api/events/checklist/route.ts b/app/api/events/checklist/route.ts index ea0686c..246e62d 100644 --- a/app/api/events/checklist/route.ts +++ b/app/api/events/checklist/route.ts @@ -20,7 +20,7 @@ export async function PATCH(request: Request) { const rateLimitRes = await checkRateLimit(user.id) if (rateLimitRes) return rateLimitRes - const { booking_id, step, checked } = await request.json() + const { booking_id, occurrence_date, step, checked } = await request.json() if (!booking_id || !step || typeof checked !== 'boolean') { return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) @@ -30,11 +30,26 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'Invalid step' }, { status: 400 }) } + // Weekly events are marked per occurrence (issue #55), so one booking can own + // several checklists. null addresses the booking's own, which is what one-time + // and tabling events use and what every pre-existing row is. + if (occurrence_date != null && !/^\d{4}-\d{2}-\d{2}$/.test(occurrence_date)) { + return NextResponse.json({ error: 'Invalid occurrence date' }, { status: 400 }) + } + const { error } = await adminSupabase .from('event_tracking') .upsert( - { booking_id, [step]: checked, updated_at: new Date().toISOString() }, - { onConflict: 'booking_id' } + { + booking_id, + occurrence_date: occurrence_date ?? null, + [step]: checked, + updated_at: new Date().toISOString(), + }, + // Targets the UNIQUE NULLS NOT DISTINCT constraint the migration adds. A + // plain unique index would treat every NULL as distinct and insert a fresh + // booking-level row on each toggle instead of updating the existing one. + { onConflict: 'booking_id,occurrence_date' } ) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) diff --git a/app/api/events/route.ts b/app/api/events/route.ts index b47cfa6..cd0f7d8 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -19,6 +19,37 @@ const adminSupabase = createAdminClient( process.env.SUPABASE_SERVICE_ROLE_KEY! ) +/** One event_tracking row as embedded above. */ +interface TrackingRow { + event_management_form: boolean + engage_form: boolean + occurrence_date: string | null +} + +/** A flagged weekly occurrence with the series and booking it belongs to. */ +interface EventOccurrenceRow { + occurrence_date: string + room_name: string | null + start_time: string | null + end_time: string | null + purpose: string | null + weekly_room_bookings: { + room_name: string + start_time: string + end_time: string + bookings: { + id: string + purpose: string + type: string + created_at: string + semester_id: string | null + bodies: { name: string } | null + users: { full_name: string } | null + event_tracking: TrackingRow[] | null + } | null + } | null +} + export async function GET() { const supabase = await createClient() @@ -38,41 +69,126 @@ export async function GET() { if (!activeSemester) return NextResponse.json({ bookings: [] }) - const [{ data: bookings, error }, { data: settingsRow }] = await Promise.all([ - adminSupabase - .from('bookings') - .select(` - id, purpose, type, created_at, - bodies(name), - users!bookings_created_by_fkey(full_name), - one_time_room_bookings(room_name, booking_date, start_time, end_time), - weekly_room_bookings(room_name, start_date, end_date, start_time, end_time, weekly_room_occurrences(occurrence_date)), - tabling_bookings( - tabling_sessions(location, session_date, start_time, end_time) - ), - event_tracking(event_management_form, engage_form) - `) - .eq('is_event', true) - .eq('semester_id', activeSemester.id), - supabase.from('app_settings').select('*').eq('id', 1).maybeSingle(), - ]) + const [{ data: bookings, error }, { data: eventOccurrences, error: occError }, { data: settingsRow }] = + await Promise.all([ + adminSupabase + .from('bookings') + .select(` + id, purpose, type, created_at, + bodies(name), + users!bookings_created_by_fkey(full_name), + one_time_room_bookings(room_name, booking_date, start_time, end_time), + weekly_room_bookings(room_name, start_date, end_date, start_time, end_time, weekly_room_occurrences(occurrence_date)), + tabling_bookings( + tabling_sessions(location, session_date, start_time, end_time) + ), + event_tracking(event_management_form, engage_form, occurrence_date) + `) + .eq('is_event', true) + .eq('semester_id', activeSemester.id), + + // Weekly events are marked on the occurrence and never on the booking, so + // they are unreachable from the query above: filtering an embedded resource + // narrows the child array, it does not select the parent. Fetched + // separately and folded in below. `!inner` drops occurrences whose ancestry + // is missing rather than emitting a row with no booking behind it. + adminSupabase + .from('weekly_room_occurrences') + .select(` + occurrence_date, room_name, start_time, end_time, purpose, + weekly_room_bookings!inner( + room_name, start_time, end_time, + bookings!inner( + id, purpose, type, created_at, semester_id, + bodies(name), + users!bookings_created_by_fkey(full_name), + event_tracking(event_management_form, engage_form, occurrence_date) + ) + ) + `) + .eq('is_event', true), + + supabase.from('app_settings').select('*').eq('id', 1).maybeSingle(), + ]) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (occError) return NextResponse.json({ error: occError.message }, { status: 500 }) // Each form's due date is the near edge of its Danger Range (issue #45) -- // the same settings the "danger" flash (dangerStart) already reads, just the // other end of the pair. const s = settingsFromRow(settingsRow as SettingsRow | null) - const withDueDates = (bookings || []).map(b => { - const eventDate = minDate(sessionDatesOf(b)) - return { - ...b, - event_date: eventDate, - event_management_form_due: eventDate ? subtractDays(eventDate, s.eventMgmt[1]) : null, - engage_form_due: eventDate ? subtractDays(eventDate, s.eventEngage[1]) : null, - } + + /** + * event_tracking is now one-to-many -- a booking can have a checklist of its + * own plus one per event occurrence -- so the embed returns an array where it + * used to return a single row. Pick the entry for this target: the one whose + * occurrence_date matches, or the booking-level row (null) for a booking event. + */ + const trackingFor = (rows: TrackingRow[] | null | undefined, date: string | null) => + (rows ?? []).find(t => (t.occurrence_date ?? null) === date) ?? null + + /** Adds the two derived due dates, which depend only on when the event is. */ + const withDates = (row: T, eventDate: string | null) => ({ + ...row, + event_date: eventDate, + event_management_form_due: eventDate ? subtractDays(eventDate, s.eventMgmt[1]) : null, + engage_form_due: eventDate ? subtractDays(eventDate, s.eventEngage[1]) : null, }) + const bookingEvents = (bookings || []).map(b => + withDates( + { ...b, occurrence_date: null, event_tracking: trackingFor(b.event_tracking as TrackingRow[], null) }, + minDate(sessionDatesOf(b)) + ) + ) + + /** + * One row per flagged occurrence: the occurrence is the event, so it is listed + * in its own right rather than nested under its series. + * + * The id is `:` because the checklist and the pending-actions + * highlighting are both keyed by row id, and every flagged week of the same + * series would otherwise collide on the booking's id. Its shape mirrors a + * booking closely enough for the page to render it unchanged, with + * occurrence_date set so the detail block knows to show one date rather than a + * range. + */ + const occurrenceEvents = (eventOccurrences || []) + .map(o => o as unknown as EventOccurrenceRow) + .map(o => { + const weekly = o.weekly_room_bookings + const booking = weekly?.bookings + if (!booking || booking.semester_id !== activeSemester.id) return null + return withDates( + { + id: `${booking.id}:${o.occurrence_date}`, + booking_id: booking.id, + occurrence_date: o.occurrence_date, + // The occurrence's purpose override wins, per issue #55. + purpose: o.purpose ?? booking.purpose, + type: booking.type, + created_at: booking.created_at, + bodies: booking.bodies, + users: booking.users, + one_time_room_bookings: null, + tabling_bookings: null, + weekly_room_bookings: [{ + room_name: o.room_name ?? weekly.room_name, + start_date: o.occurrence_date, + end_date: o.occurrence_date, + start_time: o.start_time ?? weekly.start_time, + end_time: o.end_time ?? weekly.end_time, + }], + event_tracking: trackingFor(booking.event_tracking, o.occurrence_date), + }, + o.occurrence_date + ) + }) + .filter((r): r is NonNullable => r !== null) + + const withDueDates = [...bookingEvents, ...occurrenceEvents] + // Issue #48: ordered by the event's own date -- the earliest session date // across any of its child bookings -- not by when the tracking row was // created. A booking with no session date at all (shouldn't happen for a diff --git a/lib/my-rooms-data.ts b/lib/my-rooms-data.ts index 263f11d..7ff1432 100644 --- a/lib/my-rooms-data.ts +++ b/lib/my-rooms-data.ts @@ -27,6 +27,19 @@ interface BookingRow { booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null } +/** + * The weekly shape, narrowed enough to reason about per-occurrence visibility. + * + * Only the fields the hidden filter touches are declared; the rest of the row is + * passed through untouched, which is why this widens BookingRow rather than + * replacing it. + */ +interface WeeklyBookingRow extends BookingRow { + weekly_room_bookings?: { + weekly_room_occurrences?: { hidden: boolean | null }[] | null + }[] | null +} + export interface MyRoomsPayload { oneTimeBookings: unknown[] weeklyBookings: unknown[] @@ -122,7 +135,7 @@ export async function fetchMyRooms( .select(` ${SELECT_BASE}, weekly_room_bookings(id, room_name, start_time, end_time, status, reservation_code, - weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type) + weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type, purpose, hidden, is_event) ) `) .eq('type', 'Weekly Room') @@ -173,6 +186,34 @@ export async function fetchMyRooms( */ const memberCtx = { ...ctx, isAdmin: false } + /** + * Drops occurrences the caller should not see (issue #55). + * + * A weekly occurrence can now override its booking's `hidden`, so visibility is + * no longer a property of the booking alone: a visible series can hide one + * week, and a hidden series can expose one. `occ.hidden ?? booking.hidden` is + * the precedence -- NULL inherits. + * + * This runs here, server-side, and not in the client's flatten step. Filtering + * on the client would mean sending a hidden occurrence to a browser that is not + * allowed to see it and trusting the UI not to draw it, which is the shape of + * the leak that issue #29 already had to be fixed once. + */ + const stripHiddenOccurrences = (b: BookingRow & { canManage: boolean }) => { + if (b.canManage) return b + const weekly = (b as WeeklyBookingRow).weekly_room_bookings + if (!weekly) return b + return { + ...b, + weekly_room_bookings: weekly.map(w => ({ + ...w, + weekly_room_occurrences: (w.weekly_room_occurrences ?? []).filter( + occ => !(occ.hidden ?? b.hidden) + ), + })), + } + } + const decorate = (rows: BookingRow[] | null) => (rows ?? []) .map(b => ({ @@ -183,7 +224,20 @@ export async function fetchMyRooms( (b.booking_bodies ?? []).map(x => x.body_id) ), })) - .filter(b => !b.hidden || b.canManage) + .map(stripHiddenOccurrences) + // Visibility is no longer decided by the booking alone. A manageable + // booking always stays. Otherwise a weekly series survives if any of its + // occurrences is visible -- which is what lets a single week of a hidden + // series be published by setting `hidden = false` on it -- and it is + // dropped once stripHiddenOccurrences has emptied it. One-time and tabling + // bookings have no per-occurrence override, so they keep the original + // booking-level rule. + .filter(b => { + if (b.canManage) return true + const weekly = (b as WeeklyBookingRow).weekly_room_bookings + if (!weekly) return !b.hidden + return weekly.some(w => (w.weekly_room_occurrences ?? []).length > 0) + }) return { oneTimeBookings: decorate(oneTimeBookings as BookingRow[] | null), diff --git a/supabase/migrations/20260829000000_occurrence_purpose_and_hidden.sql b/supabase/migrations/20260829000000_occurrence_purpose_and_hidden.sql new file mode 100644 index 0000000..b8c83d3 --- /dev/null +++ b/supabase/migrations/20260829000000_occurrence_purpose_and_hidden.sql @@ -0,0 +1,29 @@ +-- Per-occurrence overrides for purpose and hidden (issue #55). +-- +-- A weekly occurrence can already diverge from its series on room, time, status +-- and reservation code. Each of those is a nullable column on +-- weekly_room_occurrences where NULL means "inherit from the parent", and these +-- follow exactly that convention -- the difference being that they inherit from +-- the `bookings` row two levels up rather than from weekly_room_bookings. +-- +-- Nullable boolean for `hidden` is deliberate, and is why this is not +-- `default false`. The column needs three states: inherit (NULL), forced visible +-- (false) and forced hidden (true). A NOT NULL default would collapse "inherit" +-- into "visible" and make it impossible to un-hide a single occurrence of a +-- hidden series, or to reveal one later by changing the parent. +-- +-- The issue also asks for a per-occurrence `is_event`. That is not here: storing +-- it is trivial, but the Events tab finds events by querying `bookings` where +-- is_event, and teaching it about occurrence-level events changes both that +-- query and how such an event is presented in a list whose rows are bookings. +-- Left for a follow-up rather than shipping a column nothing reads. + +alter table public.weekly_room_occurrences + add column if not exists purpose text, + add column if not exists hidden boolean; + +comment on column public.weekly_room_occurrences.purpose is + 'Overrides bookings.purpose for this occurrence. NULL inherits.'; + +comment on column public.weekly_room_occurrences.hidden is + 'Overrides bookings.hidden for this occurrence. NULL inherits; false forces visible even when the booking is hidden.'; diff --git a/supabase/migrations/20260829001000_occurrence_events.sql b/supabase/migrations/20260829001000_occurrence_events.sql new file mode 100644 index 0000000..6b11e22 --- /dev/null +++ b/supabase/migrations/20260829001000_occurrence_events.sql @@ -0,0 +1,59 @@ +-- Weekly events are marked on the occurrence, not the booking (issue #55). +-- +-- Separate from 20260829000000 so that migration can be applied independently; +-- this one does more than add a column. +-- +-- `is_event` here is NOT NULL DEFAULT false, unlike the purpose/hidden overrides +-- in the previous migration, because it is not an override. A weekly series is +-- not "an event" that individual weeks opt out of -- one particular week is the +-- event. So the occurrence is authoritative and inherits nothing. +-- +-- Nothing needs backfilling: every booking currently flagged is_event is a +-- One-Time Room, and the Administrator UI has never offered the Mark Event +-- control on weekly bookings at all (only a badge). Booking-level is_event stays +-- exactly as it is for one-time and tabling bookings. + +alter table public.weekly_room_occurrences + add column if not exists is_event boolean not null default false; + +comment on column public.weekly_room_occurrences.is_event is + 'Marks this single occurrence as an event. Authoritative, not an override: weekly events are marked per occurrence, never on the parent booking.'; + +-- --------------------------------------------------------------------------- +-- event_tracking: one checklist per event, where an event may now be one week +-- --------------------------------------------------------------------------- +-- booking_id was the primary key, so a booking had exactly one checklist. With +-- two occurrences of the same series flagged, ticking a form on one would tick +-- it on the other. +-- +-- The new target is (booking_id, occurrence_date), with a NULL occurrence_date +-- meaning "the booking itself" -- which is what one-time and tabling events keep +-- using, so their existing rows stay valid untouched. +-- +-- Keyed on occurrence_date rather than an occurrence id on purpose. The weekly +-- PATCH handler regenerates its occurrences on every save -- it deletes them all +-- and reinserts, so their ids change -- and values survive only by being carried +-- across on the date. A foreign key to weekly_room_occurrences(id) would +-- therefore drop every checklist the next time anyone edited the booking. The +-- date is the stable identifier that write model actually preserves. +-- +-- UNIQUE NULLS NOT DISTINCT (Postgres 15+) is what makes the booking-level row +-- work: by default NULLs compare distinct, so a plain unique constraint would +-- happily admit several booking-level rows for the same booking and the upsert +-- would insert a new one every time instead of updating. + +alter table public.event_tracking + add column if not exists occurrence_date date; + +alter table public.event_tracking + add column if not exists id uuid not null default gen_random_uuid(); + +alter table public.event_tracking drop constraint if exists event_tracking_pkey; +alter table public.event_tracking add constraint event_tracking_pkey primary key (id); + +alter table public.event_tracking drop constraint if exists event_tracking_target_key; +alter table public.event_tracking add constraint event_tracking_target_key + unique nulls not distinct (booking_id, occurrence_date); + +comment on column public.event_tracking.occurrence_date is + 'Which weekly occurrence this checklist belongs to. NULL means the booking itself, which is how one-time and tabling events are tracked.'; diff --git a/supabase/migrations/rollback/20260829_occurrence_events_rollback.sql b/supabase/migrations/rollback/20260829_occurrence_events_rollback.sql new file mode 100644 index 0000000..a82edbb --- /dev/null +++ b/supabase/migrations/rollback/20260829_occurrence_events_rollback.sql @@ -0,0 +1,19 @@ +-- Rollback for 20260829001000_occurrence_events.sql. +-- +-- Deletes occurrence-level checklists before restoring booking_id as the primary +-- key, since several of them can share a booking_id and the key would not build +-- otherwise. Those rows are the ones whose occurrence_date is not null; the +-- booking-level rows that one-time and tabling events use are left alone. +-- +-- Weekly occurrences also stop being markable as events, so any weekly event +-- disappears from the Events tab. + +delete from public.event_tracking where occurrence_date is not null; + +alter table public.event_tracking drop constraint if exists event_tracking_target_key; +alter table public.event_tracking drop constraint if exists event_tracking_pkey; +alter table public.event_tracking drop column if exists id; +alter table public.event_tracking drop column if exists occurrence_date; +alter table public.event_tracking add constraint event_tracking_pkey primary key (booking_id); + +alter table public.weekly_room_occurrences drop column if exists is_event; diff --git a/supabase/migrations/rollback/20260829_occurrence_purpose_and_hidden_rollback.sql b/supabase/migrations/rollback/20260829_occurrence_purpose_and_hidden_rollback.sql new file mode 100644 index 0000000..d13b1b1 --- /dev/null +++ b/supabase/migrations/rollback/20260829_occurrence_purpose_and_hidden_rollback.sql @@ -0,0 +1,10 @@ +-- Rollback for 20260829000000_occurrence_purpose_and_hidden.sql. +-- +-- Drops both columns. Any per-occurrence overrides that had been set are lost, +-- and those occurrences fall back to their booking's purpose and hidden flag -- +-- which is the behaviour that existed before the migration, so nothing breaks, +-- but a hidden occurrence of a visible series becomes visible again. + +alter table public.weekly_room_occurrences + drop column if exists purpose, + drop column if exists hidden;