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';
}