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
2 changes: 1 addition & 1 deletion docs/02-handoff/technical-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
9 changes: 7 additions & 2 deletions frontend/app/api/delete/meeting/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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.";
Expand All @@ -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.";
Expand Down
21 changes: 11 additions & 10 deletions frontend/app/api/retrieve/meeting/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 })),
Expand Down
7 changes: 6 additions & 1 deletion frontend/app/api/update/meeting/resume/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, string> = {};
// 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
Expand All @@ -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;
}
Expand Down
Loading
Loading