when-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 2315d847681e
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 3e1cb86700a5e96238d7a2f1
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
// When's public HTTP API: no account. A poll's id is its secret, exactly like its link, and the
// owner key (handed back once, at creation) is what lets a caller manage the poll. 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 owner actions always check the key first.
import { SITE_URL, API_BASE } from "./lib/config";
import {
  parseOptions, inDayOrder, stringifyOptions, keepIds, parseAnswers, tally, bestOptions, validTimeZone, zonedToUtc, isoInZone,
  dateToUtcDay, utcDayToDate, optionLabel, icsFile, googleCalendarUrl, outlookCalendarUrl, fileName, randomId, LIMITS, ANSWERS,
  type Answer, type Poll, type PollOption, type Vote, type Comment,
} from "./lib/when";

const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{8,32}$/;

const cors = {
  "access-control-allow-origin": "*",
  "access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
  "access-control-allow-headers": "content-type, x-owner-key, idempotency-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): 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, ...(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;
}

/** Joins the poll as this route (knowing the id is the permission), then reads it, its answers and its comments. */
async function openPoll(ctx: any, pollId: string) {
  if (!ID_RE.test(pollId)) throw new HttpError(404, "No poll with that id.");
  const me = ctx.user.id;
  const joined = await ctx.bounded.get(`polls/${pollId}/joins/${me}`);
  if (!joined) {
    try {
      await ctx.bounded.set(`polls/${pollId}/joins/${me}`, { at: NOW });
    } catch {
      throw new HttpError(404, "No poll with that id.");
    }
  }
  const poll = (await ctx.bounded.get(`polls/${pollId}`)) as Poll | null;
  if (!poll) throw new HttpError(404, "No poll with that id.");
  const [votes, comments] = await Promise.all([listAll(ctx, `polls/${pollId}/votes`), listAll(ctx, `polls/${pollId}/comments`)]);
  return { poll, votes: votes as Vote[], comments: comments as Comment[] };
}

/** Checks the owner key, then makes this route an owner of the poll if it is not one yet. */
async function requireOwner(ctx: any, req: Request, pollId: string) {
  const key = (req.headers.get("x-owner-key") || "").trim();
  if (!key) throw new HttpError(401, "Send the poll's owner key in the x-owner-key header.");
  const stored = await ctx.bounded.get(`pollkeys/${pollId}`);
  if (!stored || stored.key !== key) throw new HttpError(403, "That owner key does not match this poll.");
  const me = ctx.user.id;
  if (!(await ctx.bounded.get(`polls/${pollId}/owners/${me}`))) {
    await ctx.bounded.setMany([
      { path: `pollclaims/${pollId}/c/${me}`, document: { key } },
      { path: `polls/${pollId}/owners/${me}`, document: { at: NOW } },
    ]);
  }
}

const pollUrl = (pollId: string) => `${SITE_URL}/p/${pollId}`;

const isoTime = (sec: number) => new Date(sec * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");

function summary(pollId: string, poll: Poll, votes: Vote[], comments: Comment[]) {
  const tz = poll.timezone;
  const options = inDayOrder(parseOptions(poll.options), tz);
  const counts = tally(options, votes);
  const best = bestOptions(options, counts);
  const url = pollUrl(pollId);
  const describe = (o: PollOption) => ({
    id: o.id,
    ...(o.allDay
      ? { date: utcDayToDate(o.start), allDay: true }
      : { start: isoInZone(o.start, tz), ...(o.end != null ? { end: isoInZone(o.end, tz) } : {}) }),
    label: optionLabel(o, tz),
    ...counts[o.id],
    best: best.includes(o.id),
  });
  const chosen = options.find((o) => o.id === poll.chosen);
  return {
    poll: {
      id: pollId, title: poll.title, description: poll.description ?? null, location: poll.location ?? null,
      timezone: tz, url, status: chosen ? "decided" : poll.closed ? "closed" : "open",
    },
    options: options.map(describe),
    participants: [...votes].sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0)).map((v) => {
      const a = parseAnswers(v.answers);
      return { name: v.name, answers: Object.fromEntries(options.filter((o) => a[o.id]).map((o) => [o.id, a[o.id]])) };
    }),
    best,
    chosen: chosen
      ? { ...describe(chosen), ics: `${API_BASE}/polls/${pollId}/ics`, googleCalendar: googleCalendarUrl(poll, chosen, url), outlookCalendar: outlookCalendarUrl(poll, chosen, url) }
      : null,
    comments: [...comments].sort((a, b) => (a.at ?? 0) - (b.at ?? 0)).map((c) => ({ name: c.name, text: c.text, at: isoTime(c.at ?? 0) })),
  };
}

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})?$/;

/** An ISO time as UTC seconds. Without an offset it is read in the poll's 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);
}

/** Turns one friendly option (an ISO time, a date, or {start, end} / {date, allDay}) into a stored slot. */
function parseOption(input: unknown, tz: string): Omit<PollOption, "id"> {
  if (typeof input === "string") {
    if (DATE_RE.test(input.trim())) return { start: dateToUtcDay(input.trim()), allDay: true };
    return { start: parseInstant(input, tz, "Each option") };
  }
  if (input && typeof input === "object" && !Array.isArray(input)) {
    const o = input as Record<string, unknown>;
    if (o.date != null && (o.allDay || o.start == null)) {
      if (!DATE_RE.test(String(o.date))) throw new HttpError(400, `date must look like "2026-10-22".`);
      return { start: dateToUtcDay(String(o.date)), allDay: true };
    }
    if (typeof o.start === "string" && DATE_RE.test(o.start.trim())) return { start: dateToUtcDay(o.start.trim()), allDay: true };
    const start = parseInstant(o.start, tz, "start");
    if (o.end == null || o.end === "") return { start };
    const end = parseInstant(o.end, tz, "end");
    if (end <= start) throw new HttpError(400, "Each option's end must be after its start.");
    if (end - start > 7 * 86400) throw new HttpError(400, "An option can last at most a week.");
    return { start, end };
  }
  throw new HttpError(400, `Each option is an ISO time ("2026-10-21T15:00:00-04:00"), a date ("2026-10-22") for all day, or {"start", "end"}.`);
}

function parseOptionList(list: unknown, tz: string, existing: PollOption[]): PollOption[] {
  if (!Array.isArray(list) || !list.length) throw new HttpError(400, "options must be a list with at least one time.");
  if (list.length > LIMITS.options) throw new HttpError(400, `A poll can have up to ${LIMITS.options} time options.`);
  return keepIds(list.map((x) => parseOption(x, tz)), existing);
}

/** Finds an option by id, or by its start time ("2026-10-21T15:00:00-04:00") or date ("2026-10-22"). */
function findOption(options: PollOption[], ref: string, tz: string): PollOption | undefined {
  const byId = options.find((o) => o.id === ref);
  if (byId) return byId;
  try {
    const slot = parseOption(ref, tz);
    return options.find((o) => Boolean(o.allDay) === Boolean(slot.allDay) && o.start === slot.start);
  } catch {
    return undefined;
  }
}

function parseAnswer(value: unknown): Answer | null {
  const s = String(value ?? "").trim().toLowerCase().replace(/[\s_-]+/g, "");
  if (s === "ifneedbe") return "maybe";
  return ANSWERS.includes(s as Answer) ? (s as Answer) : null;
}

function text(input: Record<string, any>, field: string, max: number, required = false): string | undefined {
  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 || undefined;
}

const routes = {
  name: "When by OpenApps",
  about: "Free group scheduling polls. No key and no account: a poll's id is its secret, like its link. The owner key from POST /polls manages the poll.",
  docs: `${SITE_URL}/llms.txt`,
  openapi: `${SITE_URL}/openapi.json`,
  routes: [
    "POST /polls {title, description?, location?, timezone?, options: [\"2026-10-21T15:00:00-04:00\", {start, end}, {date, allDay: true}]} -> pollId, url, adminUrl, ownerKey",
    "GET /polls/:id -> the poll, each option with yes/maybe/no counts, participants and their answers, the best option(s), the chosen time, comments",
    "POST /polls/:id/votes {name, answers: {<optionId or start time>: \"yes\" | \"maybe\" | \"no\"}, voteId?} -> voteId",
    "POST /polls/:id/comments {name, text} -> adds a comment everyone with the link sees",
    "PATCH /polls/:id {title?, description?, location?, timezone?, options?} with header x-owner-key",
    "POST /polls/:id/choose {optionId} with header x-owner-key -> the final time (closes the poll)",
    "POST /polls/:id/close and POST /polls/:id/reopen with header x-owner-key",
    "GET /polls/:id/ics -> a calendar file for the chosen time (404 until one is chosen)",
  ],
};

export default async function api(req: Request, ctx: any) {
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
  const parts = new URL(req.url).pathname.split("/").filter(Boolean);
  // Accept both /polls/... and /api/polls/... so a copied path works either way.
  if (parts[0] === "api") parts.shift();
  try {
    if (!parts.length && req.method === "GET") return json(routes);
    if (parts[0] !== "polls") return fail(404, "Unknown route. GET / lists the routes.");
    const me = ctx.user.id;

    if (parts.length === 1 && req.method === "POST") {
      const input = await body(req);
      const title = text(input, "title", LIMITS.title, true)!;
      const description = text(input, "description", LIMITS.description);
      const location = text(input, "location", LIMITS.location);
      const timezone = input.timezone == null || input.timezone === "" ? "UTC" : String(input.timezone).trim();
      if (!validTimeZone(timezone)) return fail(400, `timezone must be an IANA time zone like "America/New_York".`);
      const options = parseOptionList(input.options, timezone, []);
      const pollId = randomId(20);
      const ownerKey = randomId(24);
      const poll = { title, ...(description ? { description } : {}), ...(location ? { location } : {}), timezone, options: stringifyOptions(options), closed: false };
      await ctx.bounded.setMany([
        { path: `polls/${pollId}`, document: { ...poll, createdBy: me, createdAt: NOW } },
        { path: `polls/${pollId}/joins/${me}`, document: { at: NOW } },
        { path: `polls/${pollId}/owners/${me}`, document: { at: NOW } },
        { path: `pollkeys/${pollId}`, document: { key: ownerKey } },
      ]);
      return json({
        pollId,
        url: pollUrl(pollId),
        adminUrl: `${pollUrl(pollId)}/admin#k=${ownerKey}`,
        ownerKey,
        share: `Send ${pollUrl(pollId)} to the group. Keep the adminUrl and ownerKey private: they let you edit the poll and pick the final time.`,
        ...summary(pollId, { ...poll, createdBy: me, createdAt: Math.floor(Date.now() / 1000) }, [], []),
      }, 201);
    }

    const pollId = parts[1];
    const { poll, votes, comments } = await openPoll(ctx, pollId);
    const options = parseOptions(poll.options);

    if (parts.length === 2 && req.method === "GET") return json(summary(pollId, poll, votes, comments));

    if (parts.length === 3 && parts[2] === "ics" && req.method === "GET") {
      const chosen = options.find((o) => o.id === poll.chosen);
      if (!chosen) return fail(404, "No final time has been picked for this poll yet.");
      return new Response(icsFile(pollId, poll, chosen, pollUrl(pollId)), {
        headers: {
          "content-type": "text/calendar; charset=utf-8",
          "content-disposition": `attachment; filename="${fileName(poll.title)}.ics"`,
          ...cors,
        },
      });
    }

    if (parts.length === 3 && parts[2] === "votes" && req.method === "POST") {
      const input = await body(req);
      if (poll.closed) return fail(409, "This poll is closed, so answers can no longer change.");
      const name = text(input, "name", LIMITS.name, true)!;
      if (!input.answers || typeof input.answers !== "object" || Array.isArray(input.answers)) {
        return fail(400, `answers must be an object of option id (or start time) to "yes", "maybe" or "no".`);
      }
      const answers: Record<string, Answer> = Object.fromEntries(options.map((o) => [o.id, "no" as Answer]));
      for (const [ref, value] of Object.entries(input.answers)) {
        const option = findOption(options, ref, poll.timezone);
        if (!option) return fail(400, `"${ref}" is not one of this poll's options. GET /polls/${pollId} lists them.`);
        const answer = parseAnswer(value);
        if (!answer) return fail(400, `The answer for "${ref}" must be "yes", "maybe" or "no".`);
        answers[option.id] = answer;
      }
      let voteId = input.voteId == null || input.voteId === "" ? null : String(input.voteId);
      if (voteId) {
        const existing = votes.find((v) => v.id === voteId);
        if (!existing) return fail(404, "No answers with that voteId in this poll.");
        if (existing.by !== me) return fail(403, "That row was added in the web app, so only its author can change it there.");
      }
      voteId ??= randomId(16);
      await ctx.bounded.set(`polls/${pollId}/votes/${voteId}`, { name, answers: JSON.stringify(answers), by: me, updatedAt: NOW });
      const updated = [...votes.filter((v) => v.id !== voteId), { id: voteId, name, answers: JSON.stringify(answers), by: me, updatedAt: Math.floor(Date.now() / 1000) }];
      return json({ voteId, ...summary(pollId, poll, updated, comments) }, input.voteId ? 200 : 201);
    }

    if (parts.length === 3 && parts[2] === "comments" && req.method === "POST") {
      const input = await body(req);
      const name = text(input, "name", LIMITS.name, true)!;
      const said = text(input, "text", LIMITS.comment, true)!;
      const commentId = randomId(16);
      await ctx.bounded.set(`polls/${pollId}/comments/${commentId}`, { name, text: said, by: me, at: NOW });
      const added = { id: commentId, name, text: said, by: me, at: Math.floor(Date.now() / 1000) };
      return json({ commentId, ...summary(pollId, poll, votes, [...comments, added]) }, 201);
    }

    if (parts.length === 2 && req.method === "PATCH") {
      await requireOwner(ctx, req, pollId);
      const input = await body(req);
      const next: Record<string, unknown> = {};
      if ("title" in input) next.title = text(input, "title", LIMITS.title, true);
      if ("description" in input) next.description = text(input, "description", LIMITS.description) ?? null;
      if ("location" in input) next.location = text(input, "location", LIMITS.location) ?? null;
      const timezone = input.timezone == null ? poll.timezone : String(input.timezone).trim();
      if (!validTimeZone(timezone)) return fail(400, `timezone must be an IANA time zone like "America/New_York".`);
      next.timezone = timezone;
      if ("options" in input) {
        const list = parseOptionList(input.options, timezone, options);
        next.options = stringifyOptions(list);
        if (poll.chosen && !list.some((o) => o.id === poll.chosen)) next.chosen = null;
      }
      await ctx.bounded.set(`polls/${pollId}`, next);
      return json(summary(pollId, { ...poll, ...next } as Poll, votes, comments));
    }

    if (parts.length === 3 && ["choose", "close", "reopen"].includes(parts[2]) && req.method === "POST") {
      await requireOwner(ctx, req, pollId);
      let next: Partial<Poll>;
      if (parts[2] === "choose") {
        const input = await body(req);
        const ref = String(input.optionId ?? input.option ?? "").trim();
        const option = ref ? findOption(options, ref, poll.timezone) : undefined;
        if (!option) return fail(400, `optionId must be one of this poll's option ids. GET /polls/${pollId} lists them.`);
        next = { chosen: option.id, closed: true };
      } else {
        next = parts[2] === "close" ? { closed: true } : { closed: false, chosen: null };
      }
      await ctx.bounded.set(`polls/${pollId}`, next);
      return json(summary(pollId, { ...poll, ...next }, votes, comments));
    }

    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

Quality pass: comments, CSV export, share the final time, calendar menu, fixes
2315d84768 · 4 hours ago
Bounded source sync
145324066b · 1 day ago
Bounded source sync
ad9b674dac · 1 day ago
Bounded source sync
e5b69608f3 · 1 day ago
Bounded source sync
a1685914b0 · 1 day ago
Bounded source sync
7108f42d4d · 1 day ago
Bounded source sync
ffb51b672e · 1 day ago
Bounded source sync
23b5d0c53b · 1 day ago