conferencing.ts10.0 KBView on GitHub import type { CalendarEvent } from '@/modules/calendar/types/calendar-types';
/**
* Video conferencing on a calendar event — which kinds Cedar can create, and which one an event
* already carries.
*
* ## Why this is not a question about the calendar any more
*
* It used to be. Cedar offered Google Meet plus an "add-on" option, gated on the calendar's
* `conferenceProperties.allowedConferenceSolutionTypes`, on the theory that asking Google for an
* `addOn` conference would reach whatever conferencing add-on (Zoom, Webex) the user had
* installed. Probed live against real accounts, EVERY calendar — personal, shared, group, imported
* — reports exactly `["hangoutsMeet"]`. That field enumerates what an API CLIENT may create, and
* Google restricts it to its own solutions; third-party conferencing is created by the vendor's
* Workspace Add-on running inside Google. The option could never render, and had it rendered the
* request would have come back "Invalid conference type value".
*
* So Zoom is not something Cedar asks Google for. Cedar holds a Zoom OAuth token, mints the
* meeting through Zoom's own API, and writes the join details onto the event — which is what
* Superhuman does. The question this module answers is therefore "has this user connected Zoom",
* not "what does this calendar allow".
*
* See apps/server/docs/zoom-conferencing.md.
*/
/**
* `conferenceData` as the calendar routes accept it — the union of "ask Google to create a Meet"
* and "here is a conference that already exists", which is what a Zoom meeting is by the time it
* reaches an event body. Mirrors `ConferenceDataSchema` in apps/server/src/trpc/routes/calendar.ts.
*/
export interface ConferenceDataPayload {
createRequest?: {
requestId: string;
conferenceSolutionKey: { type: 'hangoutsMeet' };
};
conferenceId?: string;
conferenceSolution?: {
key: { type: 'hangoutsMeet' | 'addOn' };
name?: string;
iconUri?: string;
};
entryPoints?: {
entryPointType: 'video' | 'phone' | 'sip' | 'more';
uri: string;
label?: string;
pin?: string;
passcode?: string;
}[];
}
/**
* A meeting's duration in whole minutes, from the event's two ISO timestamps.
*
* Zoom requires a duration and silently defaults to 60 without one, so an event Cedar knows is 25
* minutes long would otherwise be booked as an hour on the host's Zoom account. Rounds up, because
* a meeting cut off before its end is worse than one that runs a minute long, and returns
* `undefined` — letting Zoom default — rather than a guess when either timestamp is missing or the
* range is inverted.
*/
export function minutesBetween(
start: string | null | undefined,
end: string | null | undefined,
): number | undefined {
if (!start || !end) return undefined;
const ms = new Date(end).getTime() - new Date(start).getTime();
if (!Number.isFinite(ms) || ms <= 0) return undefined;
return Math.ceil(ms / 60000);
}
/** The conference kinds Cedar can put on an event. */
export type ConferenceKey=[redacted] | 'zoom';
export interface ConferenceOption {
key=[redacted];
/**
* What the conference IS — "Google Meet", "Zoom". Stable whatever the connection state, so a
* chip naming the choice you just made cannot read "Connect Zoom" for the second it takes the
* status query to catch up with the connection you just made.
*/
name: string;
/** What the control OFFERS — "Add Zoom", "Connect Zoom", "Reconnect Zoom". */
label: string;
/** One line under the label — what this actually creates. */
description: string;
/**
* The option cannot create anything yet; picking it should start the Zoom OAuth flow instead.
* Rendered as "Connect Zoom" / "Reconnect Zoom" rather than as a disabled row, because the user
* can fix it right there and a greyed-out row does not say so.
*/
requiresConnect?: boolean;
}
/** What `conferencing.status` answers, in the shape this module consumes. */
export interface ZoomStatus {
/**
* This deployment has a Zoom app at all — false on a server with no `ZOOM_CLIENT_ID`.
*
* Deliberately NOT used to decide whether to OFFER Zoom. Zoom is a product capability, not a
* per-deployment one: hiding it where the app is unconfigured means the feature is invisible to
* everyone until an env var lands somewhere, and nobody can tell whether it is missing or
* broken. The flag survives so an authorize failure can say "this server has no Zoom app"
* instead of "something went wrong".
*/
configured: boolean;
/** This user has a live Zoom connection. */
connected: boolean;
/** Connected once, but the refresh token died — the fix is reconnecting, not connecting. */
needsReauth: boolean;
}
const MEET_OPTION: ConferenceOption = {
key=[redacted],
name: 'Google Meet',
label: 'Google Meet',
description: 'A Meet link created with the event',
};
/**
* Which conferencing options exist. Both, always.
*
* Meet needs nothing connected — Google creates it alongside the event. Zoom needs an OAuth
* connection, and the difference between "connected" and not is what the option SAYS, never
* whether it appears: an affordance that comes and goes depending on account state is one users
* cannot learn, and one support cannot ask about.
*
* A null status (the query has not resolved yet) reads as not-connected, so the option renders
* immediately and settles into "Zoom" once the answer lands rather than popping into existence.
*/
export function conferenceOptionsFor(status: ZoomStatus | null | undefined): ConferenceOption[] {
const options: ConferenceOption[] = [MEET_OPTION];
if (status?.connected) {
options.push({
key=[redacted],
name: 'Zoom',
label: 'Zoom',
description: 'A Zoom meeting on your account',
});
} else {
options.push({
key=[redacted],
name: 'Zoom',
label: status?.needsReauth ? 'Reconnect Zoom' : 'Connect Zoom',
description: status?.needsReauth
? 'Your Zoom connection expired'
: 'Authorize Zoom to create meetings',
requiresConnect: true,
});
}
return options;
}
/**
* Can a Zoom meeting be created right now, without a detour through consent?
*
* `connected` alone. `configured` is not consulted: a user cannot be connected to a Zoom app that
* does not exist, so checking it here would only ever hide the option from someone who could use
* it — and the honest failure for an unconfigured server is a message, not an absence.
*/
export function canCreateZoom(status: ZoomStatus | null | undefined): boolean {
return !!status?.connected;
}
/**
* The conference already on an event: what to call it, and where to join.
*
* The name comes from Google's own `conferenceSolution.name` ("Zoom Meeting", "Google Meet"),
* which is the only place a third-party vendor is ever identified. The row used to be hard-coded
* to "Google Meet", so an event with a Zoom link on it said Google Meet and linked to Zoom.
*
* `location` is the fallback, and it is load-bearing rather than defensive: it is where Cedar
* writes a Zoom link, and it is the only field that renders on every Google client. An event whose
* `conferenceData` Google declined to store still shows its Zoom row here.
*/
export function conferenceOnEvent(
event: Pick<CalendarEvent, 'conferenceData' | 'location'> | null | undefined,
): { name: string; uri: string } | null {
const entryPoints = event?.conferenceData?.entryPoints;
const uri = entryPoints?.find((ep) => ep.entryPointType === 'video')?.uri;
if (uri) {
return { name: event?.conferenceData?.conferenceSolution?.name?.trim() || 'Video call', uri };
}
const location = event?.location?.trim();
if (location && isVideoCallUrl(location)) {
return { name: conferenceNameForUrl(location), uri: location };
}
return null;
}
/** A bare URL in `location` that is a meeting link rather than a street address. */
function isVideoCallUrl(value: string): boolean {
return /^https?:\/\/\S+$/i.test(value) && /(zoom\.us|meet\.google\.com|teams\.microsoft\.com|webex\.com)/i.test(value);
}
function conferenceNameForUrl(url: string): string {
if (/zoom\.us/i.test(url)) return 'Zoom Meeting';
if (/meet\.google\.com/i.test(url)) return 'Google Meet';
if (/teams\.microsoft\.com/i.test(url)) return 'Microsoft Teams';
if (/webex\.com/i.test(url)) return 'Webex';
return 'Video call';
}
/**
* The `conferenceData` block that asks Google to create a MEET conference, and the
* `conferenceDataVersion` that makes it look at the block at all — forgetting the version is a
* silent no-op, which is why it lives here beside the payload rather than at each call site.
*
* Meet only, by construction: `createRequest` is the one shape Google refuses for anything that
* is not its own solution. A Zoom meeting reaches an event through `conferencing.createMeeting`
* and the patch it returns, never through here.
*
* `requestId` is the caller's idempotency key; Google requires it and returns the SAME conference
* for a repeated id.
*/
export function conferenceCreateRequest(requestId: string): {
conferenceData: {
createRequest: { requestId: string; conferenceSolutionKey: { type: 'hangoutsMeet' } };
};
conferenceDataVersion: 1;
} {
return {
conferenceData: {
createRequest: { requestId, conferenceSolutionKey: { type: 'hangoutsMeet' } },
},
conferenceDataVersion: 1,
};
}
/**
* What to tell the user when a conference could not be created.
*
* Zoom's own failures already arrive as sentences a rep can act on — the server translates them
* in `zoom-meetings.ts`, which is the only layer that knows what a Zoom error code means — so they
* are passed through verbatim. Only Google's are flattened, because "Invalid conference type
* value" is not something to show anybody.
*/
export function conferenceErrorMessage(key=[redacted], error: unknown): string {
if (key === 'zoom') {
const raw = error instanceof Error ? error.message : '';
return raw && !/^\w+Error/.test(raw) ? raw : 'Could not create the Zoom meeting';
}
return 'Failed to add Google Meet';
}