NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 79% CSS 19% SQL 2% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
google.ts
import { decryptToken, encryptToken } from './crypto';
import type { AppSettings, Env, MeetingMode } from './types';

const GOOGLE_SCOPES = [
  'openid',
  'email',
  'profile',
  'https://www.googleapis.com/auth/calendar.events',
  'https://www.googleapis.com/auth/calendar.events.freebusy',
];

interface TokenResponse {
  access_token: string;
  expires_in: number;
  refresh_token?: string;
  scope?: string;
  token_type: string;
  error?: string;
  error_description?: string;
}

interface GoogleUser {
  email: string;
  name?: string;
  picture?: string;
}

interface GoogleEventResponse {
  id: string;
  htmlLink?: string;
  hangoutLink?: string;
  conferenceData?: {
    entryPoints?: Array<{ entryPointType: string; uri: string }>;
  };
}

export class GoogleCalendarError extends Error {
  public readonly publicMessage: string;
  public readonly status: number;

  constructor(operation: string, status: number, details: string) {
    super(`${operation} failed (${status})`);
    this.name = 'GoogleCalendarError';
    this.status = status;
    this.publicMessage = status === 403
      ? 'Calendar setup needs attention. The owner needs to enable the Google Calendar API, then reconnect Google Calendar.'
      : 'Calendar setup needs attention. The owner needs to reconnect Google Calendar or check the calendar settings.';
    console.warn(`${operation} failed (${status}): ${details.slice(0, 1000)}`);
  }
}

async function throwGoogleCalendarError(response: Response, operation: string): Promise<never> {
  const details = await response.text().catch(() => '');
  throw new GoogleCalendarError(operation, response.status, details);
}

function redirectUri(env: Env): string {
  return `${env.APP_URL.replace(/\/$/, '')}/auth/google/callback`;
}

export async function hasGoogleConnection(env: Env): Promise<boolean> {
  const row = await env.DB.prepare('SELECT 1 AS ok FROM oauth_tokens WHERE provider = ?1')
    .bind('google')
    .first<{ ok: number }>();
  return Boolean(row?.ok);
}

export async function createGoogleAuthUrl(env: Env, forceConsent = false): Promise<string> {
  const state = crypto.randomUUID();
  const expires = Date.now() + 10 * 60_000;
  await env.DB.prepare('INSERT INTO oauth_states (state, expires_at) VALUES (?1, ?2)')
    .bind(state, expires)
    .run();

  const connected = await hasGoogleConnection(env);
  const params = new URLSearchParams({
    client_id: env.GOOGLE_CLIENT_ID,
    redirect_uri: redirectUri(env),
    response_type: 'code',
    scope: GOOGLE_SCOPES.join(' '),
    access_type: 'offline',
    include_granted_scopes: 'true',
    state,
  });

  params.set('prompt', forceConsent || !connected ? 'consent' : 'select_account');
  if (env.ADMIN_EMAIL) params.set('login_hint', env.ADMIN_EMAIL);
  return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}

async function exchangeCode(env: Env, code: string): Promise<TokenResponse> {
  const body = new URLSearchParams({
    code,
    client_id: env.GOOGLE_CLIENT_ID,
    client_secret: env.GOOGLE_CLIENT_SECRET,
    redirect_uri: redirectUri(env),
    grant_type: 'authorization_code',
  });

  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });
  const data = (await response.json()) as TokenResponse;
  if (!response.ok || data.error) {
    throw new Error(data.error_description || data.error || `Google token exchange failed (${response.status})`);
  }
  return data;
}

async function fetchGoogleUser(accessToken: string): Promise<GoogleUser> {
  const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!response.ok) throw new Error('Could not read Google account identity');
  return (await response.json()) as GoogleUser;
}

export async function completeGoogleOAuth(
  env: Env,
  state: string,
  code: string,
): Promise<string> {
  const stateRow = await env.DB.prepare('SELECT expires_at FROM oauth_states WHERE state = ?1')
    .bind(state)
    .first<{ expires_at: number }>();

  await env.DB.prepare('DELETE FROM oauth_states WHERE state = ?1').bind(state).run();
  if (!stateRow || stateRow.expires_at < Date.now()) throw new Error('OAuth state expired or invalid');

  const token = await exchangeCode(env, code);
  const user = await fetchGoogleUser(token.access_token);

  if (user.email.toLowerCase() !== env.ADMIN_EMAIL.toLowerCase()) {
    throw new Error(`This Google account is not the configured admin (${env.ADMIN_EMAIL})`);
  }

  if (token.refresh_token) {
    const encrypted = await encryptToken(env, token.refresh_token);
    await env.DB.prepare(`
      INSERT INTO oauth_tokens (provider, refresh_token_enc, account_email, updated_at)
      VALUES ('google', ?1, ?2, datetime('now'))
      ON CONFLICT(provider) DO UPDATE SET
        refresh_token_enc = excluded.refresh_token_enc,
        account_email = excluded.account_email,
        updated_at = excluded.updated_at
    `).bind(encrypted, user.email).run();
  } else {
    const existing = await hasGoogleConnection(env);
    if (!existing) {
      throw new Error('Google did not return a refresh token. Revoke app access and connect again.');
    }
  }

  return user.email;
}

async function accessToken(env: Env): Promise<string> {
  const row = await env.DB.prepare(
    'SELECT refresh_token_enc FROM oauth_tokens WHERE provider = ?1',
  ).bind('google').first<{ refresh_token_enc: string }>();

  if (!row?.refresh_token_enc) throw new Error('Google Calendar is not connected');
  const refreshToken = await decryptToken(env, row.refresh_token_enc);

  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: env.GOOGLE_CLIENT_ID,
      client_secret: env.GOOGLE_CLIENT_SECRET,
      refresh_token: refreshToken,
      grant_type: 'refresh_token',
    }),
  });

  const data = (await response.json()) as TokenResponse;
  if (!response.ok || !data.access_token) {
    throw new Error(data.error_description || data.error || 'Google refresh token failed');
  }
  return data.access_token;
}

export async function queryBusy(
  env: Env,
  settings: AppSettings,
  timeMin: string,
  timeMax: string,
): Promise<Array<{ start: string; end: string }>> {
  const token = await accessToken(env);
  const calendars = settings.busyCalendarIds.length ? settings.busyCalendarIds : [settings.calendarId];

  const response = await fetch('https://www.googleapis.com/calendar/v3/freeBusy', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      timeMin,
      timeMax,
      timeZone: settings.timezone,
      items: calendars.map((id) => ({ id })),
    }),
  });

  if (!response.ok) {
    await throwGoogleCalendarError(response, 'Google FreeBusy');
  }

  const data = (await response.json()) as {
    calendars?: Record<string, { busy?: Array<{ start: string; end: string }> }>;
  };

  return Object.values(data.calendars ?? {}).flatMap((calendar) => calendar.busy ?? []);
}

export async function createCalendarEvent(
  env: Env,
  settings: AppSettings,
  booking: {
    id: string;
    publicBookingId: string;
    name: string;
    email: string;
    phone?: string;
    message?: string;
    start: string;
    end: string;
    meetingMode: MeetingMode;
  },
): Promise<{ eventId: string; meetUrl: string | null; htmlLink: string | null }> {
  const token = await accessToken(env);
  const params = new URLSearchParams({
    sendUpdates: 'all',
    conferenceDataVersion: '1',
  });
  const calendarId = encodeURIComponent(settings.calendarId || 'primary');

  const descriptionLines = [
    `Booked through ${env.APP_URL}`,
    `Booking ID: ${booking.publicBookingId}`,
    booking.phone ? `Phone: ${booking.phone}` : '',
    booking.message ? `Message: ${booking.message}` : '',
  ].filter(Boolean);

  const event: Record<string, unknown> = {
    summary: `Meeting with ${booking.name}`,
    description: descriptionLines.join('\n\n'),
    start: { dateTime: booking.start, timeZone: settings.timezone },
    end: { dateTime: booking.end, timeZone: settings.timezone },
    attendees: [{ email: booking.email, displayName: booking.name }],
  };

  if (booking.meetingMode === 'google_meet') {
    event.conferenceData = {
      createRequest: {
        requestId: booking.id,
        conferenceSolutionKey: { type: 'hangoutsMeet' },
      },
    };
  } else if (booking.meetingMode === 'in_person' && settings.inPersonLocation) {
    event.location = settings.inPersonLocation;
  } else if (booking.meetingMode === 'phone') {
    event.location = 'Phone call';
  }

  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?${params.toString()}`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(event),
    },
  );

  if (!response.ok) {
    await throwGoogleCalendarError(response, 'Google event creation');
  }

  const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
  if (!data.id) {
    throw new GoogleCalendarError('Google event creation', 502, data.error?.message ?? 'Missing event id');
  }

  const meetUrl =
    data.hangoutLink ??
    data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri ??
    null;

  return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null };
}


export async function createCalendarHold(
  env: Env,
  settings: AppSettings,
  booking: {
    id: string;
    start: string;
    end: string;
    meetingMode: MeetingMode;
  },
): Promise<{ eventId: string; meetUrl: string | null }> {
  const token = await accessToken(env);
  const params = new URLSearchParams({
    sendUpdates: 'none',
    conferenceDataVersion: '1',
  });
  const calendarId = encodeURIComponent(settings.calendarId || 'primary');

  const event: Record<string, unknown> = {
    summary: 'Pending booking payment',
    description: `Temporary hold created by ${env.APP_URL}. No invitation is sent until payment succeeds.`,
    start: { dateTime: booking.start, timeZone: settings.timezone },
    end: { dateTime: booking.end, timeZone: settings.timezone },
    visibility: 'private',
    transparency: 'opaque',
  };

  if (booking.meetingMode === 'google_meet') {
    event.conferenceData = {
      createRequest: {
        requestId: `hold-${booking.id}`,
        conferenceSolutionKey: { type: 'hangoutsMeet' },
      },
    };
  }

  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?${params.toString()}`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(event),
    },
  );

  if (!response.ok) await throwGoogleCalendarError(response, 'Google payment hold creation');
  const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
  if (!data.id) throw new GoogleCalendarError('Google payment hold creation', 502, data.error?.message ?? 'Missing event id');

  const meetUrl = data.hangoutLink
    ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri
    ?? null;
  return { eventId: data.id, meetUrl };
}

export async function confirmCalendarHold(
  env: Env,
  settings: AppSettings,
  eventId: string,
  booking: {
    id: string;
    publicBookingId: string;
    name: string;
    email: string;
    phone?: string;
    message?: string;
    start: string;
    end: string;
    meetingMode: MeetingMode;
  },
): Promise<{ eventId: string; meetUrl: string | null; htmlLink: string | null }> {
  const token = await accessToken(env);
  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
  const encodedEventId = encodeURIComponent(eventId);
  const params = new URLSearchParams({ sendUpdates: 'all', conferenceDataVersion: '1' });

  const descriptionLines = [
    `Booked through ${env.APP_URL}`,
    `Booking ID: ${booking.publicBookingId}`,
    booking.phone ? `Phone: ${booking.phone}` : '',
    booking.message ? `Message: ${booking.message}` : '',
    'Payment received via Stripe.',
  ].filter(Boolean);

  const event: Record<string, unknown> = {
    summary: `Meeting with ${booking.name}`,
    description: descriptionLines.join('\n\n'),
    start: { dateTime: booking.start, timeZone: settings.timezone },
    end: { dateTime: booking.end, timeZone: settings.timezone },
    attendees: [{ email: booking.email, displayName: booking.name }],
    visibility: 'default',
    transparency: 'opaque',
  };

  // Google Meet was created on the private hold already. Omitting conferenceData
  // here preserves that conference while the PATCH adds the attendee and sends the invite.
  if (booking.meetingMode === 'in_person' && settings.inPersonLocation) {
    event.location = settings.inPersonLocation;
  } else if (booking.meetingMode === 'phone') {
    event.location = 'Phone call';
  }

  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
    {
      method: 'PATCH',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(event),
    },
  );

  if (!response.ok) await throwGoogleCalendarError(response, 'Google paid booking confirmation');
  const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
  if (!data.id) throw new GoogleCalendarError('Google paid booking confirmation', 502, data.error?.message ?? 'Missing event id');

  const meetUrl = data.hangoutLink
    ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri
    ?? null;
  return { eventId: data.id, meetUrl, htmlLink: data.htmlLink ?? null };
}


function managedBookingDescription(
  env: Env,
  booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string },
  extraLines: string[] = [],
): string {
  return [
    `Booked through ${env.APP_URL}`,
    booking.publicBookingId ? `Booking ID: ${booking.publicBookingId}` : '',
    booking.phone ? `Phone: ${booking.phone}` : '',
    booking.message ? `Message: ${booking.message}` : '',
    booking.paid ? 'Payment received via Stripe.' : '',
    ...extraLines,
  ].filter(Boolean).join('\n\n');
}

export async function rescheduleCalendarEvent(
  env: Env,
  settings: AppSettings,
  eventId: string,
  booking: {
    phone?: string;
    message?: string;
    paid?: boolean;
    publicBookingId?: string;
    start: string;
    end: string;
  },
): Promise<{ eventId: string; meetUrl: string | null }> {
  const token = await accessToken(env);
  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
  const encodedEventId = encodeURIComponent(eventId);
  const params = new URLSearchParams({ sendUpdates: 'all', conferenceDataVersion: '1' });
  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
    {
      method: 'PATCH',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        start: { dateTime: booking.start, timeZone: settings.timezone },
        end: { dateTime: booking.end, timeZone: settings.timezone },
        description: managedBookingDescription(env, booking),
      }),
    },
  );
  if (!response.ok) await throwGoogleCalendarError(response, 'Google event reschedule');
  const data = (await response.json()) as GoogleEventResponse & { error?: { message?: string } };
  if (!data.id) throw new GoogleCalendarError('Google event reschedule', 502, data.error?.message ?? 'Missing event id');
  const meetUrl = data.hangoutLink
    ?? data.conferenceData?.entryPoints?.find((entry) => entry.entryPointType === 'video')?.uri
    ?? null;
  return { eventId: data.id, meetUrl };
}

export async function sendRescheduleSuggestion(
  env: Env,
  settings: AppSettings,
  eventId: string,
  booking: { phone?: string; message?: string; paid?: boolean; publicBookingId?: string },
  proposal: { start: string; end: string; url: string },
): Promise<void> {
  const token = await accessToken(env);
  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
  const encodedEventId = encodeURIComponent(eventId);
  const params = new URLSearchParams({ sendUpdates: 'all' });
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone: settings.timezone,
    weekday: 'long',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
  });
  const proposedText = `${formatter.format(new Date(proposal.start))} – ${new Intl.DateTimeFormat('en-US', {
    timeZone: settings.timezone,
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
  }).format(new Date(proposal.end))} (${settings.timezone})`;
  const description = managedBookingDescription(env, booking, [
    'Slot reschedule suggestion',
    `Suggested new time: ${proposedText}`,
    `Review and accept: ${proposal.url}`,
    'The existing meeting time stays confirmed until the suggestion is accepted.',
  ]);
  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
    {
      method: 'PATCH',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ description }),
    },
  );
  if (!response.ok) await throwGoogleCalendarError(response, 'Google reschedule suggestion');
}

export async function deleteCalendarEvent(
  env: Env,
  settings: AppSettings,
  eventId: string,
): Promise<void> {
  const token = await accessToken(env);
  const calendarId = encodeURIComponent(settings.calendarId || 'primary');
  const encodedEventId = encodeURIComponent(eventId);
  const params = new URLSearchParams({ sendUpdates: 'all' });

  const response = await fetch(
    `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${encodedEventId}?${params.toString()}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${token}` },
    },
  );

  if (response.status === 410 || response.status === 404) return;
  if (!response.ok) {
    await throwGoogleCalendarError(response, 'Google event cancellation');
  }
}

export async function disconnectGoogle(env: Env): Promise<void> {
  await env.DB.prepare("DELETE FROM oauth_tokens WHERE provider = 'google'").run();
}