public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
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
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();
}