diff --git a/docs/02-handoff/technical-decisions.md b/docs/02-handoff/technical-decisions.md index 80adc6da..129b974a 100644 --- a/docs/02-handoff/technical-decisions.md +++ b/docs/02-handoff/technical-decisions.md @@ -123,7 +123,7 @@ Each section ends with a **Revisit if:** line — the condition under which this - Pool exhaustion fails soft: the meeting still saves, with `zoomSyncStatus: "error"`, retryable the same as any other sync failure. - There's no Zoom-native "Room Calendar" resource here — each room's calendar in Zoom's admin console is actually a Google Calendar the app writes to directly, since there's no Google Workspace add-on that auto-creates a Zoom meeting from a calendar event. This calendar-per-room mapping is unrelated to host assignment and stays fixed per room. - Recurring meetings get one stable Zoom meeting for the whole series — a real recurring meeting (Zoom type 8) carrying the actual pattern, usually endless via `end_times: 0` (undocumented, but exactly what Zoom's own portal stores for its "no end" meetings; Zoom's PATCH path clamps it to a ~2-year rolling horizon that every subsequent edit re-extends). Its `start_time` is always the next *future* occurrence: Zoom silently rewrites a past start to "now" and files the meeting under the host's past meetings. Bounded series longer than Zoom's 50-occurrence cap still end on time — the app and calendars own the real schedule; Zoom's copy just under-shows the tail. Only a whole-series delete touches the Zoom meeting, and only when no other platform meeting still shares its `zid`; a schedule PATCH for a shared `zid` sends the union of every sharing row's weekdays (one Zoom meeting, one schedule), never a single row's narrowed view. -- **Adopted legacy Zoom meetings:** ICR's pre-platform meetings were pointed at (not recreated) in Aug 2026, preserving the meeting IDs/passcodes members have used for years, then converted in place from type 3 to type 8 (a Zoom PATCH changes the type while keeping ID, passcode, and join URL — verified empirically). Their historic Zoom names are pinned via `Meeting.zoomTopic`: when set, syncs send it verbatim; when null, the topic derives as `title + " - Hybrid"/" - Zoom Only"`. An app-side title edit can therefore never silently rename a Zoom meeting members recognize. +- **Adopted legacy Zoom meetings:** ICR's pre-platform meetings were pointed at (not recreated) in Aug 2026, preserving the meeting IDs/passcodes members have used for years, then converted in place from type 3 to type 8 (a Zoom PATCH changes the type while keeping ID, passcode, and join URL — verified empirically). Their historic Zoom names are pinned via `Meeting.zoomTopic`: when set, syncs send it verbatim; when null, the topic derives from the meeting's linked-schedule family (`buildLinkedScheduleLabel`, `util/meetings/linkedSchedules.ts`): a lone schedule gets `title + " - Hybrid"/" - Zoom Only"`, while a meeting run as two linked schedules gets one name covering both — `"One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"` — carried identically by the shared Zoom meeting and by every member's Google Calendar event, so the two services can never drift apart. An app-side title edit can therefore never silently rename a Zoom meeting members recognize. - **Host reassignment transfers in place** — a Zoom Host change PATCHes `schedule_for` onto the existing meeting, keeping its ID, passcode and join link (every sharing row's `zoomHost` follows it), and only falls back to tear-down-and-recreate when Zoom refuses the move (missing scheduling privilege, basic-tier host) on a meeting no other row shares. - **A pure Zoom Room change moves in place too (GitHub #522)** — room and host are independent resources (above), so a managed meeting's Zoom Room change never touches the Zoom meeting itself: `zid`/link/passcode/host stay put, and only the join-link event moves — the old room's calendar event is deleted, a fresh one is created on the new room's calendar with the same stored link. Tear-down-and-recreate is reserved for a genuine host-can't-transfer reason (an explicit host change Zoom refused to move, or a shared-`zid` row whose host couldn't move); a room change bundled with one of those still recreates, same as before. The whole-series edit's synchronous host-resolution step no longer treats a room change as needing a new host either — only "never had a `zid`" or an explicit host change does. - **`Meeting.zoomManaged = false`** marks the handful of meetings whose Zoom side ICR does not control (e.g. Noon Brown Baggers, owned by an outside account): the app syncs their calendars and keeps the stored link, but never creates, PATCHes, deletes, or auto-provisions anything on Zoom for them, and blocks host reassignment in the form. Everything ICR's own account owns — including adopted legacy meetings — stays `zoomManaged: true`. diff --git a/frontend/app/api/delete/meeting/route.ts b/frontend/app/api/delete/meeting/route.ts index e920979d..f7fa7817 100644 --- a/frontend/app/api/delete/meeting/route.ts +++ b/frontend/app/api/delete/meeting/route.ts @@ -10,6 +10,7 @@ import { import { IMeeting } from '../../../../types/models'; import { deleteZoomMeeting, zoomRoomCalendarId } from '../../../../services/zoom'; import { reconcilePendingResume, tearDownPendingResumeSeries, MeetingWithSuspensions } from '../../../../util/meetings/suspension'; +import { getZoomScheduleFamily } from '../../../../util/meetings/linkedSchedules'; import { prisma } from '../../../../lib/prisma'; // Returns "YYYY-MM-DD" in Eastern Time for the given UTC timestamp. @@ -49,6 +50,10 @@ async function syncPartialDelete( if (!accessToken || status === 'Suspended') return; let synced = true; let syncError: string | null = null; + // The surviving row may be one schedule of a linked family, whose event title names every + // schedule -- rewriting its body without the family would quietly rename it back to its own + // mode alone. + const family = await getZoomScheduleFamily(prisma, mid, meetingForCalendar.zid ?? null); for (const [cat, calId] of Object.entries(calendarIds)) { const eventId = eventIds[cat]; if (!eventId) { @@ -60,7 +65,7 @@ async function syncPartialDelete( syncError = syncError ?? `Missing Google Calendar event ID for "${cat}".`; continue; } - const { ok, error } = await updateCalendarEvent(accessToken, eventId, meetingForCalendar, calId); + const { ok, error } = await updateCalendarEvent(accessToken, eventId, meetingForCalendar, calId, undefined, family); if (!ok) { synced = false; syncError = syncError ?? error ?? "Failed to update the calendar event."; @@ -73,7 +78,7 @@ async function syncPartialDelete( if (zoomCalendarEventId && zoomRoom && meetingForCalendar.zoomLink) { const calId = zoomRoomCalendarId[zoomRoom]; if (calId) { - const { ok, error } = await updateCalendarEvent(accessToken, zoomCalendarEventId, meetingForCalendar, calId, meetingForCalendar.zoomLink); + const { ok, error } = await updateCalendarEvent(accessToken, zoomCalendarEventId, meetingForCalendar, calId, meetingForCalendar.zoomLink, family); if (!ok) { synced = false; syncError = syncError ?? error ?? "Failed to update the Zoom-Room calendar event."; diff --git a/frontend/app/api/retrieve/meeting/[id]/route.ts b/frontend/app/api/retrieve/meeting/[id]/route.ts index 22e94be1..cad7e28f 100644 --- a/frontend/app/api/retrieve/meeting/[id]/route.ts +++ b/frontend/app/api/retrieve/meeting/[id]/route.ts @@ -4,6 +4,7 @@ import { prisma } from "../../../../../lib/prisma"; import { toPublicMeeting } from "../../../../../util/meetings/publicMeeting"; import { getUnresolvedSuspension } from "../../../../../util/meetings/suspension"; import { isSharedZoomScheduleCompatible } from "../../../../../util/meetings/sharedZoomSchedule"; +import { isDetachedSplitChild } from "../../../../../util/meetings/linkedSchedules"; import { formatETDateString } from "../../../../../util/date/timeUtils"; const getMeeting = async(request: NextRequest) => { try { @@ -65,16 +66,16 @@ const getMeeting = async(request: NextRequest) => { }, }) : []; - // A "This event" split-off child (isRecurring: false, splitFromMid set) has no - // representation in Zoom's single schedule at all -- it's a one-off, not a second weekly - // slot -- so it must never count toward this divergence signal. Filtered out here, not in - // isSharedZoomScheduleCompatible itself: that function's "incompatible" answer for such a - // row is still the correct input to the schedule-neutral PATCH decision in services/zoom.ts, - // which is a different question (can Zoom's PATCH represent this row at all) than "is there - // a genuine, user-visible divergence to warn about." Without this, a detached child would - // permanently pin zoomScheduleDiverged to true with no way to ever clear it. A recurring - // tail split (editScope 'thisAndFollowing') keeps `isRecurring: true` and still counts. - const scheduleRelevantRows = [meeting, ...siblings].filter((row) => row.isRecurring || !row.splitFromMid); + // A "This event" split-off child has no representation in Zoom's single schedule at all -- + // it's a one-off, not a second weekly slot -- so it must never count toward this divergence + // signal. Filtered out here, not in isSharedZoomScheduleCompatible itself: that function's + // "incompatible" answer for such a row is still the correct input to the schedule-neutral + // PATCH decision in services/zoom.ts, which is a different question (can Zoom's PATCH + // represent this row at all) than "is there a genuine, user-visible divergence to warn + // about." Without this, a detached child would permanently pin zoomScheduleDiverged to true + // with no way to ever clear it. Same predicate the family label excludes by, so the two can + // never disagree about what counts as a detached one-off. + const scheduleRelevantRows = [meeting, ...siblings].filter((row) => !isDetachedSplitChild(row)); const sharedZoom = siblings.length > 0 ? { sharedWith: siblings.map(({ title, modeType }) => ({ title, modeType })), diff --git a/frontend/app/api/update/meeting/resume/route.ts b/frontend/app/api/update/meeting/resume/route.ts index e9004413..0486c0ce 100644 --- a/frontend/app/api/update/meeting/resume/route.ts +++ b/frontend/app/api/update/meeting/resume/route.ts @@ -12,6 +12,7 @@ import { MeetingWithPattern, MeetingWithSuspensions, } from '../../../../../util/meetings/suspension'; +import { getZoomScheduleFamily } from '../../../../../util/meetings/linkedSchedules'; import { prisma } from '../../../../../lib/prisma'; // Resuming always creates a fresh series/event starting today (or recreates the original @@ -36,6 +37,10 @@ async function syncResume( } const meetingForSync = toCalendarMeeting(meeting, meeting.startDateTime, meeting.endDateTime); + // A resumed row may be one schedule of a linked family, whose event title names every + // schedule -- recreating its events without the family would rename them to its own mode + // alone and leave them wrong until the row is next fully edited. + const family = await getZoomScheduleFamily(prisma, meeting.mid, meeting.zid ?? null); const eventIds: Record = {}; // requestedCats, not Object.keys(calendarIds) -- a category missing from calendarIds (its // GOOGLE_CALENDAR_* env var isn't configured) must still count against `synced` below, same @@ -45,7 +50,7 @@ async function syncResume( ? `Calendar for "${unconfiguredCat}" is not configured.` : null; for (const [cat, calId] of Object.entries(calendarIds)) { - const { id, error } = await createCalendarEvent(accessToken, meetingForSync, calId); + const { id, error } = await createCalendarEvent(accessToken, meetingForSync, calId, undefined, family); if (id) eventIds[cat] = id; else googleSyncError = googleSyncError ?? error; } diff --git a/frontend/app/api/update/meeting/route.ts b/frontend/app/api/update/meeting/route.ts index f34886c7..9051edca 100644 --- a/frontend/app/api/update/meeting/route.ts +++ b/frontend/app/api/update/meeting/route.ts @@ -11,6 +11,7 @@ import { createZoomMeeting, updateZoomMeeting, deleteZoomMeeting, getZoomHostCap import { findResourceConflicts, findResourceConflictRows, ConflictRow, ResourceConflictAbort } from "../../../../util/meetings/resourceOverlap"; import { lockResourceClaims, ResourceClaim } from "../../../../util/meetings/resourceLocks"; import { meetingSchema, editScopeSchema } from "../../../../util/meetings/meetingValidation"; +import { linkedFamilyLoader, LinkedFamilyLoader } from "../../../../util/meetings/linkedSchedules"; import { reconcilePendingResume, tearDownPendingResumeSeries } from "../../../../util/meetings/suspension"; import { calculateEndDateFromOccurrences } from "../../../../util/meetings/meetingOccurrences"; import { EditScope, countOccurrencesBefore, exclusionInstant, trimmedEndDate, isLiveOccurrence, rootSplitMid, toETDateStr } from "../../../../util/meetings/editScope"; @@ -53,6 +54,11 @@ async function syncUpdatedMeeting( // time-conflict, below) -- read outside this block by zoomBlocking, so the calType calendar // reconcile doesn't publish the new time while Zoom itself is still sitting at the old one. let skipCalendarTimeSync = false; + // One family lookup for this whole sync: the Zoom PATCH/create below and every Google Calendar + // write further down name the family the same way, so they must read the same rows. Whichever + // gets there first pays for the query; an In-Person family member reaches only the calendar + // half and loads it there. + const loadFamily = linkedFamilyLoader(prisma, mid); if (zoomEnabled) { // A room isn't tied to any particular Zoom meeting (#522) -- a room change alone moves only @@ -182,16 +188,13 @@ async function syncUpdatedMeeting( // Unmanaged Zoom meetings are never PATCHed -- the stored link is the contract; only // the calendars follow the app-side edit. // The pinned topic (if any) lives on the DB row, not the client payload -- thread it - // through so a managed PATCH keeps the meeting's established Zoom name. Sibling rows - // sharing this zid ride along so the PATCH sends the whole union schedule (#513). - const scheduleSiblings = existingMeeting.zoomManaged - ? await prisma.meeting.findMany({ - where: { zid, deletedAt: null, mid: { not: newMeeting.mid } }, - include: { recurrencePattern: true }, - }) - : []; + // through so a managed PATCH keeps the meeting's established Zoom name (a null one + // stays null, so an auto topic is recomputed from the family below rather than pinned). + // The whole linked-schedule family rides along so the PATCH sends the union schedule + // (#513) and the family's own Zoom name, not this row's narrowed view of either. + const family = existingMeeting.zoomManaged ? await loadFamily(zid) : []; const ok = existingMeeting.zoomManaged - ? await updateZoomMeeting(zid, { ...newMeeting, zoomTopic: existingMeeting.zoomTopic }, scheduleSiblings as unknown as IMeeting[]) + ? await updateZoomMeeting(zid, { ...newMeeting, zoomTopic: existingMeeting.zoomTopic }, family) : true; if (!ok) zoomSynced = false; } @@ -208,7 +211,12 @@ async function syncUpdatedMeeting( zoomSynced = false; zoomSyncError = hostSyncError ?? "No Zoom host available for this meeting's schedule (pool exhausted)."; } else { - const created = await createZoomMeeting(newMeeting, host); + // No zid to group by here (this row either never had one or just had it torn down), so + // the family is whatever linkedToMid says -- enough for the fresh meeting to be minted + // with the family's union schedule and Zoom name rather than a name it has to be + // renamed out of on the next PATCH. + const family = await loadFamily(null); + const created = await createZoomMeeting(newMeeting, host, family); if (created) { zid = created.zid; zoomLink = created.zoomLink; @@ -230,14 +238,15 @@ async function syncUpdatedMeeting( const calId = zoomRoomCalendarId[newZoomRoom]; if (calId) { const meetingWithZoomLink = { ...newMeeting, zoomLink }; + const family = await loadFamily(zid); if (zoomCalendarEventId) { - const { ok, error } = await updateCalendarEvent(accessToken, zoomCalendarEventId, meetingWithZoomLink, calId, zoomLink); + const { ok, error } = await updateCalendarEvent(accessToken, zoomCalendarEventId, meetingWithZoomLink, calId, zoomLink, family); if (!ok) { zoomSynced = false; zoomSyncError = zoomSyncError ?? error ?? "Zoom meeting's calendar event failed to update."; } } else { - const { id: eventId, error } = await createCalendarEvent(accessToken, meetingWithZoomLink, calId, zoomLink); + const { id: eventId, error } = await createCalendarEvent(accessToken, meetingWithZoomLink, calId, zoomLink, family); if (eventId) zoomCalendarEventId = eventId; else { zoomSynced = false; @@ -263,6 +272,7 @@ async function syncUpdatedMeeting( accessToken, meetingForCalendar, existingEventIds, + await loadFamily(zid), ); await prisma.meeting.update({ @@ -345,10 +355,14 @@ async function syncScopedParentCalendar( meetingForCalendar: IMeeting, zoomCalendarEventId: string | null, zoomRoom: string | null, + loadFamily: LinkedFamilyLoader, ): Promise { if (!accessToken || status === 'Suspended') return; let synced = true; let syncError: string | null = null; + // The parent may be one schedule of a linked family, whose event title names every schedule -- + // rewriting its body without the family would quietly rename it back to its own mode alone. + const family = await loadFamily(meetingForCalendar.zid ?? null); for (const [cat, calId] of Object.entries(calendarIds)) { const eventId = eventIds[cat]; if (!eventId) { @@ -360,7 +374,7 @@ async function syncScopedParentCalendar( syncError = syncError ?? `Missing Google Calendar event ID for "${cat}".`; continue; } - const { ok, error } = await updateCalendarEvent(accessToken, eventId, meetingForCalendar, calId); + const { ok, error } = await updateCalendarEvent(accessToken, eventId, meetingForCalendar, calId, undefined, family); if (!ok) { synced = false; syncError = syncError ?? error ?? "Failed to update the calendar event."; @@ -373,7 +387,7 @@ async function syncScopedParentCalendar( if (zoomCalendarEventId && zoomRoom && meetingForCalendar.zoomLink) { const calId = zoomRoomCalendarId[zoomRoom]; if (calId) { - const { ok, error } = await updateCalendarEvent(accessToken, zoomCalendarEventId, meetingForCalendar, calId, meetingForCalendar.zoomLink); + const { ok, error } = await updateCalendarEvent(accessToken, zoomCalendarEventId, meetingForCalendar, calId, meetingForCalendar.zoomLink, family); if (!ok) { synced = false; syncError = syncError ?? error ?? "Failed to update the Zoom-Room calendar event."; @@ -396,6 +410,7 @@ async function syncSplitMeeting( meetingData: IMeeting, isRecurring: boolean, accessToken: string | undefined, + loadFamily: LinkedFamilyLoader, ): Promise { // Defense in depth, same guard as syncUpdatedMeeting's -- the caller always creates a // split-off row with status: 'Active' (it has no suspension history of its own), so this @@ -405,6 +420,11 @@ async function syncSplitMeeting( const zoomEnabled = meetingData.modeType === 'Hybrid' || meetingData.modeType === 'Remote'; const meetingForSync: IMeeting = { ...meetingData, isRecurring }; + // The split-off row is a lineage of its parent, not a second mode, so it joins the family only + // through the zid it inherited -- which is exactly how Zoom already sees it. Read from the + // parent's loader: this sync runs concurrently with syncScopedParentCalendar over the same + // zid group, so the two share one lookup instead of issuing near-identical queries. + const family = await loadFamily(meetingData.zid ?? null); let googleCalendarEventIds: Record | undefined; let googleSyncStatus: string | undefined; @@ -419,7 +439,7 @@ async function syncSplitMeeting( ? `Calendar for "${unconfiguredCat}" is not configured.` : null; for (const [cat, calId] of Object.entries(calendarIds)) { - const { id, error } = await createCalendarEvent(accessToken, meetingForSync, calId); + const { id, error } = await createCalendarEvent(accessToken, meetingForSync, calId, undefined, family); if (id) eventIds[cat] = id; else syncError = syncError ?? error; } @@ -438,7 +458,7 @@ async function syncSplitMeeting( if (zoomEnabled && accessToken && meetingData.zoomLink && meetingData.zoomRoom) { const calId = zoomRoomCalendarId[meetingData.zoomRoom]; if (calId) { - const { id: eventId, error } = await createCalendarEvent(accessToken, meetingForSync, calId, meetingData.zoomLink); + const { id: eventId, error } = await createCalendarEvent(accessToken, meetingForSync, calId, meetingData.zoomLink, family); if (eventId) zoomCalendarEventId = eventId; else { zoomSynced = false; @@ -684,11 +704,15 @@ async function handleScopedEdit( // The parent's OWN field values (unchanged by a scoped edit -- only its pattern is // trimmed/excluded) plus the just-written post-trim pattern from the transaction result. const parentForCalendar = { ...existingMeeting, recurrencePattern: updatedParent.recurrencePattern } as unknown as IMeeting; + // One family lookup for both background syncs below: the split child inherits the parent's + // zid, so keyed on the parent's mid it returns the superset both of them need -- and they + // start concurrently, which is why the loader caches its in-flight promise. + const loadFamily = linkedFamilyLoader(prisma, mid); after( syncScopedParentCalendar( mid, existingMeeting.status, auth.accessToken, calendarIds, eventIds, parentForCalendar, - existingMeeting.zoomCalendarEventId, existingMeeting.zoomRoom, + existingMeeting.zoomCalendarEventId, existingMeeting.zoomRoom, loadFamily, ).catch((error) => console.error("syncScopedParentCalendar threw:", error)), ); if (hasStaleResumeSeries) { @@ -705,6 +729,7 @@ async function handleScopedEdit( { ...newMeeting, mid: newMid, zid: existingMeeting.zid, zoomLink: existingMeeting.zoomLink, status: 'Active' }, isRecurringSplit, auth.accessToken, + loadFamily, ).catch(async (error) => { console.error("syncSplitMeeting threw:", error); try { diff --git a/frontend/app/api/update/meeting/sync/route.ts b/frontend/app/api/update/meeting/sync/route.ts index e838e0bc..b2e9e2bd 100644 --- a/frontend/app/api/update/meeting/sync/route.ts +++ b/frontend/app/api/update/meeting/sync/route.ts @@ -5,6 +5,7 @@ import { IMeeting } from "../../../../../types/models"; import { createCalendarEvent, updateCalendarEvent, reconcileMeetingCalendars } from "../../../../../services/googleCalendar"; import { createZoomMeeting, getZoomHostCapacities, getZoomMeetingCredentials, updateZoomMeeting, getZoomMeetingInvitation, resolveZoomHost, zoomHostPool, zoomRoomCalendarId } from "../../../../../services/zoom"; import { lockResourceClaims } from "../../../../../util/meetings/resourceLocks"; +import { linkedFamilyLoader } from "../../../../../util/meetings/linkedSchedules"; import { prisma } from "../../../../../lib/prisma"; const syncMeeting = async (request: Request): Promise => { @@ -53,6 +54,9 @@ const syncMeeting = async (request: Request): Promise => { let zoomSyncError: string | null = meeting.zoomSyncError ?? null; let zid = meeting.zid; let zoomLink = meeting.zoomLink; + // One family lookup for this whole retry, shared by the Zoom write and the calendar + // writes below -- they name the family the same way, so they must read the same rows. + const loadFamily = linkedFamilyLoader(prisma, mid); if (zoomEnabled) { let zoomPasscode = meeting.zoomPasscode; @@ -84,16 +88,12 @@ const syncMeeting = async (request: Request): Promise => { // The conflict itself still surfaces independently via /api/admin/conflict-mids // (Diagnostics), regardless of what happens here. // Unmanaged (adopted/external) Zoom meetings are never PATCHed -- retrying the - // sync only re-runs the calendar half against the stored link. Sibling rows - // sharing this zid ride along so the PATCH sends the whole union schedule (#513). - const scheduleSiblings = meeting.zoomManaged - ? await prisma.meeting.findMany({ - where: { zid, deletedAt: null, mid: { not: mid } }, - include: { recurrencePattern: true }, - }) - : []; + // sync only re-runs the calendar half against the stored link. The whole + // linked-schedule family rides along so the PATCH sends the union schedule + // (#513) and the family's own Zoom name, not this row's narrowed view of either. + const family = meeting.zoomManaged ? await loadFamily(zid) : []; const ok = meeting.zoomManaged - ? await updateZoomMeeting(zid, meetingForCalendar, scheduleSiblings as unknown as IMeeting[]) + ? await updateZoomMeeting(zid, meetingForCalendar, family) : true; if (!ok) zoomSynced = false; } else if (!meeting.zoomManaged) { @@ -129,7 +129,11 @@ const syncMeeting = async (request: Request): Promise => { // failure below must not roll back the reservation (the final // prisma.meeting.update further down re-persists this same value either way). zoomHost = host; - const created = await createZoomMeeting(meetingForCalendar, host); + // Same family lookup as the PATCH branch above, minus the zid this row + // doesn't have yet -- the fresh Zoom meeting is minted with the family's + // union schedule and Zoom name from the start. + const family = await loadFamily(null); + const created = await createZoomMeeting(meetingForCalendar, host, family); if (created) { zid = created.zid; zoomLink = created.zoomLink; @@ -148,14 +152,15 @@ const syncMeeting = async (request: Request): Promise => { const calId = zoomRoomCalendarId[meeting.zoomRoom]; if (calId) { const meetingWithZoomLink = { ...meetingForCalendar, zoomLink }; + const family = await loadFamily(zid); if (zoomCalendarEventId) { - const { ok, error } = await updateCalendarEvent(auth.accessToken, zoomCalendarEventId, meetingWithZoomLink, calId, zoomLink); + const { ok, error } = await updateCalendarEvent(auth.accessToken, zoomCalendarEventId, meetingWithZoomLink, calId, zoomLink, family); if (!ok) { zoomSynced = false; zoomSyncError = zoomSyncError ?? error ?? "Zoom meeting's calendar event failed to update."; } } else { - const { id: eventId, error } = await createCalendarEvent(auth.accessToken, meetingWithZoomLink, calId, zoomLink); + const { id: eventId, error } = await createCalendarEvent(auth.accessToken, meetingWithZoomLink, calId, zoomLink, family); if (eventId) zoomCalendarEventId = eventId; else { zoomSynced = false; @@ -198,6 +203,7 @@ const syncMeeting = async (request: Request): Promise => { auth.accessToken, { ...meetingForCalendar, zoomLink }, existingEventIds, + await loadFamily(zid), ); googleSyncStatus = result.allSynced ? 'synced' : 'error'; diff --git a/frontend/app/api/write/meeting/route.ts b/frontend/app/api/write/meeting/route.ts index 69ae0877..7f511cbc 100644 --- a/frontend/app/api/write/meeting/route.ts +++ b/frontend/app/api/write/meeting/route.ts @@ -7,6 +7,7 @@ import { createZoomMeeting, getZoomHostCapacities, getZoomMeetingInvitation, res import { findResourceConflicts, findResourceConflictRows, ConflictRow, ResourceConflictAbort } from "../../../../util/meetings/resourceOverlap"; import { lockResourceClaims, ResourceClaim } from "../../../../util/meetings/resourceLocks"; import { meetingSchema } from "../../../../util/meetings/meetingValidation"; +import { linkedFamilyLoader } from "../../../../util/meetings/linkedSchedules"; import { calculateEndDateFromOccurrences } from "../../../../util/meetings/meetingOccurrences"; import { prisma } from "../../../../lib/prisma"; @@ -45,13 +46,21 @@ async function syncNewMeeting( let zoomCalendarEventId: string | null = null; let zoomSynced = true; let zoomSyncError: string | null = null; + // One family lookup for this whole sync, shared by the Zoom create and every calendar publish + // below -- the family names the Zoom meeting and each member's calendar event alike, so a + // second lookup could only disagree with the first. + const loadFamily = linkedFamilyLoader(prisma, mid); if (zoomEnabled && !zid && !zoomLink) { if (!zoomHost) { zoomSynced = false; zoomSyncError = hostSyncError ?? "No Zoom host available for this meeting's schedule (pool exhausted)."; } else { - const created = await createZoomMeeting({ ...meetingData, isRecurring }, zoomHost); + // The row is already committed by the time this runs, so the family lookup sees every + // schedule this meeting was created with -- one Zoom meeting is minted for the whole + // family, with its union schedule and its family Zoom name. + const family = await loadFamily(null); + const created = await createZoomMeeting({ ...meetingData, isRecurring }, zoomHost, family); if (created) { zid = created.zid; zoomLink = created.zoomLink; @@ -95,8 +104,9 @@ async function syncNewMeeting( let syncError: string | null = unconfiguredCat ? `Calendar for "${unconfiguredCat}" is not configured.` : null; + const family = await loadFamily(zid); for (const [cat, calId] of Object.entries(calendarIds)) { - const { id, error } = await createCalendarEvent(accessToken, meetingForSync, calId); + const { id, error } = await createCalendarEvent(accessToken, meetingForSync, calId, undefined, family); if (id) eventIds[cat] = id; else syncError = syncError ?? error; } @@ -126,7 +136,7 @@ async function syncNewMeeting( if (accessToken && zoomLink && meetingData.zoomRoom) { const calId = zoomRoomCalendarId[meetingData.zoomRoom]; if (calId) { - const { id: eventId, error } = await createCalendarEvent(accessToken, { ...meetingForSync, zoomLink }, calId, zoomLink); + const { id: eventId, error } = await createCalendarEvent(accessToken, { ...meetingForSync, zoomLink }, calId, zoomLink, await loadFamily(zid)); if (eventId) zoomCalendarEventId = eventId; else { zoomSynced = false; diff --git a/frontend/scripts/zoom-scan.ts b/frontend/scripts/zoom-scan.ts index 718478ab..a359fdbc 100644 --- a/frontend/scripts/zoom-scan.ts +++ b/frontend/scripts/zoom-scan.ts @@ -18,6 +18,7 @@ // (the condition neutralizes services/zoom.ts's "server-only" guard outside Next). import { PrismaClient } from "@prisma/client"; import { getZoomMeetingCredentials, updateZoomMeeting } from "../services/zoom"; +import { getZoomScheduleFamily } from "../util/meetings/linkedSchedules"; import type { IMeeting } from "../types/models"; const databaseUrl = process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL; @@ -68,15 +69,18 @@ async function main(): Promise { } } - // updateZoomMeeting receives the OTHER rows sharing this zid, so a shared meeting's PATCH - // sends the union of every row's schedule rather than one row's narrowed view (#513). + // updateZoomMeeting receives the representative's whole linked-schedule family (which also + // covers every row sharing this zid), so a shared meeting's PATCH sends the union of every + // row's schedule rather than one row's narrowed view (#513) -- and, just as importantly, + // recomputes the same family Zoom name the app's own PATCH paths do, so the monthly scan + // can never rename a linked family's meeting back to a single schedule's name. const representative = group.find((m) => m.zoomManaged && m.isRecurring && m.status !== "Suspended") ?? null; if (representative) { - const siblings = group.filter((m) => m !== representative); + const family = await getZoomScheduleFamily(prisma, representative.mid, zid); const ok = await updateZoomMeeting(zid, { ...representative, recurrencePattern: representative.recurrencePattern ?? null, - } as unknown as IMeeting, siblings as unknown as IMeeting[]); + } as unknown as IMeeting, family); if (ok) extended++; else { failures++; diff --git a/frontend/services/googleCalendar.ts b/frontend/services/googleCalendar.ts index 82614cb0..49bb39bd 100644 --- a/frontend/services/googleCalendar.ts +++ b/frontend/services/googleCalendar.ts @@ -2,6 +2,7 @@ import "server-only"; import { google } from "googleapis"; import { IMeeting, IRecurrencePattern } from "../types/models"; import { getETDayBounds, convertETToUTC } from "../util/date/timeUtils"; +import { buildLinkedScheduleLabel, LINKED_SCHEDULE_MODE_LABEL } from "../util/meetings/linkedSchedules"; export const calendarIdForCategory: Record = { AA: process.env.GOOGLE_CALENDAR_AA ?? "", @@ -109,29 +110,41 @@ function formatExdateCompact(occurrenceDate: Date | string, meetingStartDateTime return `${etDateCompact}T${get('hour')}${get('minute')}${get('second')}`; } -// Suffix appended to the GCal event title so the mode is visible at a glance; -// "Remote" reads as "Zoom Only" since ICR's meetings are never fully unattended. -const modeTitleSuffix: Record = { - Hybrid: "Hybrid", - "In Person": "In Person", - Remote: "Zoom Only", -}; - // MEETING_LOCATION used for public-facing Google Calendars only, // Zoom Room calendars pass their own join link. const MEETING_LOCATION = "518 W Seneca St, Ithaca, NY 14850"; -function buildEventTitle(meeting: IMeeting): string { - const suffix = modeTitleSuffix[meeting.modeType]; - return suffix ? `${meeting.title} - ${suffix}` : meeting.title; +// A lone meeting's event title names its own mode, "In Person" included: unlike a Zoom topic, +// a calendar event exists for an in-person meeting and its mode is exactly what a reader needs. +// Passed explicitly rather than left to buildLinkedScheduleLabel's default, so both services +// state the one thing they disagree about at their own call site (cf. ZOOM_SINGLE_TOPIC_SUFFIX). +const CALENDAR_SINGLE_TITLE_SUFFIX = LINKED_SCHEDULE_MODE_LABEL; + +// The event title carries the meeting's mode ("… - Zoom Only") so it's visible at a glance on a +// public calendar. A meeting run as a linked-schedule family gets that family's full name -- +// "One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat" -- on EVERY member's event, not just the +// segment describing that member: each schedule keeps its own event with its own dates and +// RRULE, but a reader landing on either one sees the same meeting described the same way, which +// is also exactly the name the family's shared Zoom meeting carries. Nothing pins a calendar +// title the way zoomTopic pins a Zoom topic, so there's no verbatim-name escape hatch here. +// +// TODO(linked-schedules PR3): the union title is recomputed only for the row being written, so +// adding or removing a family member leaves the OTHER members' events on their previous title +// until each is itself edited or retry-synced. PR3's family-wide fan-out has to republish every +// member's calendar events alongside its updateZoomMeeting, not just the Zoom half. +function buildEventTitle(meeting: IMeeting, family: IMeeting[]): string { + return buildLinkedScheduleLabel(meeting.title, meeting, family, CALENDAR_SINGLE_TITLE_SUFFIX); } +// family: the meeting's linked-schedule family (util/meetings/linkedSchedules.ts), for the +// title above. Defaulted so the many callers with no family concept -- suspension resume, +// delete-route rewrites -- stay unchanged. // locationOverride: Zoom Room calendars pass the join link here — Zoom Rooms detects a // joinable meeting from the location field, not the description. // Exported for direct unit testing of the RRULE/EXDATE serialization -- this is the single // place a RecurrencePattern turns into a Google Calendar body, so its output shape is worth // testing without going through the network-calling functions below. -export function buildEventBody(meeting: IMeeting, locationOverride?: string) { +export function buildEventBody(meeting: IMeeting, family: IMeeting[] = [], locationOverride?: string) { const descriptionLines = [ meeting.calType?.length ? `Type: ${meeting.calType.join(', ')}` : null, meeting.modeType ? `Mode: ${meeting.modeType}` : null, @@ -144,7 +157,7 @@ if (meeting.description) { } const event: Record = { - summary: buildEventTitle(meeting), + summary: buildEventTitle(meeting, family), description: descriptionLines.join("\n"), location: locationOverride ?? MEETING_LOCATION, start: { dateTime: new Date(meeting.startDateTime).toISOString(), timeZone: "America/New_York" }, @@ -168,17 +181,23 @@ if (meeting.description) { return event; } +// family (trailing, optional, like every other write below): the meeting's linked-schedule +// family, so the event title names the whole family. Trailing rather than beside `meeting` +// because most call sites -- resume, suspension, delete-route rewrites -- have no family in +// hand, and a positional parameter they'd all have to pass `[]` to is only a chance to get the +// argument order wrong. export async function createCalendarEvent( accessToken: string, meeting: IMeeting, calendarId: string, locationOverride?: string, + family: IMeeting[] = [], ): Promise<{ id: string | null; error: string | null }> { try { const calendar = getCalendarClient(accessToken); const res = await calendar.events.insert({ calendarId, - requestBody: buildEventBody(meeting, locationOverride), + requestBody: buildEventBody(meeting, family, locationOverride), }); return { id: res.data.id ?? null, error: null }; } catch (error) { @@ -193,13 +212,14 @@ export async function updateCalendarEvent( meeting: IMeeting, calendarId: string, locationOverride?: string, + family: IMeeting[] = [], ): Promise<{ ok: boolean; error: string | null }> { try { const calendar = getCalendarClient(accessToken); await calendar.events.update({ calendarId, eventId: googleCalendarEventId, - requestBody: buildEventBody(meeting, locationOverride), + requestBody: buildEventBody(meeting, family, locationOverride), }); return { ok: true, error: null }; } catch (error) { @@ -211,11 +231,14 @@ export async function updateCalendarEvent( // Reconciles a meeting's Google Calendar events against its current calType: // removes events for calendars no longer selected, updates events for calendars // still selected, and creates events for newly selected calendars. Used by both -// the update route (on every edit) and the sync route (manual "Retry sync"). +// the update route (on every edit) and the sync route (manual "Retry sync"), which pass the +// same linked-schedule family they hand Zoom in that request so the event title and the Zoom +// topic are derived from one lookup and can't disagree. export async function reconcileMeetingCalendars( accessToken: string, meeting: IMeeting, existingEventIds: Record, + family: IMeeting[] = [], ): Promise<{ updatedEventIds: Record; allSynced: boolean; googleSyncError: string | null }> { const calendarIds = calendarIdsForMeeting(meeting.calType ?? []); const updatedEventIds: Record = { ...existingEventIds }; @@ -259,13 +282,13 @@ export async function reconcileMeetingCalendars( for (const [cat, calId] of Object.entries(calendarIds)) { const existingId = existingEventIds[cat]; if (existingId) { - const { ok, error } = await updateCalendarEvent(accessToken, existingId, meeting, calId); + const { ok, error } = await updateCalendarEvent(accessToken, existingId, meeting, calId, undefined, family); if (!ok) { allSynced = false; recordError(error ?? "Failed to update the calendar event."); } } else { - const { id: newId, error } = await createCalendarEvent(accessToken, meeting, calId); + const { id: newId, error } = await createCalendarEvent(accessToken, meeting, calId, undefined, family); if (newId) updatedEventIds[cat] = newId; else { allSynced = false; diff --git a/frontend/services/zoom.ts b/frontend/services/zoom.ts index 483927af..91733ee2 100644 --- a/frontend/services/zoom.ts +++ b/frontend/services/zoom.ts @@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client"; import { IMeeting } from "../types/models"; import { expandOccurrences, findResourceConflicts, findFirstFreePoolHost, getPoolHostLoads, OccurrenceInput } from "../util/meetings/resourceOverlap"; import { isSharedZoomScheduleCompatible } from "../util/meetings/sharedZoomSchedule"; +import { buildLinkedScheduleLabel, isZoomBearing, resolveFamilyRows } from "../util/meetings/linkedSchedules"; import { prisma } from "../lib/prisma"; const ZOOM_BASE_API = process.env.NEXT_PUBLIC_ZOOM_BASE_API ?? "https://api.zoom.us/v2"; @@ -247,19 +248,28 @@ const ZOOM_WEEKDAY: Record = { Sunday: 1, Monday: 2, Tuesday: 3, Wednesday: 4, Thursday: 5, Friday: 6, Saturday: 7, }; +// The family members Zoom's one schedule actually has to cover, besides `meeting` itself. An +// In-Person member is deliberately excluded: it holds no zid, and unioning its weekdays into +// weekly_days would advertise Zoom occurrences for a schedule that never meets online. +function zoomScheduleSiblings(meeting: IMeeting, family: IMeeting[]): IMeeting[] { + return resolveFamilyRows(meeting, family) + .filter((row) => row.mid !== meeting.mid && isZoomBearing(row)); +} + // Maps the app's recurrence pattern onto Zoom's recurrence object so a recurring series is a // real type-8 recurring meeting on Zoom (visible as a series in the host's portal). An // unbounded series sends end_times: 0 -- undocumented but exactly what the Zoom portal itself // stores for its own "no end" meetings (verified empirically 2026-08-20; Zoom's PATCH path // clamps it to a ~2-year rolling horizon, which future edits keep pushing forward). -// Some Zoom meetings serve several platform rows at once (one shared zid -- e.g. a Hybrid -// M-Sat row and a Remote Sunday row on one legacy meeting). Zoom holds ONE schedule, so a -// PATCH built from a single row would silently narrow it to that row's days (#513); with -// siblings supplied, the recurrence is instead the union of every row's weekdays -- valid only -// when all rows are weekly at the same interval, time-of-day, and duration, which is how the -// shared legacy meetings were built. "incompatible" tells the caller to leave Zoom's schedule -// untouched rather than mangle it. -function buildZoomRecurrence(meeting: IMeeting, siblings: IMeeting[] = []): Record | null | "incompatible" { +// Some Zoom meetings serve several platform rows at once (one linked-schedule family -- e.g. a +// Hybrid M-Sat row and a Remote Sunday row on one legacy meeting). Zoom holds ONE schedule, so a +// PATCH built from a single row would silently narrow it to that row's days (#513); with the +// family supplied, the recurrence is instead the union of its Zoom-bearing rows' weekdays -- +// valid only when all of them are weekly at the same interval, time-of-day, and duration, which +// is how the shared legacy meetings were built. "incompatible" tells the caller to leave Zoom's +// schedule untouched rather than mangle it. +function buildZoomRecurrence(meeting: IMeeting, family: IMeeting[] = []): Record | null | "incompatible" { + const siblings = zoomScheduleSiblings(meeting, family); if (!meeting.isRecurring || !meeting.recurrencePattern) return siblings.length ? "incompatible" : null; if (siblings.length > 0) { const rows = [meeting, ...siblings]; @@ -297,11 +307,12 @@ function buildZoomRecurrence(meeting: IMeeting, siblings: IMeeting[] = []): Reco // anchor: Zoom silently rewrites a past start_time to "now" (verified 2026-08-20), and a // backdated anchor (e.g. a lease-year import) would otherwise file the meeting under the // host's past meetings. -function nextOccurrenceStart(meeting: IMeeting, siblings: IMeeting[] = []): Date { +function nextOccurrenceStart(meeting: IMeeting, family: IMeeting[] = []): Date { if (!meeting.isRecurring || !meeting.recurrencePattern) return new Date(meeting.startDateTime); const now = new Date(); const horizon = new Date(now.getTime() + 370 * 24 * 60 * 60 * 1000); // A shared meeting's next occurrence is the earliest across ALL rows it serves. + const siblings = zoomScheduleSiblings(meeting, family); const candidates = [meeting, ...siblings.filter((m) => m.isRecurring && m.recurrencePattern)] .map((m) => expandOccurrences( { ...m, recurrencePattern: m.recurrencePattern } as Parameters[0], @@ -312,39 +323,47 @@ function nextOccurrenceStart(meeting: IMeeting, siblings: IMeeting[] = []): Date return candidates.reduce((a, b) => (a < b ? a : b)); } +// A lone meeting's Zoom topic names only Hybrid and Remote. "In Person" is deliberately absent: +// an in-person meeting has no Zoom meeting of its own to name, and only ever appears in a topic +// as one segment of a family that also meets online. +const ZOOM_SINGLE_TOPIC_SUFFIX: Record = { + Hybrid: "Hybrid", + Remote: "Zoom Only", +}; + // ICR's own Zoom naming convention, applied when no explicit zoomTopic is pinned. Adopted // legacy meetings carry a pinned zoomTopic instead (their verbatim pre-app names) so an -// app-side edit can never rename them implicitly. -function zoomTopicFor(meeting: IMeeting): string { +// app-side edit can never rename them implicitly. Nothing writes the derived topic back into +// Meeting.zoomTopic: a null column keeps meaning "auto, recompute from the current family." +function zoomTopicFor(meeting: IMeeting, family: IMeeting[] = []): string { if (meeting.zoomTopic) return meeting.zoomTopic; - const suffix = meeting.modeType === "Remote" ? " - Zoom Only" : meeting.modeType === "Hybrid" ? " - Hybrid" : ""; - return `${meeting.title}${suffix}`; + return buildLinkedScheduleLabel(meeting.title, meeting, family, ZOOM_SINGLE_TOPIC_SUFFIX); } -function buildZoomMeetingBody(meeting: IMeeting, scheduleSiblings: IMeeting[] = []) { +function buildZoomMeetingBody(meeting: IMeeting, family: IMeeting[] = []) { const durationMinutes = Math.round( (new Date(meeting.endDateTime).getTime() - new Date(meeting.startDateTime).getTime()) / 60000, ); - const recurrence = buildZoomRecurrence(meeting, scheduleSiblings); + const recurrence = buildZoomRecurrence(meeting, family); if (recurrence === "incompatible") { // Divergent shared rows can't be one fixed-time series -- send a schedule-neutral body // (content only) so the PATCH can't narrow whatever union Zoom currently holds (#513). console.error(`Zoom shared-schedule rows for "${meeting.title}" diverged; leaving Zoom's schedule untouched`); return { - topic: zoomTopicFor(meeting), + topic: zoomTopicFor(meeting, family), duration: durationMinutes, agenda: meeting.description, settings: { host_video: true, participant_video: true, join_before_host: true }, }; } return { - topic: zoomTopicFor(meeting), + topic: zoomTopicFor(meeting, family), // Recurring series are real recurring meetings on Zoom (type 8, usually endless via // end_times: 0) -- one stable meeting ID across all occurrences, now with the schedule // visible in the host's portal. One-time meetings stay plain scheduled (type 2). type: recurrence ? 8 : 2, ...(recurrence ? { recurrence } : {}), - start_time: toZoomStartTime(recurrence ? nextOccurrenceStart(meeting, scheduleSiblings) : new Date(meeting.startDateTime)), + start_time: toZoomStartTime(recurrence ? nextOccurrenceStart(meeting, family) : new Date(meeting.startDateTime)), duration: durationMinutes, timezone: "America/New_York", agenda: meeting.description, @@ -352,7 +371,10 @@ function buildZoomMeetingBody(meeting: IMeeting, scheduleSiblings: IMeeting[] = }; } -export async function createZoomMeeting(meeting: IMeeting, hostEmail: string): Promise<{ zoomLink: string; zid: string; zoomPasscode: string | null } | null> { +// `family`: every live row of this meeting's linked-schedule family (getLinkedFamily), so the +// very first Zoom meeting is already minted with the union schedule and the family's topic -- +// a family is only ever served by ONE Zoom meeting, created once. +export async function createZoomMeeting(meeting: IMeeting, hostEmail: string, family: IMeeting[] = []): Promise<{ zoomLink: string; zid: string; zoomPasscode: string | null } | null> { try { const token = await getZoomAccessToken(); if (!token) return null; @@ -361,7 +383,7 @@ export async function createZoomMeeting(meeting: IMeeting, hostEmail: string): P const res = await fetch(`${ZOOM_BASE_API}/users/${encodeURIComponent(hostEmail)}/meetings`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(buildZoomMeetingBody(meeting)), + body: JSON.stringify(buildZoomMeetingBody(meeting, family)), }); invalidateZoomTokenIfUnauthorized(res); if (!res.ok) { @@ -428,13 +450,14 @@ export async function getZoomMeetingCredentials(zid: string): Promise<{ passcode } } -export async function updateZoomMeeting(zid: string, meeting: IMeeting, scheduleSiblings: IMeeting[] = []): Promise { +export async function updateZoomMeeting(zid: string, meeting: IMeeting, family: IMeeting[] = []): Promise { // Managed recurring meetings mirror their real schedule to Zoom (type 8 + recurrence, built // from the same pattern the app/calendars use) -- each successful PATCH also re-extends // Zoom's ~2-year rolling occurrence horizon. Unmanaged meetings never reach this function // (gated at every call site); their Zoom-side schedule is the owner's business. - // scheduleSiblings: the OTHER active rows sharing this zid (shared legacy meetings) -- the - // schedule sent is then the union of all rows, never one row's narrowed view (#513). + // family: this meeting's linked-schedule family (getLinkedFamily) -- the schedule sent is the + // union of its Zoom-bearing rows, never one row's narrowed view (#513), and the topic is + // recomputed from the family on every PATCH. try { const token = await getZoomAccessToken(); @@ -443,7 +466,7 @@ export async function updateZoomMeeting(zid: string, meeting: IMeeting, schedule const res = await fetch(`${ZOOM_BASE_API}/meetings/${zid}`, { method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - body: JSON.stringify(buildZoomMeetingBody(meeting, scheduleSiblings)), + body: JSON.stringify(buildZoomMeetingBody(meeting, family)), }); invalidateZoomTokenIfUnauthorized(res); if (!res.ok) console.error("Zoom updateMeeting error:", await res.text()); diff --git a/frontend/tests/integration/resume-meeting-route.test.ts b/frontend/tests/integration/resume-meeting-route.test.ts index 0fa4308a..df74ce7d 100644 --- a/frontend/tests/integration/resume-meeting-route.test.ts +++ b/frontend/tests/integration/resume-meeting-route.test.ts @@ -83,9 +83,11 @@ test("resuming early discards the pre-created future series instead of promoting const response = await POST(request); expect(response.status).toBe(200); - await waitFor(async () => (mockedDelete.mock.calls.length > 0 ? true : null)); + // Both halves of the background sync, not just the teardown: the fresh series is created + // after a family lookup, so waiting only on the delete would race the create. + await waitFor(async () => + mockedDelete.mock.calls.length > 0 && mockedCreate.mock.calls.length > 0 ? true : null); expect(mockedDelete).toHaveBeenCalledWith("fake-token", "pending-future-event-id", "fake-calendar-id"); - expect(mockedCreate).toHaveBeenCalled(); }); test("resuming a meeting that isn't currently suspended returns 400", async () => { diff --git a/frontend/tests/integration/update-meeting-route.test.ts b/frontend/tests/integration/update-meeting-route.test.ts index 102e4632..21df82c4 100644 --- a/frontend/tests/integration/update-meeting-route.test.ts +++ b/frontend/tests/integration/update-meeting-route.test.ts @@ -286,7 +286,7 @@ test("a pure Zoom Room change on a MANAGED meeting moves in place -- keeps zid/l // The join-link event moved: deleted off the old room's calendar, created on the new one. expect(mockedDeleteCalendarEvent).toHaveBeenCalledWith("fake-token", "old-managed-room-event", "cal-managed-room-1"); expect(mockedCreateCalendarEvent).toHaveBeenCalledWith( - "fake-token", expect.anything(), "cal-managed-room-2", "https://zoom.us/j/managed1", + "fake-token", expect.anything(), "cal-managed-room-2", "https://zoom.us/j/managed1", expect.any(Array), ); expect(stored?.zoomCalendarEventId).toBe("new-managed-room-event"); }); @@ -333,7 +333,7 @@ test("a shared-zid meeting's pure Zoom Room change also just moves calendars, wi expect(stored?.zid).toBe(sharedZid); expect(mockedDeleteCalendarEvent).toHaveBeenCalledWith("fake-token", "old-shared-room-event", "cal-shared-room-1"); expect(mockedCreateCalendarEvent).toHaveBeenCalledWith( - "fake-token", expect.anything(), "cal-shared-room-2", "https://zoom.us/j/sharedroom", + "fake-token", expect.anything(), "cal-shared-room-2", "https://zoom.us/j/sharedroom", expect.any(Array), ); // The sibling itself is untouched -- this was never about the sibling's own zoomRoom (it has @@ -385,7 +385,7 @@ test("a room change combined with a genuine recreate reason (explicit host chang // one is still cleaned up first. expect(mockedDeleteCalendarEvent).toHaveBeenCalledWith("fake-token", "old-combined-room-event", "cal-combined-room-1"); expect(mockedCreateCalendarEvent).toHaveBeenCalledWith( - "fake-token", expect.anything(), "cal-combined-room-2", "http://zoom.test/combined", + "fake-token", expect.anything(), "cal-combined-room-2", "http://zoom.test/combined", expect.any(Array), ); }); @@ -1019,12 +1019,20 @@ test("editing a meeting whose scheduled resume date has already passed promotes const response = await PUT(request); expect(response.status).toBe(200); + // The reconcile runs in the background sync, so wait for its persisted status rather than + // asserting straight off the response -- otherwise this races the sync's own DB reads. + await waitFor(async () => { + const row = await prisma.meeting.findUnique({ where: { mid } }); + return row?.googleSyncStatus != null ? row : null; + }); + // reconcileMeetingCalendars must have been called against the promoted pointer // (resume-event-id), not the stale pre-suspend one still sitting in the DB row. expect(mockedReconcileMeetingCalendars).toHaveBeenCalledWith( "fake-token", expect.anything(), { AA: "resume-event-id" }, + expect.any(Array), ); const suspension = await prisma.suspensionPeriod.findFirst({ where: { mid } }); diff --git a/frontend/tests/integration/update-meeting-scoped-edit.test.ts b/frontend/tests/integration/update-meeting-scoped-edit.test.ts index 5f7cdd50..9fa8fa7f 100644 --- a/frontend/tests/integration/update-meeting-scoped-edit.test.ts +++ b/frontend/tests/integration/update-meeting-scoped-edit.test.ts @@ -714,6 +714,14 @@ describe("scoped edit Zoom Room moves", () => { modeType: "Hybrid", room: meeting.room, zoomRoom: "Scoped Zoom Room 2", confirmOverride: true, })); expect(overridden.status).toBe(200); + + // Drains the parent's background calendar rewrite before the test ends -- nothing above + // needs it, but a still-in-flight updateCalendarEvent would otherwise land inside the next + // test and break its not.toHaveBeenCalled() assertion. + await waitFor(async () => { + const parent = await getTestPrismaClient().meeting.findUnique({ where: { mid: meeting.mid } }); + return parent?.googleSyncStatus != null ? true : null; + }); }); test("a suspended parent's scoped edit never rewrites its room-cal event either", async () => { diff --git a/frontend/tests/integration/update-meeting-sync-route.test.ts b/frontend/tests/integration/update-meeting-sync-route.test.ts index 11e78772..662abe9e 100644 --- a/frontend/tests/integration/update-meeting-sync-route.test.ts +++ b/frontend/tests/integration/update-meeting-sync-route.test.ts @@ -89,6 +89,7 @@ test("a retry that newly succeeds at getting a host both creates the Zoom meetin "fake-token", expect.objectContaining({ zoomLink: "http://zoom.test/retried" }), {}, + expect.any(Array), ); const afterRetry = await prisma.meeting.findUnique({ where: { mid: meetingData.mid } }); @@ -249,7 +250,7 @@ describe("retrying an already-synced meeting (existing zid)", () => { expect(mockedReconcileMeetingCalendars).toHaveBeenCalled(); }); - test("a shared-zid meeting's retry hands its sibling rows to the Zoom PATCH so the union schedule is sent (#513)", async () => { + test("a shared-zid meeting's retry hands the whole family to the Zoom PATCH so the union schedule is sent (#513)", async () => { mockedUpdateZoomMeeting.mockResolvedValue(true); mockedReconcileMeetingCalendars.mockResolvedValue({ updatedEventIds: {}, allSynced: true, googleSyncError: null }); @@ -266,10 +267,12 @@ describe("retrying an already-synced meeting (existing zid)", () => { })); expect(mockedUpdateZoomMeeting).toHaveBeenCalledTimes(1); - const [zidArg, , siblingsArg] = mockedUpdateZoomMeeting.mock.calls[0]; + const [zidArg, , familyArg] = mockedUpdateZoomMeeting.mock.calls[0]; expect(zidArg).toBe(shared); - expect(siblingsArg).toHaveLength(1); - expect(siblingsArg[0].mid).toBe(siblingData.mid); + // The family the Zoom body is built from is every live row the meeting serves, the retried + // row included -- the service replaces that row with the in-flight copy it was handed. + expect((familyArg as { mid: string }[]).map((row) => row.mid).sort()) + .toEqual([meetingData.mid, siblingData.mid].sort()); }); test("a retry adopts a portal-side passcode/link change: fresh credentials are stored and the reconcile publishes the live link", async () => { @@ -294,6 +297,7 @@ describe("retrying an already-synced meeting (existing zid)", () => { "fake-token", expect.objectContaining({ zoomLink: "http://zoom.test/existing?pwd=rotated" }), expect.anything(), + expect.any(Array), ); const after = await prisma.meeting.findUnique({ where: { mid: meetingData.mid } }); expect(after?.zoomPasscode).toBe("rotated"); diff --git a/frontend/tests/integration/write-meeting-route.test.ts b/frontend/tests/integration/write-meeting-route.test.ts index 5e28669e..b123cd4b 100644 --- a/frontend/tests/integration/write-meeting-route.test.ts +++ b/frontend/tests/integration/write-meeting-route.test.ts @@ -502,6 +502,9 @@ test("a Remote meeting (no zoomRoom) still gets a Zoom meeting created, and its "fake-token", expect.objectContaining({ zoomLink: "http://zoom.test/remote" }), "fake-calendar-id", + undefined, + // The linked-schedule family, for the event title -- a family of one here. + [expect.objectContaining({ mid: payload.mid })], ); }); @@ -717,6 +720,11 @@ test("a recurring meeting creates its Meeting and RecurrencePattern together (on expect(meetingRow?.isRecurring).toBe(true); expect(patternRow).not.toBeNull(); expect(patternRow?.daysOfWeek).toEqual(["Monday"]); + + // Drains this meeting's background sync before the test ends -- nothing above needs its + // result, but a still-in-flight createCalendarEvent would otherwise land inside the next + // test and count against its own call assertions. + await waitForGoogleSyncStatus(payload.mid); }); test("a category with no configured calendar fails the meeting's sync, even if its other category succeeds", async () => { diff --git a/frontend/tests/unit/googleCalendar.test.ts b/frontend/tests/unit/googleCalendar.test.ts index 7e8956c1..5dc866ab 100644 --- a/frontend/tests/unit/googleCalendar.test.ts +++ b/frontend/tests/unit/googleCalendar.test.ts @@ -167,6 +167,71 @@ describe("toRRule — monthly", () => { }); }); +// The event `summary` is the only name a member of the public ever sees for this meeting, so +// these lock in both halves of it: the mode suffix every ordinary meeting has carried since +// launch, and the family name a linked-schedule meeting carries on every one of its events. +describe("buildEventBody — event title", () => { + const familyRow = (mid: string, modeType: string, daysOfWeek: string[]): IMeeting => buildMeeting({ + mid, + modeType, + title: "One Day at a Time", + isRecurring: true, + recurrencePattern: { ...base, daysOfWeek }, + }); + + it("names a lone meeting with its own mode suffix, byte-for-byte as it always has", () => { + expect(buildEventBody(buildMeeting({ modeType: "Hybrid" })).summary).toBe("Test Meeting - Hybrid"); + expect(buildEventBody(buildMeeting({ modeType: "In Person" })).summary).toBe("Test Meeting - In Person"); + // Remote reads as "Zoom Only" -- ICR's meetings are never fully unattended. + expect(buildEventBody(buildMeeting({ modeType: "Remote" })).summary).toBe("Test Meeting - Zoom Only"); + }); + + it("leaves an unrecognised mode's title bare rather than inventing a suffix", () => { + expect(buildEventBody(buildMeeting({ modeType: "Telepathic" })).summary).toBe("Test Meeting"); + }); + + it("keeps the lone-meeting suffix for a family of one, the shape almost every meeting has", () => { + const remote = familyRow("m-remote", "Remote", ["Monday"]); + expect(buildEventBody(remote, [remote]).summary).toBe("One Day at a Time - Zoom Only"); + }); + + it("names both schedules on every member's event, so the two calendars agree", () => { + const hybrid = familyRow("m-hybrid", "Hybrid", ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]); + const remote = familyRow("m-remote", "Remote", ["Saturday"]); + const family = [hybrid, remote]; + const expected = "One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"; + + // Each member keeps its own event with its own dates and RRULE, but both events are named + // after the whole family -- the same name the family's shared Zoom meeting carries. + expect(buildEventBody(hybrid, family).summary).toBe(expected); + expect(buildEventBody(remote, family).summary).toBe(expected); + }); + + it("orders segments Hybrid / In Person / Remote regardless of the family's own order", () => { + const inPerson = familyRow("m-inperson", "In Person", ["Saturday"]); + const remote = familyRow("m-remote", "Remote", ["Sunday"]); + + expect(buildEventBody(remote, [remote, inPerson]).summary) + .toBe("One Day at a Time - In Person Sat - Zoom Only Sun"); + }); + + it("keeps the lone-meeting name when the other rows are the same mode (a scoped edit's split children)", () => { + const parent = familyRow("m-parent", "Hybrid", ["Monday"]); + const child = familyRow("m-child", "Hybrid", ["Monday"]); + + expect(buildEventBody(parent, [parent, child]).summary).toBe("One Day at a Time - Hybrid"); + }); + + it("names the in-flight edit's days, not the copy of it still stored in the family", () => { + const stored = familyRow("m-hybrid", "Hybrid", ["Monday"]); + const edited = familyRow("m-hybrid", "Hybrid", ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]); + const remote = familyRow("m-remote", "Remote", ["Saturday"]); + + expect(buildEventBody(edited, [stored, remote]).summary) + .toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + }); +}); + // buildEventBody is the single place a RecurrencePattern turns into a Google Calendar // event body -- every full events.insert/events.update (create, whole-series edit, Retry // sync, reconcile, pending-resume series creation) goes through it, so these cases are what diff --git a/frontend/tests/unit/linkedSchedules.test.ts b/frontend/tests/unit/linkedSchedules.test.ts index 4ed201ac..8f651342 100644 --- a/frontend/tests/unit/linkedSchedules.test.ts +++ b/frontend/tests/unit/linkedSchedules.test.ts @@ -3,12 +3,16 @@ import { LINKED_SCHEDULE_CAP, LINKED_SCHEDULE_MODES, availableModesFor, + buildLinkedScheduleLabel, canLinkSchedule, claimedDaysFor, familyMembers, getLinkedFamily, + isDetachedSplitChild, isZoomBearing, + resolveFamilyRows, type LinkedFamily, + type LinkedScheduleLabelRow, type LinkedScheduleRow, } from "../../util/meetings/linkedSchedules"; @@ -187,6 +191,131 @@ describe("claimedDaysFor", () => { }); }); +// --- the shared family label ------------------------------------------------------------- + +const WEEK = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +const labelRow = (mid: string, modeType: string, daysOfWeek?: string[]): LinkedScheduleLabelRow => ({ + mid, + modeType, + recurrencePattern: daysOfWeek ? { type: "weekly", daysOfWeek } : null, +}); + +describe("resolveFamilyRows", () => { + test("replaces the family's stored copy of the row being written with the in-flight one", () => { + const stored = labelRow("m-1", "Hybrid", ["Monday"]); + const inFlight = labelRow("m-1", "Hybrid", ["Monday", "Tuesday"]); + const sibling = labelRow("m-2", "Remote", ["Saturday"]); + + expect(resolveFamilyRows(inFlight, [stored, sibling])).toEqual([inFlight, sibling]); + }); + + test("adds the row when the caller passed only its siblings", () => { + const inFlight = labelRow("m-1", "Hybrid", ["Monday"]); + const sibling = labelRow("m-2", "Remote", ["Saturday"]); + + expect(resolveFamilyRows(inFlight, [sibling])).toEqual([inFlight, sibling]); + }); + + test("treats an empty family as a family of one", () => { + const inFlight = labelRow("m-1", "Hybrid", ["Monday"]); + expect(resolveFamilyRows(inFlight, [])).toEqual([inFlight]); + }); +}); + +describe("buildLinkedScheduleLabel", () => { + const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; + + test("names each mode with its own days, in the fixed mode order", () => { + const hybrid = labelRow("m-hybrid", "Hybrid", weekdays); + const remote = labelRow("m-remote", "Remote", ["Saturday"]); + const expected = "One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"; + + // Same name from either member, and regardless of how the family came back from the + // database -- nothing about it may depend on which row triggered the write. + expect(buildLinkedScheduleLabel("One Day at a Time", hybrid, [hybrid, remote])).toBe(expected); + expect(buildLinkedScheduleLabel("One Day at a Time", remote, [remote, hybrid])).toBe(expected); + }); + + test("names an In-Person member, which holds no Zoom identity of its own", () => { + const inPerson = labelRow("m-inperson", "In Person", ["Saturday"]); + const remote = labelRow("m-remote", "Remote", ["Sunday"]); + + expect(buildLinkedScheduleLabel("Weekend Al-Anon", remote, [inPerson, remote])) + .toBe("Weekend Al-Anon - In Person Sat - Zoom Only Sun"); + }); + + test("collapses a member meeting every day to 'Daily', like every other schedule display", () => { + const hybrid = labelRow("m-hybrid", "Hybrid", [...WEEK]); + const inPerson = labelRow("m-inperson", "In Person", ["Saturday"]); + + expect(buildLinkedScheduleLabel("Early Bird Group", hybrid, [hybrid, inPerson])) + .toBe("Early Bird Group - Hybrid Daily - In Person Sat"); + }); + + test("falls back to the lone-meeting suffix below two distinct modes", () => { + const hybrid = labelRow("m-hybrid", "Hybrid", weekdays); + // Several rows of one mode -- a scoped edit's split children, a legacy zid group -- are not + // a linked family, and must keep the plain name they have today. + const splitChild = labelRow("m-split", "Hybrid", weekdays); + + expect(buildLinkedScheduleLabel("Early Bird Group", hybrid, [hybrid, splitChild])) + .toBe("Early Bird Group - Hybrid"); + // No family supplied at all is the same case -- the row names only itself. + expect(buildLinkedScheduleLabel("Early Bird Group", hybrid, [])).toBe("Early Bird Group - Hybrid"); + }); + + test("takes the lone-meeting suffix from the caller, which is the one thing the services differ on", () => { + const inPerson = labelRow("m-1", "In Person", ["Monday"]); + + // A calendar event says so; a Zoom topic can't, since an in-person meeting has no Zoom + // meeting to name -- so Zoom passes a map without it. + expect(buildLinkedScheduleLabel("Early Bird Group", inPerson, [inPerson])) + .toBe("Early Bird Group - In Person"); + expect(buildLinkedScheduleLabel("Early Bird Group", inPerson, [inPerson], { Hybrid: "Hybrid", Remote: "Zoom Only" })) + .toBe("Early Bird Group"); + }); + + test("names a family member with no recurrence pattern at all rather than dropping it", () => { + const hybrid = labelRow("m-hybrid", "Hybrid", weekdays); + const oneTime = labelRow("m-remote", "Remote"); + + expect(buildLinkedScheduleLabel("Early Bird Group", hybrid, [hybrid, oneTime])) + .toBe("Early Bird Group - Hybrid Mon-Fri - Zoom Only One-time"); + }); + + test("never names a detached split child, whatever mode it was later given", () => { + const hybrid = { ...labelRow("m-hybrid", "Hybrid", weekdays), isRecurring: true }; + // A "this occurrence" split-off that was afterwards edited to a different mode: it shares + // the zid, so it reaches the family, but it is a one-off, not a schedule of its own. + const detached = { ...labelRow("m-split", "Remote"), isRecurring: false, splitFromMid: "m-hybrid" }; + + expect(buildLinkedScheduleLabel("One Day at a Time", hybrid, [hybrid, detached])) + .toBe("One Day at a Time - Hybrid"); + // Its own edit names only itself too -- the in-flight payload carries no lineage fields, so + // the exclusion has to come from the stored copy in the family. + expect(buildLinkedScheduleLabel("One Day at a Time", labelRow("m-split", "Remote"), [hybrid, detached])) + .toBe("One Day at a Time - Zoom Only"); + }); + + test("still names a recurring tail split, which is a genuine ongoing schedule", () => { + const hybrid = { ...labelRow("m-hybrid", "Hybrid", weekdays), isRecurring: true }; + const tail = { ...labelRow("m-tail", "Remote", ["Saturday"]), isRecurring: true, splitFromMid: "m-hybrid" }; + + expect(buildLinkedScheduleLabel("One Day at a Time", hybrid, [hybrid, tail])) + .toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + }); +}); + +describe("isDetachedSplitChild", () => { + test("only a non-recurring row split off a parent is detached", () => { + expect(isDetachedSplitChild({ isRecurring: false, splitFromMid: "m-parent" })).toBe(true); + expect(isDetachedSplitChild({ isRecurring: true, splitFromMid: "m-parent" })).toBe(false); + expect(isDetachedSplitChild({ isRecurring: false, splitFromMid: null })).toBe(false); + expect(isDetachedSplitChild({ isRecurring: true })).toBe(false); + }); +}); + describe("isZoomBearing", () => { test("Hybrid and Remote rows need the family's Zoom meeting", () => { expect(isZoomBearing({ modeType: "Hybrid" })).toBe(true); diff --git a/frontend/tests/unit/suspension.test.ts b/frontend/tests/unit/suspension.test.ts index c8327e4b..914518b3 100644 --- a/frontend/tests/unit/suspension.test.ts +++ b/frontend/tests/unit/suspension.test.ts @@ -13,7 +13,9 @@ jest.mock("../../services/googleCalendar", () => ({ jest.mock("../../lib/prisma", () => ({ prisma: { $transaction: jest.fn(), - meeting: { update: jest.fn() }, + // findUnique/findMany back the linked-schedule family lookup createPendingResumeSeries does + // before it publishes: these meetings have no family, so the reads answer empty. + meeting: { update: jest.fn(), findUnique: jest.fn(async () => null), findMany: jest.fn(async () => []) }, suspensionPeriod: { update: jest.fn() }, }, })); @@ -312,6 +314,10 @@ describe("createPendingResumeSeries", () => { "token-1", expect.objectContaining({ mid: meeting.mid }), "cal-aa", + undefined, + // The linked-schedule family, so a pre-created resume series is published under the same + // name every other member's event carries. + [], ); expect(result).toEqual({ resumeEventIds: { AA: "evt-new" }, error: null }); }); diff --git a/frontend/tests/unit/zoom.test.ts b/frontend/tests/unit/zoom.test.ts index 1e4ce58f..269932a0 100644 --- a/frontend/tests/unit/zoom.test.ts +++ b/frontend/tests/unit/zoom.test.ts @@ -317,8 +317,11 @@ describe("rehostZoomMeeting (#516)", () => { }); describe("shared-zid union schedules (#513, via updateZoomMeeting's request body)", () => { + // Only Hybrid/Remote rows ever share a Zoom meeting -- an In-Person row holds no zid and is + // filtered out of the union (see the linked-family tests below). const weeklyRow = (days: string[], overrides: Partial = {}): IMeeting => buildMeeting({ isRecurring: true, + modeType: "Hybrid", recurrencePattern: { type: "weekly", startDate: new Date("2026-07-01T23:00:00.000Z"), endDate: null, daysOfWeek: days, firstDayOfWeek: "Sunday", interval: 1, @@ -329,7 +332,7 @@ describe("shared-zid union schedules (#513, via updateZoomMeeting's request body it("sends the union of all sharing rows' weekdays, not the edited row's alone", async () => { const { getCapturedBody } = mockFetchCapturingBody(); const edited = weeklyRow(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]); - const sunday = weeklyRow(["Sunday"], { mid: "m-2", startDateTime: new Date("2026-07-05T22:00:00.000Z"), endDateTime: new Date("2026-07-05T23:00:00.000Z") }); + const sunday = weeklyRow(["Sunday"], { mid: "m-2", modeType: "Remote", startDateTime: new Date("2026-07-05T22:00:00.000Z"), endDateTime: new Date("2026-07-05T23:00:00.000Z") }); await updateZoomMeeting("zid-shared", edited, [sunday]); @@ -357,3 +360,144 @@ describe("shared-zid union schedules (#513, via updateZoomMeeting's request body expect(body?.topic).toBeDefined(); }); }); + +// One meeting run as two linked schedules (util/meetings/linkedSchedules.ts) is served by ONE +// Zoom meeting, so its Zoom name has to say which mode meets on which days, and its recurrence +// must cover only the schedules that actually meet online. +describe("linked-schedule family topics and recurrence (via the outgoing request body)", () => { + const familyRow = (mid: string, modeType: string, days: string[], overrides: Partial = {}): IMeeting => + buildMeeting({ + mid, + modeType, + title: "One Day at a Time", + isRecurring: true, + startDateTime: new Date("2026-07-01T23:00:00.000Z"), + endDateTime: new Date("2026-07-02T00:00:00.000Z"), + recurrencePattern: { + type: "weekly", startDate: new Date("2026-07-01T23:00:00.000Z"), endDate: null, + daysOfWeek: days, firstDayOfWeek: "Sunday", interval: 1, + }, + ...overrides, + }); + + const hybridWeekdays = () => familyRow("m-hybrid", "Hybrid", ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]); + const remoteSaturday = () => familyRow("m-remote", "Remote", ["Saturday"]); + + it("names each mode with its own days for a Hybrid + Remote family", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const hybrid = hybridWeekdays(); + + await updateZoomMeeting("zid-shared", hybrid, [hybrid, remoteSaturday()]); + + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + }); + + it("names a Hybrid + In Person family, whose In-Person member holds no Zoom link of its own", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const hybrid = familyRow("m-hybrid", "Hybrid", ["Monday", "Tuesday", "Wednesday"]); + const inPerson = familyRow("m-inperson", "In Person", ["Thursday", "Friday"]); + + await updateZoomMeeting("zid-shared", hybrid, [hybrid, inPerson]); + + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Wed - In Person Thu-Fri"); + }); + + it("names an In Person + Remote family from the Remote member that holds the Zoom meeting", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const inPerson = familyRow("m-inperson", "In Person", ["Saturday"]); + const remote = familyRow("m-remote", "Remote", ["Sunday"]); + + await updateZoomMeeting("zid-shared", remote, [inPerson, remote]); + + expect(getCapturedBody()?.topic).toBe("One Day at a Time - In Person Sat - Zoom Only Sun"); + }); + + it("orders segments Hybrid / In Person / Remote regardless of the family's own order", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const hybrid = hybridWeekdays(); + const remote = remoteSaturday(); + + // Written from the Remote member, with the family listed Remote-first: the name must not + // depend on which row triggered the write or how the rows came back from the database. + await updateZoomMeeting("zid-shared", remote, [remote, hybrid]); + + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + }); + + it("leaves a single-schedule meeting's topic byte-identical to the mode suffix it has today", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const remote = familyRow("m-1", "Remote", ["Monday"]); + + await createZoomMeeting(remote, "host@test.icr"); + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Zoom Only"); + + // A family of one (the overwhelmingly common case, as getLinkedFamily returns it) is the + // same path -- no hierarchy, no trailing day label. + await createZoomMeeting(remote, "host@test.icr", [remote]); + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Zoom Only"); + + const hybrid = familyRow("m-1", "Hybrid", ["Monday"]); + await createZoomMeeting(hybrid, "host@test.icr", [hybrid]); + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid"); + + const inPerson = familyRow("m-1", "In Person", ["Monday"]); + await createZoomMeeting(inPerson, "host@test.icr", [inPerson]); + expect(getCapturedBody()?.topic).toBe("One Day at a Time"); + }); + + it("keeps a pinned zoomTopic verbatim even for a linked family", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const hybrid = hybridWeekdays(); + + await updateZoomMeeting("zid-shared", { ...hybrid, zoomTopic: "ICR Legacy Zoom Name" }, [hybrid, remoteSaturday()]); + + expect(getCapturedBody()?.topic).toBe("ICR Legacy Zoom Name"); + }); + + it("keeps the single-schedule name when the extra rows are the same mode (a scoped edit's split children)", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const parent = hybridWeekdays(); + const splitChild = familyRow("m-split", "Hybrid", ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]); + + await updateZoomMeeting("zid-shared", parent, [parent, splitChild]); + + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid"); + }); + + it("excludes an In-Person member's weekdays from Zoom's recurrence union", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const hybrid = hybridWeekdays(); + const inPerson = familyRow("m-inperson", "In Person", ["Saturday"]); + + await updateZoomMeeting("zid-shared", hybrid, [hybrid, inPerson]); + + const body = getCapturedBody(); + // Mon-Fri only: Saturday meets in person, so Zoom must not list an occurrence for it. + expect(body?.recurrence).toEqual({ type: 2, repeat_interval: 1, weekly_days: "2,3,4,5,6", end_times: 0 }); + expect(body?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - In Person Sat"); + }); + + it("names the family the same way when the caller passes only the other rows", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + const hybrid = hybridWeekdays(); + + // A caller holding siblings rather than the whole family still gets the family's name -- + // the row being written is added to it, never counted twice. + await updateZoomMeeting("zid-shared", hybrid, [remoteSaturday()]); + + expect(getCapturedBody()?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + }); + + it("uses the in-flight row rather than its stored copy when the family already contains it", async () => { + const { getCapturedBody } = mockFetchCapturingBody(); + // What the database still holds for the row being edited, as getLinkedFamily returns it. + const storedHybrid = familyRow("m-hybrid", "Hybrid", ["Monday"]); + const editedHybrid = hybridWeekdays(); + + await updateZoomMeeting("zid-shared", editedHybrid, [storedHybrid, remoteSaturday()]); + + const body = getCapturedBody(); + expect(body?.recurrence).toEqual({ type: 2, repeat_interval: 1, weekly_days: "2,3,4,5,6,7", end_times: 0 }); + expect(body?.topic).toBe("One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"); + }); +}); diff --git a/frontend/util/meetings/linkedSchedules.ts b/frontend/util/meetings/linkedSchedules.ts index 0208fc1e..28f05ec6 100644 --- a/frontend/util/meetings/linkedSchedules.ts +++ b/frontend/util/meetings/linkedSchedules.ts @@ -1,6 +1,8 @@ import type { Prisma } from "@prisma/client"; +import type { IMeeting } from "../../types/models"; import { WEEKDAY_NAMES } from "../date/timeUtils"; +import { formatDayColumn } from "./recurrenceDisplay"; // A "linked schedules" family is one meeting the group runs as two co-existing weekly // schedules -- same time, duration and interval, different modes on different weekdays -- served @@ -26,12 +28,50 @@ export const LINKED_SCHEDULE_MODES = ["Hybrid", "In Person", "Remote"] as const; export type LinkedScheduleMode = (typeof LINKED_SCHEDULE_MODES)[number]; +// Only the pattern fields formatDayColumn reads, all optional so a Prisma row, an IMeeting's +// IRecurrencePattern, and a bare { daysOfWeek } candidate all satisfy it. +export interface LinkedSchedulePattern { + type?: string; + weekOfMonth?: number | null; + dayOfMonth?: number | null; + daysOfWeek?: string[] | null; +} + // The subset of a meeting row the pure predicates below read -- deliberately structural (same // approach as SharedZoomScheduleRow), so a Prisma row, an IMeeting, and a not-yet-created // candidate row from a request payload all satisfy it without casting. export interface LinkedScheduleRow { modeType: string; - recurrencePattern?: { daysOfWeek?: string[] | null } | null; + recurrencePattern?: LinkedSchedulePattern | null; +} + +/** + * A row {@link buildLinkedScheduleLabel} can reconcile against a family: identity plus schedule. + * The lineage fields are optional so an in-flight request payload (which carries no + * `splitFromMid`) still satisfies it -- see {@link isDetachedSplitChild}. + */ +export interface LinkedScheduleLabelRow extends LinkedScheduleRow { + mid: string; + isRecurring?: boolean | null; + splitFromMid?: string | null; +} + +/** + * A "this occurrence" split-off child: a one-off detached from a series, not a schedule of its + * own. It has no representation in the family's name or in Zoom's single schedule -- one + * detached child whose mode was later edited would otherwise add a bogus segment + * ("… - Zoom Only One-time") to every sharing row's Zoom topic and calendar title. + * + * Deliberately NOT applied to the family {@link getZoomScheduleFamily} returns: such a row is + * still a real row of the shared Zoom meeting, and buildZoomRecurrence must keep seeing it to + * decide whether Zoom's schedule can be represented at all (retrieve/meeting/[id] draws the + * same distinction for zoomScheduleDiverged, from this same predicate). + * + * A recurring tail split (editScope 'thisAndFollowing') keeps `isRecurring: true` and is a + * genuine ongoing schedule, so it is not detached. + */ +export function isDetachedSplitChild(row: { isRecurring?: boolean | null; splitFromMid?: string | null }): boolean { + return !row.isRecurring && !!row.splitFromMid; } export interface LinkedFamily { @@ -90,6 +130,34 @@ export async function getLinkedFamily( return { anchor, linked }; } +/** + * Every live row the family's single Zoom meeting has to account for, ready to hand to + * createZoomMeeting/updateZoomMeeting (services/zoom.ts). + * + * Two sources, unioned by mid, because neither alone is the whole picture: + * - the linked-schedule family -- the only way to reach a Zoom-free In-Person member, which + * holds no zid yet still names itself in the family's Zoom topic; + * - every other live row sharing this zid -- a scoped edit's split children (deliberately not + * family members) and any legacy zid group the linked backfill didn't cover. Those are real + * rows of the same Zoom meeting, and dropping them would narrow the union schedule Zoom + * currently holds (#513). + */ +export async function getZoomScheduleFamily( + tx: Prisma.TransactionClient, + mid: string, + zid: string | null, +): Promise { + const family = await getLinkedFamily(tx, mid); + const zidRows = zid + ? await tx.meeting.findMany({ where: { zid, deletedAt: null }, include: FAMILY_INCLUDE }) + : []; + const byMid = new Map(); + for (const row of [...(family ? familyMembers(family) : []), ...zidRows]) byMid.set(row.mid, row); + // A Prisma row with its pattern included is structurally what the Zoom body builder reads, + // just not nominally an IMeeting -- the same cast the routes already made for zid siblings. + return [...byMid.values()] as unknown as IMeeting[]; +} + /** Whether another schedule may still be added to this family (cap of {@link LINKED_SCHEDULE_CAP}). */ export function canLinkSchedule(family: LinkedFamily): boolean { return familyMembers(family).length < LINKED_SCHEDULE_CAP; @@ -129,3 +197,101 @@ export function claimedDaysFor(family: LinkedFamily): string[] { export function isZoomBearing(row: { modeType: string }): boolean { return row.modeType === "Hybrid" || row.modeType === "Remote"; } + +// --- the family's display name, shared by every external service -------------------------- + +/** + * The family as a service must see it for THIS write: callers load the family from the + * database, so the row being created or updated is still its pre-edit copy in there -- the + * in-flight version replaces it. A caller that passes only the OTHER rows still gets a + * complete family, so no caller has to know which of the two shapes it holds. + */ +export function resolveFamilyRows(meeting: TRow, family: TRow[]): TRow[] { + return family.some((row) => row.mid === meeting.mid) + ? family.map((row) => (row.mid === meeting.mid ? meeting : row)) + : [meeting, ...family]; +} + +// How each mode names itself inside a family label. Only Remote is renamed ("Zoom Only") -- +// ICR's meetings are never fully unattended, so "Remote" would read as unhosted. +export const LINKED_SCHEDULE_MODE_LABEL: Record = { + Hybrid: "Hybrid", + "In Person": "In Person", + Remote: "Zoom Only", +}; + +// The Day column the exports already render ("Mon-Fri", "Sat", "Daily") -- one formatter, so a +// family's external name can never disagree with how the same schedule reads everywhere else. +function scheduleDayLabel(pattern: LinkedSchedulePattern | null | undefined): string { + if (!pattern) return formatDayColumn(null); + return formatDayColumn({ + type: pattern.type ?? "", + weekOfMonth: pattern.weekOfMonth ?? null, + dayOfMonth: pattern.dayOfMonth ?? null, + daysOfWeek: pattern.daysOfWeek ?? [], + }); +} + +/** + * The name one meeting run as several linked schedules carries on every external service: + * `"One Day at a Time - Hybrid Mon-Fri - Zoom Only Sat"`. Segments follow + * {@link LINKED_SCHEDULE_MODES}' fixed order, never the order the rows were created in, so the + * name is stable no matter which member triggered the write. + * + * Pure text: no knowledge of Zoom, Google Calendar, or zid. A caller with a pinned name of its + * own (Zoom's `zoomTopic`) short-circuits before ever reaching here. + * + * `singleScheduleSuffix` is the one thing the services genuinely disagree about: a lone row's + * own mode suffix. A Google Calendar event says "In Person" on an in-person meeting; a Zoom + * topic never does, because an in-person meeting has no Zoom meeting to name. Passing the map + * keeps each service's established single-schedule name byte-for-byte while the family case + * stays shared. + * + * `baseTitle` is the caller's own row's title, so two members' names agree only while their + * `title` columns do. Nothing here reconciles them -- an admin may still edit a linked row's + * title directly, which silently de-syncs the two events' names until both are rewritten. + */ +export function buildLinkedScheduleLabel( + baseTitle: string, + meeting: LinkedScheduleLabelRow, + family: LinkedScheduleLabelRow[], + singleScheduleSuffix: Record = LINKED_SCHEDULE_MODE_LABEL, +): string { + // Detachment is a property of the STORED row: the in-flight copy replacing it below comes + // from a request payload that carries no lineage fields, so a detached child would otherwise + // re-enter the label through its own edit. + const detachedMids = new Set(family.filter(isDetachedSplitChild).map((row) => row.mid)); + const rows = resolveFamilyRows(meeting, family) + .filter((row) => !detachedMids.has(row.mid) && !isDetachedSplitChild(row)); + const segments = LINKED_SCHEDULE_MODES.flatMap((mode) => { + const row = rows.find((candidate) => candidate.modeType === mode); + return row ? [`${LINKED_SCHEDULE_MODE_LABEL[mode]} ${scheduleDayLabel(row.recurrencePattern)}`.trim()] : []; + }); + // Keyed on distinct MODES, not row count: a family's modes are unique by construction, so + // two segments means a genuine linked family, while several rows of the same mode (a legacy + // zid group, a recurring tail split) collapse to one segment and keep the plain + // single-schedule name they have today. Always the row's OWN mode, even when the row was + // filtered out above -- a detached one-off names itself, never the family it left. + if (segments.length < 2) { + const suffix = singleScheduleSuffix[meeting.modeType]; + return suffix ? `${baseTitle} - ${suffix}` : baseTitle; + } + return `${baseTitle} - ${segments.join(" - ")}`; +} + +/** A shareable once-per-request family reader -- see {@link linkedFamilyLoader}. */ +export type LinkedFamilyLoader = (zid: string | null) => Promise; + +/** + * A once-per-request {@link getZoomScheduleFamily} reader. One meeting's family names both its + * Zoom topic and every family member's Google Calendar event title in the same sync, and the + * two must agree -- so the lookup happens at most once and every consumer reads that result. + * The `zid` argument only matters on the first call, which is the one that runs the query. + * + * Caches the in-flight promise, not the resolved value: a scoped edit starts its parent and + * child `after()` syncs concurrently, so two callers can reach an unresolved loader. + */ +export function linkedFamilyLoader(tx: Prisma.TransactionClient, mid: string): LinkedFamilyLoader { + let loaded: Promise | null = null; + return (zid) => (loaded ??= getZoomScheduleFamily(tx, mid, zid)); +} diff --git a/frontend/util/meetings/suspension.ts b/frontend/util/meetings/suspension.ts index 6857ec18..8e1d3a4b 100644 --- a/frontend/util/meetings/suspension.ts +++ b/frontend/util/meetings/suspension.ts @@ -3,6 +3,7 @@ import { Meeting, RecurrencePattern, SuspensionPeriod } from "@prisma/client"; import { formatETDateString, isDstGapError } from "../date/timeUtils"; import { isDateSuspended, adjustOccurrenceToDate, firstOccurrenceOnOrAfter } from "./meetingOccurrences"; import { calendarIdsForMeeting, createCalendarEvent, deleteCalendarEvent } from "../../services/googleCalendar"; +import { linkedFamilyLoader } from "./linkedSchedules"; import { IMeeting } from "../../types/models"; import { prisma } from "../../lib/prisma"; @@ -111,6 +112,11 @@ export async function createPendingResumeSeries( const calendarIds = calendarIdsForMeeting(requestedCats); const resumeEventIds: Record = {}; let error: string | null = null; + // A suspended row may be one schedule of a linked family, whose event title names every + // schedule -- pre-creating the resume series without the family would publish it under this + // row's own mode alone, and it would stay wrong once promoted. Lazy: the early returns below + // for "no upcoming occurrence" must stay genuine no-ops, query included. + const loadFamily = linkedFamilyLoader(prisma, meeting.mid); // calendarIds silently drops any category whose GOOGLE_CALENDAR_* env var isn't configured -- // only recorded once we know there's an occurrence worth syncing into (the early returns // below for "no upcoming occurrence" stay genuine no-ops, not a misconfiguration report). @@ -141,8 +147,9 @@ export async function createPendingResumeSeries( return { resumeEventIds: {}, error: "Could not compute the resume time — it falls in a DST transition gap." }; } const resumeMeeting = toCalendarMeeting(meeting, start, end); + const family = await loadFamily(meeting.zid ?? null); for (const [cat, calId] of Object.entries(calendarIds)) { - const { id, error: createError } = await createCalendarEvent(accessToken, resumeMeeting, calId); + const { id, error: createError } = await createCalendarEvent(accessToken, resumeMeeting, calId, undefined, family); if (id) resumeEventIds[cat] = id; else error = error ?? createError; } @@ -151,8 +158,9 @@ export async function createPendingResumeSeries( // -- otherwise there's nothing meaningful to resume it into. recordUnconfiguredCat(); const resumeMeeting = toCalendarMeeting(meeting, meeting.startDateTime, meeting.endDateTime); + const family = await loadFamily(meeting.zid ?? null); for (const [cat, calId] of Object.entries(calendarIds)) { - const { id, error: createError } = await createCalendarEvent(accessToken, resumeMeeting, calId); + const { id, error: createError } = await createCalendarEvent(accessToken, resumeMeeting, calId, undefined, family); if (id) resumeEventIds[cat] = id; else error = error ?? createError; }