book-free.openapps.xyz

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.
Owned app · rev f8f1b3a80195
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.
← All files
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
f8f1b3a801 · 2 hours ago
Quality pass: guest reschedule, days off, calendar feed, API docs
c827e5d073 · 5 hours ago
Bounded source sync
68e67b4092 · 1 day ago
Bounded source sync
ab2e006f9b · 1 day ago
Bounded source sync
bb10824b54 · 1 day ago
Bounded source sync
d22f765fb7 · 1 day ago
Bounded source sync
30b4b06513 · 1 day ago
Bounded source sync
c3d297477a · 1 day ago
Bounded source sync
7b18317417 · 1 day ago