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
5 changes: 4 additions & 1 deletion app/(dashboard)/my-rooms/my-rooms-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import BookingDetailModal from './booking-detail-modal'
import NotificationBell from './notification-bell'
import CalendarView from './calendar-view'
import { Skeleton } from '@/app/_components/skeleton'
import { wantsSenateSession } from '@/lib/senate-types'
import {
type FlatBooking,
type MyRoomsResponse,
Expand Down Expand Up @@ -136,8 +137,10 @@ export default function MyRoomsClient({
return () => window.removeEventListener('chambers:senate-prefs-updated', fetchBookings)
}, [fetchBookings])

// The same rule the update emails and dashboard alerts now apply, so what this
// page hides and what Chambers stops sending you cannot drift apart (#92, #93).
const passesSenateFilter = (b: FlatBooking) =>
b.bodyName !== 'Senate' || !b.senateType || (senateTypePreferences[b.senateType] ?? true)
wantsSenateSession(senateTypePreferences, b.bodyName, b.senateType)

const filteredUpcoming = all.filter(b => isWithinDays(b.date, filter, today) && passesSenateFilter(b))

Expand Down
2 changes: 0 additions & 2 deletions app/(dashboard)/my-rooms/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ export function bookingTitle(b: FlatBooking): string {
return b.purpose?.trim() || b.scopeLabel
}

export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const

export const statusColors: Record<string, string> = {
'Reserved': 'bg-[#0f3d20] border-[#22c55e]',
'Alternate Room': 'bg-[#0e2f4f] border-[#4285f4]',
Expand Down
10 changes: 8 additions & 2 deletions app/(dashboard)/settings-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { getJson } from '@/lib/fetch-json'
import { Skeleton } from '@/app/_components/skeleton'
import { getPrefsForRole, EMAIL_PREF_LABELS, EmailPrefKey } from '@/lib/email-preferences'
import { SENATE_TYPES } from '@/lib/senate-types'

interface Membership {
id: string
Expand Down Expand Up @@ -34,7 +35,7 @@ export interface Settings {
available_bodies: AvailableBody[]
}

export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const
export { SENATE_TYPES } from '@/lib/senate-types'

interface SettingsModalProps {
onClose: () => void
Expand Down Expand Up @@ -273,7 +274,12 @@ export default function SettingsModal({ onClose, cachedSettings, onSettingsLoade
{/* Senate Session Types */}
{!loading && isSenateMember && (
<div className="space-y-3">
<p className="text-xs font-medium text-[#93b8d8]">Senate Session Types Shown in My Rooms</p>
<div>
<p className="text-xs font-medium text-[#93b8d8]">Senate Session Types You Follow</p>
<p className="text-xs text-[#6a96bb] mt-1">
Deselected types are hidden from My Rooms, and Chambers stops emailing and alerting you about them.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SENATE_TYPES.map(type => (
<label key={type} className="flex items-center gap-3 cursor-pointer">
Expand Down
158 changes: 93 additions & 65 deletions app/api/administrator/bookings/weekly/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed-
import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated'
import { sendBookingCreatedEmail } from '@/lib/emails/booking-created'
import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes'
import { occurrenceMoved } from '@/lib/weekly-occurrences'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { waitUntil } from '@vercel/functions'
Expand Down Expand Up @@ -43,6 +44,20 @@ const adminSupabase = createAdminClient(
process.env.SUPABASE_SERVICE_ROLE_KEY!
)

/** One stored occurrence as read back before the regeneration below. */
interface PrevOccurrenceRow {
occurrence_date: string
room_name: string | null
start_time: string | null
end_time: string | null
status: string | null
reservation_code: string | null
purpose: string | null
senate_type: string | null
hidden: boolean | null
is_event: boolean | null
}

function getWeeklyDates(startDate: string, endDate: string): string[] {
const dates: string[] = []
const current = new Date(startDate + 'T00:00:00')
Expand Down Expand Up @@ -204,7 +219,9 @@ export async function PATCH(request: Request) {
.single(),
adminSupabase
.from('weekly_room_occurrences')
.select('occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose')
.select(
'occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose, senate_type, hidden, is_event'
)
.eq('weekly_booking_id', weekly_id),
])

Expand Down Expand Up @@ -287,28 +304,60 @@ export async function PATCH(request: Request) {
.single()
const bodyName = bodyData?.name ?? 'Unknown'

// The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for
// the divisional/multi fan-out policy.
const scopedRow: ScopedRow = {
id: booking_id,
body_id: selection.value.body_id,
scope: selection.value.scope,
division: selection.value.division,
}
const recipients = await resolveBookingRecipients(adminSupabase, scopedRow)
// Which weeks this edit actually moved, in date order.
//
// This used to be "the first week carrying any override", which is a different
// question and usually a different week: an override set on week 1 months ago
// is still an override today, so every later edit to the series was reported
// against week 1 -- with an empty change list, because week 1 had not in fact
// moved (issue #91). Comparing each week against what was stored for it is the
// only way to name the week the administrator touched.
const prevByDate = new Map(
((prevOccurrences ?? []) as PrevOccurrenceRow[]).map(o => [o.occurrence_date, o])
)
const movedOccurrences = newOccurrences.filter(o =>
occurrenceMoved(prevByDate.get(o.occurrence_date), o)
)

// `hidden != null` rather than a truthiness test: an occurrence forced
// visible (false) has been changed just as much as one forced hidden.
// Series-level fields. Compared against what was on the row before this
// request rather than against the payload, so an edit that resubmits a field
// unchanged does not report it as a change.
//
// Hoisted out of the alerts block below because the email needs it too: an
// edit to a single week used to be described using the series' start date, so
// someone told "your booking changed" was pointed at a date months earlier
// than the one that had actually moved (issue #79).
const overriddenOcc = newOccurrences.find(
o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code
|| o.purpose || o.hidden != null
// Computed here rather than in the email block below because the audience
// depends on it: whether this edit is about particular sessions or about the
// whole series decides which Senate session types it is about.
const seriesChanges = collectChanges(
changed('Purpose', prevBooking?.purpose, purpose),
changed('Room', prevWeekly?.room_name, room_name),
changed('Start date', prevWeekly?.start_date, start_date, formatDate),
changed('End date', prevWeekly?.end_date, end_date, formatDate),
changed('Start time', prevWeekly?.start_time, start_time, formatTime),
changed('End time', prevWeekly?.end_time, end_time, formatTime),
changed('Status', prevWeekly?.status, status),
changed('Reservation code', prevWeekly?.reservation_code, reservation_code || null),
)
const changedOcc = overriddenOcc ?? newOccurrences[0]

// The sessions this notification is about: the weeks that moved, or -- when
// the series itself moved -- all of them. Senate members who have deselected
// every one of these session types drop out of the audience (issues #92, #93).
const notifiedSenateTypes = (seriesChanges.length === 0 ? movedOccurrences : newOccurrences)
.map(o => o.senate_type)

// The audience is the whole scope, not just the owning body -- see resolveBookingRecipients for
// the divisional/multi fan-out policy.
const recipients = await resolveBookingRecipients(adminSupabase, scopedRow, {
senateTypes: notifiedSenateTypes,
})

// The alert points at the earliest week that moved, falling back to the start
// of the series when the edit was series-wide.
const alertOcc = movedOccurrences[0] ?? newOccurrences[0]

if (recipients.length && auditLog) {
await adminSupabase.from('user_alerts').insert(
Expand All @@ -317,8 +366,8 @@ export async function PATCH(request: Request) {
audit_log_id: auditLog.id,
booking_id,
booking_type: 'Weekly Room',
booking_date: changedOcc?.occurrence_date ?? start_date,
start_time: changedOcc?.start_time ?? start_time,
booking_date: alertOcc?.occurrence_date ?? start_date,
start_time: alertOcc?.start_time ?? start_time,
}))
)
}
Expand All @@ -330,60 +379,38 @@ export async function PATCH(request: Request) {
try {
const emails = recipients.map(r => r.email)

// Series-level fields. Compared against what was on the row before this
// request rather than against the payload, so an edit that resubmits a
// field unchanged does not report it as a change.
const seriesChanges = collectChanges(
changed('Purpose', prevBooking?.purpose, purpose),
changed('Room', prevWeekly?.room_name, room_name),
changed('Start date', prevWeekly?.start_date, start_date, formatDate),
changed('End date', prevWeekly?.end_date, end_date, formatDate),
changed('Start time', prevWeekly?.start_time, start_time, formatTime),
changed('End time', prevWeekly?.end_time, end_time, formatTime),
changed('Status', prevWeekly?.status, status),
changed('Reservation code', prevWeekly?.reservation_code, reservation_code || null),
)

// When exactly one week carries overrides and the series itself did not
// move, the edit was to that week -- so the email describes that week.
// If the series moved too, the series is the story and a per-week
// heading would understate it.
const targetOcc = overriddenOcc && seriesChanges.length === 0 ? overriddenOcc : null

if (targetOcc) {
const prev = (prevOccurrences ?? []).find(
(o: { occurrence_date: string }) => o.occurrence_date === targetOcc.occurrence_date
)
// When the series itself did not move, the edit was to the weeks that
// moved -- so the email describes those weeks. If the series moved too,
// the series is the story and a per-week heading would understate it.
const targetOccs = seriesChanges.length === 0 ? movedOccurrences : []

const sessions = targetOccs.map(occ => {
const prev = prevByDate.get(occ.occurrence_date)
// An occurrence field that is null inherits from the series, so the
// comparison is between effective values -- otherwise clearing an
// override would read as a change to nothing.
const occChanges = collectChanges(
changed('Room', prev?.room_name ?? prevWeekly?.room_name, targetOcc.room_name ?? room_name),
changed('Start time', prev?.start_time ?? prevWeekly?.start_time, targetOcc.start_time ?? start_time, formatTime),
changed('End time', prev?.end_time ?? prevWeekly?.end_time, targetOcc.end_time ?? end_time, formatTime),
changed('Status', prev?.status ?? prevWeekly?.status, targetOcc.status ?? status),
changed('Purpose', prev?.purpose ?? prevBooking?.purpose, targetOcc.purpose ?? purpose),
changed('Reservation code', prev?.reservation_code ?? prevWeekly?.reservation_code, targetOcc.reservation_code ?? (reservation_code || null)),
const changes = collectChanges(
changed('Room', prev?.room_name ?? prevWeekly?.room_name, occ.room_name ?? room_name),
changed('Start time', prev?.start_time ?? prevWeekly?.start_time, occ.start_time ?? start_time, formatTime),
changed('End time', prev?.end_time ?? prevWeekly?.end_time, occ.end_time ?? end_time, formatTime),
changed('Status', prev?.status ?? prevWeekly?.status, occ.status ?? status),
changed('Purpose', prev?.purpose ?? prevBooking?.purpose, occ.purpose ?? purpose),
changed('Reservation code', prev?.reservation_code ?? prevWeekly?.reservation_code, occ.reservation_code ?? (reservation_code || null)),
)

const index = newOccurrences.findIndex(o => o.occurrence_date === targetOcc.occurrence_date)

await sendBookingUpdatedEmail({
bodyName,
purpose: targetOcc.purpose ?? purpose,
roomOrTable: targetOcc.room_name || room_name || 'N/A',
date: targetOcc.occurrence_date,
startTime: targetOcc.start_time || start_time,
endTime: targetOcc.end_time || end_time,
status: targetOcc.status || status,
changes: occChanges,
occurrence: {
position: index >= 0 ? `week ${index + 1} of ${newOccurrences.length}` : null,
},
recipients: emails,
})
return
}
const index = newOccurrences.findIndex(o => o.occurrence_date === occ.occurrence_date)

return {
date: occ.occurrence_date,
startTime: occ.start_time || start_time,
endTime: occ.end_time || end_time,
roomOrTable: occ.room_name || room_name || 'N/A',
status: occ.status || status,
purpose: occ.purpose ?? purpose,
position: index >= 0 ? `week ${index + 1} of ${newOccurrences.length}` : null,
changes,
}
})

await sendBookingUpdatedEmail({
bodyName,
Expand All @@ -394,6 +421,7 @@ export async function PATCH(request: Request) {
endTime: end_time,
status,
changes: seriesChanges,
sessions,
recipients: emails,
})
} catch (e) {
Expand Down
38 changes: 34 additions & 4 deletions lib/booking-scope.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { hasLiveAdmin, type AuthedUser } from './auth'
import { wantsAnySenateSession } from './senate-types'

/**
* Multi-body bookings (issue #19).
Expand Down Expand Up @@ -339,11 +340,18 @@ export interface Recipient {
fullName: string
}

interface RecipientUser {
email: string
full_name: string
is_active: boolean
senate_type_preferences: Record<string, boolean> | null
}

interface RecipientRow {
user_id: string
body_id: string
role: string
users: { email: string; full_name: string; is_active: boolean } | { email: string; full_name: string; is_active: boolean }[] | null
users: RecipientUser | RecipientUser[] | null
}

/**
Expand All @@ -367,23 +375,34 @@ interface RecipientRow {
*
* `leadershipOnly` narrows to Leadership across the whole audience regardless of scope -- used for
* the missed-reservation email, which only ever went to leadership.
*
* `senateTypes` are the session types this notification is about. Pass them and members of the
* Senate who have deselected every one of those types in Settings drop out of the audience --
* which is what that preference was always supposed to mean, rather than only hiding the rows on
* the My Rooms page (issues #92, #93). Omit it for a notification that is not about particular
* sessions; nobody is filtered then.
*/
export async function resolveBookingRecipients(
adminSupabase: SupabaseClient,
row: ScopedRow,
opts: { leadershipOnly?: boolean } = {}
opts: { leadershipOnly?: boolean; senateTypes?: (string | null | undefined)[] } = {}
): Promise<Recipient[]> {
const bodyIds = await resolveBookingBodyIds(adminSupabase, row)
if (bodyIds.length === 0) return []

const [{ data }, { data: bookingRow }] = await Promise.all([
adminSupabase
.from('board_memberships')
.select('user_id, body_id, role, users(email, full_name, is_active)')
.select('user_id, body_id, role, users(email, full_name, is_active, senate_type_preferences)')
.in('body_id', bodyIds),
adminSupabase.from('bookings').select('hidden').eq('id', row.id).maybeSingle(),
// bodies(name) is read for the Senate session-type filter below, which keys on the owning
// body being the one literally named "Senate" -- the same thing the My Rooms filter keys on.
adminSupabase.from('bookings').select('hidden, bodies(name)').eq('id', row.id).maybeSingle(),
])

const ownerBody = Array.isArray(bookingRow?.bodies) ? bookingRow?.bodies[0] : bookingRow?.bodies
const ownerBodyName = (ownerBody as { name: string } | null | undefined)?.name ?? null

// A hidden booking notifies only the people who can manage it, which is exactly the set
// canManageScoped() admits: Leadership anywhere in the booking's audience.
const leadershipOnly = !!opts.leadershipOnly || !!bookingRow?.hidden
Expand All @@ -397,6 +416,17 @@ export async function resolveBookingRecipients(

if (leadershipOnly && m.role !== 'Leadership') continue

// Applies to Leadership too. Someone who has said they do not follow Office Hours does not
// start wanting those emails because they lead the body -- the preference is about what they
// read, not about what they are responsible for, and Leadership can still see every session
// on the booking itself.
if (
opts.senateTypes &&
!wantsAnySenateSession(user.senate_type_preferences, ownerBodyName, opts.senateTypes)
) {
continue
}

// Divisional: peer bodies contribute only their leadership.
if (
!leadershipOnly &&
Expand Down
Loading
Loading