Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7385a6c1c47153b1728160ff
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
functions/api.ts
// Book's public HTTP API: no account. Anyone can read a page's open times and book one. The owner
// key (handed back once, when a page is made) manages the page; the cancel key (handed back once,
// with each booking) manages that booking. Every route answers JSON except the calendar file;
// GET / describes the routes. Writes go through the same policy rules as the web app, as this
// route's public principal, which every caller shares: so every owner or guest action checks the
// key first, and the policy checks it again through the claim written with the action. The calendar feed
// key is derived from the owner key, so a calendar app can read the bookings but change nothing.
import { SITE_URL, API_BASE } from "./lib/config";
import {
readHours, stringifyHours, parseHours, hasHours, hoursSummary, readQuestions, parseQuestions, parseAnswers, checkMeetingUrl, meetingKind,
readDaysOff, parseDaysOff, handleProblem, suggestHandle, validTimeZone, zonedToUtc, wallClock, isoInZone, addDays, slotLabel, zoneLabel,
openSlots, cellsFor, meetingEvent, icsFile, icsFeed, feedKey, googleCalendarUrl, fileName, randomId, EMAIL_RE, DURATIONS, BUFFERS, LIMITS,
DEFAULTS, DEFAULT_HOURS, CELL,
type Page, type Booking, type Answer,
} from "./lib/book";
const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{8,32}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const LOCAL_RE = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(:\d{2})?$/;
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
"access-control-allow-headers": "content-type, x-owner-key, x-cancel-key",
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string) => json({ error }, status);
class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }
async function body(req: Request): Promise<Record<string, any>> {
try {
const parsed = await req.json();
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch { /* fall through */ }
throw new HttpError(400, "Send a JSON object body.");
}
async function listAll(ctx: any, path: string, filter?: Record<string, unknown>): Promise<any[]> {
const rows: any[] = [];
let cursor: string | undefined;
for (let page = 0; page < 20; page++) {
const res = await ctx.bounded.get(path, { limit: 500, ...(filter ? { filter } : {}), ...(cursor ? { cursor } : {}) });
// Rows name their path in _id (or pathId); the bare id is its last segment.
for (const r of res?.data || []) rows.push({ ...r, id: r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop() });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
const nowSec = () => Math.floor(Date.now() / 1000);
const pageUrl = (handle: string) => `${SITE_URL}/${handle}`;
const manageUrl = (handle: string, id: string, key: string) => `${SITE_URL}/b/${handle}/${id}#c=${key}`;
const icsUrl = (id: string, key: string) => `${API_BASE}/bookings/${id}/ics?c=${key}`;
const feedUrl = async (handle: string, ownerKey: string) => `${API_BASE}/pages/${handle}/calendar.ics?f=${await feedKey(ownerKey)}`;
function text(input: Record<string, any>, field: string, max: number, required = false): string {
const value = input[field] == null ? "" : String(input[field]).trim();
if (required && !value) throw new HttpError(400, `${field} is required.`);
if (value.length > max) throw new HttpError(400, `Keep ${field} under ${max} characters.`);
return value;
}
function whole(input: Record<string, any>, field: string, min: number, max: number, fallback: number): number {
if (input[field] == null || input[field] === "") return fallback;
const n = Number(input[field]);
if (!Number.isInteger(n) || n < min || n > max) throw new HttpError(400, `${field} must be a whole number from ${min} to ${max}.`);
return n;
}
function zone(value: unknown, fallback: string, field = "timezone"): string {
const tz = value == null || value === "" ? fallback : String(value).trim();
if (!validTimeZone(tz)) throw new HttpError(400, `${field} must be an IANA time zone like "America/New_York".`);
return tz;
}
/** An ISO time as UTC seconds. Without an offset it is read in the given time zone. */
function parseInstant(value: unknown, tz: string, field: string): number {
const s = String(value ?? "").trim();
const local = s.match(LOCAL_RE);
if (local) return zonedToUtc(local[1], local[2], tz);
const t = Date.parse(s);
if (!s || !/^\d{4}-\d{2}-\d{2}T/.test(s) || !Number.isFinite(t)) throw new HttpError(400, `${field} must be a time like "2026-10-21T15:00:00-04:00".`);
return Math.floor(t / 1000);
}
type PageFields = Omit<Page, "createdBy" | "createdAt">;
/** Validates page settings from a person or an assistant. With `base`, fields left out keep their current value. */
function readPage(input: Record<string, any>, base?: { page: Page; meetingUrl: string }): { page: PageFields; meetingUrl: string } {
const has = (k: string) => input[k] !== undefined;
const b = base?.page;
const durationMin = has("durationMin") || !b ? whole(input, "durationMin", 15, 60, DEFAULTS.durationMin) : b.durationMin;
if (!DURATIONS.includes(durationMin)) throw new HttpError(400, `durationMin must be one of ${DURATIONS.join(", ")}.`);
const bufferMin = has("bufferMin") || !b ? whole(input, "bufferMin", 0, 60, DEFAULTS.bufferMin) : b.bufferMin;
if (!BUFFERS.includes(bufferMin)) throw new HttpError(400, `bufferMin must be one of ${BUFFERS.join(", ")}.`);
if (!b && !has("timezone")) throw new HttpError(400, `timezone is required: the IANA time zone the hours are in, like "America/New_York".`);
let hours = b ? b.hours : stringifyHours(DEFAULT_HOURS);
if (has("hours")) {
try {
const h = readHours(input.hours);
if (!hasHours(h)) throw new Error("hours needs at least one open range.");
hours = stringifyHours(h);
} catch (e) {
throw new HttpError(400, (e as Error).message);
}
}
const tz = has("timezone") || !b ? zone(input.timezone, "") : b.timezone;
// An optional field: left out when there is nothing to store, cleared with null.
let daysOff = b?.daysOff;
if (has("daysOff")) {
try {
const ranges = readDaysOff(input.daysOff, wallClock(nowSec(), tz).date);
daysOff = ranges.length ? JSON.stringify(ranges) : b?.daysOff ? null : undefined;
} catch (e) {
throw new HttpError(400, (e as Error).message);
}
}
let questions = b ? b.questions : "[]";
if (has("questions")) {
try { questions = JSON.stringify(readQuestions(input.questions)); } catch (e) { throw new HttpError(400, (e as Error).message); }
}
let meetingUrl = base?.meetingUrl ?? "";
if (has("meetingUrl") || !base) {
try { meetingUrl = checkMeetingUrl(String(input.meetingUrl ?? "")); } catch (e) { throw new HttpError(400, (e as Error).message); }
}
const page: PageFields = {
name: has("name") || !b ? text(input, "name", LIMITS.name, true) : b.name,
title: has("title") ? text(input, "title", LIMITS.title, true) : b?.title ?? `${durationMin} minute call`,
description: has("description") ? text(input, "description", LIMITS.description) : b?.description ?? "",
durationMin,
timezone: tz,
hours,
...(daysOff !== undefined ? { daysOff } : {}),
noticeHours: has("noticeHours") || !b ? whole(input, "noticeHours", 0, LIMITS.noticeHours, DEFAULTS.noticeHours) : b.noticeHours,
horizonDays: has("horizonDays") || !b ? whole(input, "horizonDays", 1, LIMITS.horizonDays, DEFAULTS.horizonDays) : b.horizonDays,
bufferMin,
questions,
meeting: meetingKind(meetingUrl),
};
return { page, meetingUrl };
}
function publicPage(handle: string, page: Page) {
const hours = parseHours(page.hours);
return {
handle,
url: pageUrl(handle),
name: page.name,
title: page.title,
description: page.description || null,
durationMin: page.durationMin,
meeting: page.meeting,
timezone: page.timezone,
hours: JSON.parse(stringifyHours(hours)),
hoursSummary: hoursSummary(hours),
daysOff: parseDaysOff(page.daysOff).filter(([, to]) => to >= wallClock(nowSec(), page.timezone).date).map(([from, to]) => ({ from, to })),
noticeHours: page.noticeHours,
horizonDays: page.horizonDays,
bufferMin: page.bufferMin,
questions: parseQuestions(page.questions),
};
}
async function getPage(ctx: any, handle: string): Promise<Page> {
const page = handleProblem(handle) ? null : ((await ctx.bounded.get(`pages/${handle}`)) as Page | null);
if (!page) throw new HttpError(404, "No booking page at that address.");
return page;
}
/** Makes this route an owner of the page, with the page's admin key, if it is not one yet. */
async function claimOwner(ctx: any, handle: string, key: string) {
const me = ctx.user.id;
if (!(await ctx.bounded.get(`pages/${handle}/owners/${me}`))) {
await ctx.bounded.setMany([
{ path: `pageclaims/${handle}/c/${me}`, document: { key } },
{ path: `pages/${handle}/owners/${me}`, document: { at: NOW } },
]);
}
}
/** Checks the owner key in x-owner-key, then claims the page for this route. Returns the key. */
async function requireOwner(ctx: any, req: Request, handle: string): Promise<string> {
const key = (req.headers.get("x-owner-key") || "").trim();
if (!key) throw new HttpError(401, "Send the page's owner key in the x-owner-key header.");
const stored = await ctx.bounded.get(`pagekeys/${handle}`);
if (!stored || stored.key !== key) throw new HttpError(403, "That owner key does not match this page.");
await claimOwner(ctx, handle, key);
return key;
}
/** Taken cells between two instants, as a set of UTC seconds. */
async function takenCells(ctx: any, handle: string, from: number, to: number): Promise<Set<number>> {
const rows = await listAll(ctx, `pages/${handle}/slots`, { at: { $gte: from, $lt: to } });
return new Set(rows.map((r) => Number(r.at)));
}
function describeBooking(id: string, b: Booking, tz: string) {
return {
bookingId: id,
start: isoInZone(b.start, tz),
end: isoInZone(b.end, tz),
label: slotLabel(b.start, b.end, tz),
status: b.status,
...(b.status === "cancelled" ? { cancelledBy: b.cancelledBy ?? null } : {}),
...(b.movedTo ? { movedTo: b.movedTo } : {}),
name: b.name,
email: b.email,
note: b.note || null,
answers: parseAnswers(b.answers),
};
}
/** Builds the answers to the page's questions from {label: text}, a list in question order, or [{q, a}]. */
function readAnswers(input: unknown, page: Page): Answer[] {
const questions = parseQuestions(page.questions);
const given = new Map<string, string>();
if (Array.isArray(input)) {
input.forEach((x, i) => {
if (typeof x === "string" && questions[i]) given.set(questions[i].label.toLowerCase(), x);
else if (x && typeof x === "object" && typeof x.q === "string") given.set(x.q.toLowerCase(), String(x.a ?? ""));
});
} else if (input && typeof input === "object") {
for (const [k, v] of Object.entries(input)) given.set(k.toLowerCase(), String(v ?? ""));
}
return questions.map((q) => {
const a = (given.get(q.label.toLowerCase()) ?? "").trim();
if (q.required && !a) throw new HttpError(400, `Answer the question "${q.label}" (in answers).`);
if (a.length > LIMITS.answer) throw new HttpError(400, `Keep each answer under ${LIMITS.answer} characters.`);
return { q: q.label, a };
}).filter((x) => x.a);
}
/** Checks a booking's cancel key, then claims it for this route so the policy lets it read and cancel the booking. */
async function openBooking(ctx: any, req: Request, url: URL, id: string, allowOwner: boolean) {
if (!ID_RE.test(id)) throw new HttpError(404, "No booking with that id.");
const keys = await ctx.bounded.get(`bookingkeys/${id}`);
if (!keys) throw new HttpError(404, "No booking with that id.");
const handle = String(keys.handle);
const cancelKey = (req.headers.get("x-cancel-key") || url.searchParams.get("c") || "").trim();
const me = ctx.user.id;
let as: "guest" | "owner";
if (cancelKey) {
if (cancelKey !== keys.key) throw new HttpError(403, "That cancel key does not match this booking.");
await ctx.bounded.setMany([
{ path: `bookingclaims/${id}/c/${me}`, document: { key: cancelKey } },
{ path: `pages/${handle}/bookers/${me}`, document: { bookingId: id } },
]);
as = "guest";
} else if (allowOwner && req.headers.get("x-owner-key")) {
await requireOwner(ctx, req, handle);
as = "owner";
} else {
throw new HttpError(401, `Send the booking's cancel key in the x-cancel-key header${allowOwner ? ", or the page's owner key in x-owner-key" : ""}.`);
}
const booking = (await ctx.bounded.get(`pages/${handle}/bookings/${id}`)) as Booking | null;
const page = (await ctx.bounded.get(`pages/${handle}`)) as Page | null;
if (!booking || !page) throw new HttpError(404, "No booking with that id.");
const link = await ctx.bounded.get(`pagelinks/${handle}`);
return { handle, booking, page, meetingUrl: String(link?.meetingUrl ?? ""), key: String(keys.key), as };
}
interface NewBooking { start: number; name: string; email: string; note: string; answers: Answer[] }
/**
* Books an open time in one batch: the booking, the 15-minute cells it holds, its cancel key, and the mark that lets
* this caller read the meeting link. `also(bookingId)` adds writes to the same batch (a move cancels the old booking there).
*/
async function createBooking(ctx: any, handle: string, page: Page, tz: string, b: NewBooking, cancelKey: string, also: (id: string) => object[] = () => []) {
const end = b.start + page.durationMin * 60;
const heldUntil = end + page.bufferMin * 60;
const taken = await takenCells(ctx, handle, b.start, heldUntil);
if (!openSlots(page, taken, nowSec(), b.start, b.start + 1).includes(b.start)) {
throw new HttpError(409, `That time is not open. GET ${API_BASE}/pages/${handle}/availability lists the open times.`);
}
const bookingId = randomId(20);
const cells = cellsFor(b.start, heldUntil);
try {
await ctx.bounded.setMany([
{
path: `pages/${handle}/bookings/${bookingId}`,
document: { start: b.start, end, heldUntil, name: b.name, email: b.email, note: b.note, answers: JSON.stringify(b.answers), status: "booked", by: ctx.user.id, createdAt: NOW },
},
...cells.map((c, i) => ({ path: `pages/${handle}/slots/${c}`, document: { bookingId, at: c, ...(i < cells.length - 1 ? { next: c + CELL } : {}) } })),
{ path: `bookingkeys/${bookingId}`, document: { key: cancelKey, handle } },
{ path: `pages/${handle}/bookers/${ctx.user.id}`, document: { bookingId } },
...also(bookingId),
]);
} catch (err) {
if ((await takenCells(ctx, handle, b.start, heldUntil)).size) throw new HttpError(409, "Someone booked that time a moment ago. Pick another open time.");
throw err;
}
const link = await ctx.bounded.get(`pagelinks/${handle}`);
const meetingUrl = String(link?.meetingUrl ?? "");
const manage = manageUrl(handle, bookingId, cancelKey);
const event = meetingEvent(bookingId, page, { start: b.start, end, name: b.name, status: "booked" }, meetingUrl, manage);
return {
bookingId,
start: isoInZone(b.start, tz),
end: isoInZone(end, tz),
label: slotLabel(b.start, end, tz),
with: page.name,
title: page.title,
meetingUrl,
icsUrl: icsUrl(bookingId, cancelKey),
googleCalendarUrl: googleCalendarUrl(event),
manageUrl: manage,
cancelKey,
note: "Book does not send emails. Give the person the meeting link, the calendar file and the manageUrl (it is how they move or cancel the booking).",
};
}
const routes = {
name: "Book by OpenApps",
about: "A free booking page for one-on-one meetings, a free Calendly alternative. No key and no account: anyone can read a page's open times and book one. The owner key from POST /pages manages a page; the cancel key from each booking moves or cancels that booking.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"POST /pages {handle, name, timezone, meetingUrl, title?, durationMin?, hours?, daysOff?, noticeHours?, horizonDays?, bufferMin?, description?, questions?} -> url, adminUrl, ownerKey, calendarFeedUrl",
"GET /pages/:handle -> the page: who, what, how long, weekly hours",
"PATCH /pages/:handle {any of the fields above} with header x-owner-key",
"GET /pages/:handle/availability?from=YYYY-MM-DD&days=14&tz=Area/City -> open start times as ISO times in tz",
"POST /pages/:handle/bookings {start, name, email, note?, answers?} -> bookingId, meetingUrl, icsUrl, googleCalendarUrl, manageUrl, cancelKey",
"GET /pages/:handle/bookings with header x-owner-key -> upcoming, past and cancelled bookings",
"GET /pages/:handle/calendar.ics?f=<feedKey> -> the page's bookings as a calendar to subscribe to (the link is calendarFeedUrl)",
"GET /bookings/:id with header x-cancel-key -> the booking and its meeting link",
"POST /bookings/:id/reschedule {start} with header x-cancel-key -> moves the booking to another open time; same cancel key",
"POST /bookings/:id/cancel with header x-cancel-key (the guest) or x-owner-key (the page's owner) -> frees the time",
"GET /bookings/:id/ics?c=<cancelKey> -> a calendar file for the meeting",
],
};
export default async function api(req: Request, ctx: any) {
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
const url = new URL(req.url);
const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
// Accept both /pages/... and /api/pages/... so a copied path works either way.
if (parts[0] === "api") parts.shift();
try {
if (!parts.length && req.method === "GET") return json(routes);
const me = ctx.user.id;
if (parts[0] === "pages" && parts.length === 1 && req.method === "POST") {
const input = await body(req);
const { page, meetingUrl } = readPage(input);
const asked = input.handle == null || input.handle === "" ? "" : String(input.handle).trim().toLowerCase();
let handle = asked;
if (asked) {
const problem = handleProblem(asked);
if (problem) return fail(400, `handle: ${problem}`);
if (await ctx.bounded.get(`pages/${asked}`)) return fail(409, `The address ${pageUrl(asked)} is taken. Pick another handle.`);
} else {
const base = suggestHandle(page.name).slice(0, 27).replace(/-+$/, "") || "meet";
const options = [base, ...Array.from({ length: 4 }, () => `${base}-${Math.floor(1000 + Math.random() * 9000)}`)].filter((h) => !handleProblem(h));
for (const h of options) if (!(await ctx.bounded.get(`pages/${h}`))) { handle = h; break; }
if (!handle) return fail(409, "Could not find a free address. Send a handle.");
}
const ownerKey = randomId(24);
try {
await ctx.bounded.setMany([
{ path: `pages/${handle}`, document: { ...page, createdBy: me, createdAt: NOW } },
{ path: `pages/${handle}/owners/${me}`, document: { at: NOW } },
{ path: `pagekeys/${handle}`, document: { key: ownerKey } },
{ path: `pagelinks/${handle}`, document: { meetingUrl } },
]);
} catch {
if (await ctx.bounded.get(`pages/${handle}`)) return fail(409, `The address ${pageUrl(handle)} was just taken. Pick another handle.`);
throw new Error("Could not create the page.");
}
return json({
handle,
url: pageUrl(handle),
adminUrl: `${SITE_URL}/e/${handle}#k=${ownerKey}`,
ownerKey,
calendarFeedUrl: await feedUrl(handle, ownerKey),
share: `Share ${pageUrl(handle)} with anyone who should book time. Keep the adminUrl and ownerKey private: they show the bookings and change the page. calendarFeedUrl puts every booking in the owner's calendar app (subscribe to it by URL); it can only read.`,
page: publicPage(handle, { ...page, createdBy: me, createdAt: nowSec() }),
}, 201);
}
if (parts[0] === "pages" && parts.length >= 2) {
const handle = parts[1].toLowerCase();
const page = await getPage(ctx, handle);
if (parts.length === 2 && req.method === "GET") return json(publicPage(handle, page));
if (parts.length === 2 && req.method === "PATCH") {
await requireOwner(ctx, req, handle);
const input = await body(req);
const link = await ctx.bounded.get(`pagelinks/${handle}`);
const next = readPage(input, { page, meetingUrl: String(link?.meetingUrl ?? "") });
await ctx.bounded.setMany([
{ path: `pages/${handle}`, document: next.page },
...(next.meetingUrl !== link?.meetingUrl ? [{ path: `pagelinks/${handle}`, document: { meetingUrl: next.meetingUrl } }] : []),
]);
return json({ ...publicPage(handle, { ...page, ...next.page }), meetingUrl: next.meetingUrl });
}
if (parts.length === 3 && parts[2] === "availability" && req.method === "GET") {
const tz = zone(url.searchParams.get("tz") ?? url.searchParams.get("timezone"), page.timezone, "tz");
const today = wallClock(nowSec(), tz).date;
const from = url.searchParams.get("from") || today;
if (!DATE_RE.test(from)) return fail(400, `from must be a date like "${today}".`);
const days = whole(Object.fromEntries(url.searchParams), "days", 1, 62, 14);
const lo = zonedToUtc(from, "00:00", tz);
const hi = zonedToUtc(addDays(from, days), "00:00", tz);
const taken = await takenCells(ctx, handle, lo, hi + (page.durationMin + page.bufferMin) * 60);
const slots = openSlots(page, taken, nowSec(), lo, hi);
return json({
handle, title: page.title, name: page.name, durationMin: page.durationMin,
timezone: tz, zone: zoneLabel(tz, slots), from, days,
slots: slots.map((s) => isoInZone(s, tz)),
book: `POST ${API_BASE}/pages/${handle}/bookings with {"start": one of slots, "name", "email"}`,
});
}
if (parts.length === 3 && parts[2] === "bookings" && req.method === "POST") {
const input = await body(req);
const tz = zone(input.timezone, page.timezone);
const start = parseInstant(input.start, tz, "start");
const name = text(input, "name", LIMITS.name, true);
const email = text(input, "email", LIMITS.email, true);
if (!EMAIL_RE.test(email)) return fail(400, "email does not look like an email address.");
const note = text(input, "note", LIMITS.note);
const answers = readAnswers(input.answers, page);
return json(await createBooking(ctx, handle, page, tz, { start, name, email, note, answers }, randomId(24)), 201);
}
if (parts.length === 3 && parts[2] === "calendar.ics" && req.method === "GET") {
const given = (url.searchParams.get("f") || "").trim();
if (!given) throw new HttpError(401, "Add the feed key as ?f=<key>. calendarFeedUrl from POST /pages or GET /pages/<handle>/bookings is the full link.");
const stored = await ctx.bounded.get(`pagekeys/${handle}`);
if (!stored || given !== (await feedKey(String(stored.key)))) throw new HttpError(403, "That feed key does not match this page.");
await claimOwner(ctx, handle, String(stored.key));
const rows = (await listAll(ctx, `pages/${handle}/bookings`)) as (Booking & { id: string })[];
const link = await ctx.bounded.get(`pagelinks/${handle}`);
const events = rows.filter((b) => b.status === "booked").map((b) => meetingEvent(b.id, page, b, String(link?.meetingUrl ?? ""), `${SITE_URL}/e/${handle}`, true));
return new Response(icsFeed(`${page.title}: bookings`, events), {
headers: { "content-type": "text/calendar; charset=utf-8", "cache-control": "no-store", ...cors },
});
}
if (parts.length === 3 && parts[2] === "bookings" && req.method === "GET") {
const ownerKey = await requireOwner(ctx, req, handle);
const rows = (await listAll(ctx, `pages/${handle}/bookings`)) as (Booking & { id: string })[];
const now = nowSec();
const tz = page.timezone;
const upcoming = rows.filter((b) => b.status === "booked" && b.end > now).sort((a, b) => a.start - b.start);
const past = rows.filter((b) => b.status === "booked" && b.end <= now).sort((a, b) => b.start - a.start);
const cancelled = rows.filter((b) => b.status === "cancelled").sort((a, b) => b.start - a.start);
return json({
handle, timezone: tz,
calendarFeedUrl: await feedUrl(handle, ownerKey),
upcoming: upcoming.map((b) => describeBooking(b.id, b, tz)),
past: past.map((b) => describeBooking(b.id, b, tz)),
cancelled: cancelled.map((b) => describeBooking(b.id, b, tz)),
});
}
}
if (parts[0] === "bookings" && parts.length >= 2) {
const id = parts[1];
if (parts.length === 2 && req.method === "GET") {
const { handle, booking, page, meetingUrl, key } = await openBooking(ctx, req, url, id, false);
const tz = page.timezone;
return json({
...describeBooking(id, booking, tz),
page: { handle, url: pageUrl(handle), name: page.name, title: page.title, timezone: tz },
meetingUrl: booking.status === "booked" ? meetingUrl : null,
icsUrl: icsUrl(id, key),
manageUrl: manageUrl(handle, id, key),
});
}
if (parts.length === 3 && parts[2] === "ics" && req.method === "GET") {
const { handle, booking, page, meetingUrl, key } = await openBooking(ctx, req, url, id, false);
const event = meetingEvent(id, page, booking, meetingUrl, manageUrl(handle, id, key));
return new Response(icsFile(event), {
headers: { "content-type": "text/calendar; charset=utf-8", "content-disposition": `attachment; filename="${fileName(event.title)}.ics"`, ...cors },
});
}
if (parts.length === 3 && parts[2] === "reschedule" && req.method === "POST") {
const { handle, booking, page, key } = await openBooking(ctx, req, url, id, false);
if (booking.status === "cancelled") {
return fail(409, booking.movedTo ? `This booking was already moved. Its new bookingId is ${booking.movedTo}, with the same cancel key.` : "This booking is cancelled. Book a new time with POST /pages/<handle>/bookings.");
}
if (booking.end <= nowSec()) return fail(409, "This meeting is over, so it cannot be moved.");
const input = await body(req);
const tz = zone(input.timezone, page.timezone);
const start = parseInstant(input.start, tz, "start");
const moved = await createBooking(ctx, handle, page, tz, {
start, name: booking.name, email: booking.email, note: booking.note, answers: parseAnswers(booking.answers),
}, key, (newId) => [
{ path: `pages/${handle}/bookings/${id}`, document: { status: "cancelled", cancelledBy: "guest", cancelledAt: NOW, movedTo: newId } },
...cellsFor(booking.start, booking.heldUntil).map((c) => ({ path: `pages/${handle}/slots/${c}`, document: null })),
]);
return json({ ...moved, movedFrom: id }, 201);
}
if (parts.length === 3 && parts[2] === "cancel" && req.method === "POST") {
const { handle, booking, page, as } = await openBooking(ctx, req, url, id, true);
if (booking.status === "cancelled") return fail(409, "This booking is already cancelled.");
if (booking.end <= nowSec()) return fail(409, "This meeting is over, so it cannot be cancelled.");
await ctx.bounded.setMany([
{ path: `pages/${handle}/bookings/${id}`, document: { status: "cancelled", cancelledBy: as, cancelledAt: NOW } },
...cellsFor(booking.start, booking.heldUntil).map((c) => ({ path: `pages/${handle}/slots/${c}`, document: null })),
]);
return json({ ...describeBooking(id, { ...booking, status: "cancelled", cancelledBy: as }, page.timezone), freed: true });
}
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
if (err instanceof HttpError) return fail(err.status, err.message);
const message = err instanceof Error ? err.message : String(err);
return fail(500, `Something went wrong: ${message}`);
}
}
Change history
Add favicon.ico and link tag
Quality pass: guest reschedule, days off, calendar feed, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync