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 cf614aedcf4cdabfa9c45095
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/lib/forms.ts
// Forms' shared logic, used by the web app and the public API so both accept and refuse the same things:
// question types, checking a form, checking answers, results summaries and CSV.
export const QUESTION_TYPES = {
short_text: "Short answer",
long_text: "Paragraph",
email: "Email",
number: "Number",
date: "Date",
choice: "Multiple choice",
checkboxes: "Checkboxes",
dropdown: "Dropdown",
rating: "Rating, 1 to 5",
scale: "Scale, 0 to 10",
yes_no: "Yes or no",
} as const;
export type QuestionType = keyof typeof QUESTION_TYPES;
export const OPTION_TYPES: readonly QuestionType[] = ["choice", "checkboxes", "dropdown"];
/** Questions whose answer can decide whether a later question shows. */
export const CONDITION_TYPES: readonly QuestionType[] = ["choice", "checkboxes", "dropdown", "yes_no"];
/** Show a question only when an earlier question's answer is (or, for checkboxes, includes) this option. */
export interface Condition { question: string; is: string }
export interface Question {
id: string;
type: QuestionType;
label: string;
help?: string;
required?: boolean;
options?: string[];
showIf?: Condition;
}
/** The answers a condition can match: the options, or yes and no. */
export const conditionValues = (q: Question): string[] => (q.type === "yes_no" ? ["yes", "no"] : q.options || []);
export type Answer = string | number | string[];
export type Answers = Record<string, Answer>;
/** A form as stored. questions is a JSON array of Question. */
export interface FormDoc {
title: string;
description: string;
questions: string;
thankYou: string;
closed?: boolean | null;
createdBy: string;
createdAt: number;
}
/** A response as stored. answers is a JSON object of question id (or field name) to answer. */
export interface ResponseDoc { id: string; answers: string; createdAt: number; by: string; _createdAt?: number }
/** A parsed response. at is milliseconds (the stored write time), which orders responses sent in the same second. */
export interface Submission { id: string; createdAt: number; at: number; answers: Answers }
/** Newest first. */
export const newestFirst = (a: Submission, b: Submission) => b.at - a.at;
export const LIMITS = {
title: 200,
description: 2000,
thankYou: 1000,
questions: 100,
label: 300,
help: 500,
options: 50,
option: 200,
shortText: 1000,
longText: 10000,
email: 254,
freeFields: 50,
freeKey: 100,
freeValue: 2000,
questionsJson: 100000,
answersJson: 120000,
};
const clean = (v: unknown) => (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v).trim() : "");
const TYPE_ALIASES: Record<string, QuestionType> = {
text: "short_text", short: "short_text", short_answer: "short_text", string: "short_text",
long: "long_text", paragraph: "long_text", textarea: "long_text",
radio: "choice", single: "choice", single_choice: "choice", multiple_choice: "choice",
checkbox: "checkboxes", multi: "checkboxes", multiple: "checkboxes", multi_choice: "checkboxes",
select: "dropdown",
stars: "rating",
opinion_scale: "scale", nps: "scale", net_promoter_score: "scale",
yesno: "yes_no", boolean: "yes_no", bool: "yes_no",
};
function questionType(raw: unknown): QuestionType | null {
const t = clean(raw).toLowerCase().replace(/[\s-]+/g, "_");
if (t in QUESTION_TYPES) return t as QuestionType;
return TYPE_ALIASES[t] ?? null;
}
const ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
/** Checks a list of questions from the builder or the API. Returns the cleaned list or an error sentence. */
export function checkQuestions(input: unknown): { questions: Question[] } | { error: string } {
if (input == null) return { questions: [] };
if (!Array.isArray(input)) return { error: "questions must be a list." };
if (input.length > LIMITS.questions) return { error: `A form can have up to ${LIMITS.questions} questions.` };
const used = new Set<string>();
const questions: Question[] = [];
for (const [i, raw] of input.entries()) {
const n = `Question ${i + 1}`;
if (!raw || typeof raw !== "object") return { error: `${n} must be an object with a type and a label.` };
const r = raw as Record<string, unknown>;
const type = questionType(r.type ?? "short_text");
if (!type) return { error: `${n}: type must be one of ${Object.keys(QUESTION_TYPES).join(", ")}.` };
const label = clean(r.label ?? r.title ?? r.question);
if (!label) return { error: `${n} needs a label.` };
if (label.length > LIMITS.label) return { error: `${n}: keep the label under ${LIMITS.label} characters.` };
const help = clean(r.help ?? r.description);
if (help.length > LIMITS.help) return { error: `${n}: keep the help text under ${LIMITS.help} characters.` };
let id = typeof r.id === "string" && ID_RE.test(r.id) && !used.has(r.id) ? r.id : "";
while (!id || used.has(id)) id = randomId(8);
used.add(id);
const q: Question = { id, type, label, required: r.required === true || r.required === "true" };
if (help) q.help = help;
if (OPTION_TYPES.includes(type)) {
const seen = new Set<string>();
const options: string[] = [];
for (const o of Array.isArray(r.options) ? r.options : []) {
const s = clean(o);
if (!s || seen.has(s.toLowerCase())) continue;
if (s.length > LIMITS.option) return { error: `${n}: keep each option under ${LIMITS.option} characters.` };
seen.add(s.toLowerCase());
options.push(s);
}
const min = type === "checkboxes" ? 1 : 2;
if (options.length < min) return { error: `${n} needs at least ${min === 1 ? "one option" : "two options"}.` };
if (options.length > LIMITS.options) return { error: `${n} can have up to ${LIMITS.options} options.` };
q.options = options;
}
if (r.showIf) {
// Only an earlier question can decide, so people always answer it first and conditions never loop.
const cond = (typeof r.showIf === "object" ? r.showIf : {}) as Record<string, unknown>;
const ref = clean(cond.question);
const target = questions.find((x) => x.id === ref) || questions.find((x) => x.label.toLowerCase() === ref.toLowerCase());
if (!target) return { error: `${n}: showIf.question must be the id or label of an earlier question.` };
if (!CONDITION_TYPES.includes(target.type)) return { error: `${n}: showIf can only use a multiple choice, checkboxes, dropdown or yes or no question.` };
const is = clean(cond.is ?? cond.equals ?? cond.value).toLowerCase();
const match = conditionValues(target).find((o) => o.toLowerCase() === is);
if (!match) return { error: `${n}: showIf.is must be one of: ${conditionValues(target).join(", ")}.` };
q.showIf = { question: target.id, is: match };
}
questions.push(q);
}
if (JSON.stringify(questions).length > LIMITS.questionsJson) return { error: "The questions are too long altogether. Shorten some labels or options." };
return { questions };
}
/** Checks the text parts of a form. Missing parts keep their current value. */
export function checkFormText(input: { title?: unknown; description?: unknown; thankYou?: unknown }, current?: { title: string; description: string; thankYou: string }):
{ title: string; description: string; thankYou: string } | { error: string } {
const title = input.title === undefined && current ? current.title : clean(input.title);
const description = input.description === undefined && current ? current.description : clean(input.description);
const thankYou = input.thankYou === undefined && current ? current.thankYou : clean(input.thankYou);
if (!title) return { error: "Give the form a title." };
if (title.length > LIMITS.title) return { error: `Keep the title under ${LIMITS.title} characters.` };
if (description.length > LIMITS.description) return { error: `Keep the description under ${LIMITS.description} characters.` };
if (thankYou.length > LIMITS.thankYou) return { error: `Keep the thank-you message under ${LIMITS.thankYou} characters.` };
return { title, description, thankYou };
}
export function parseQuestions(raw: string | null | undefined): Question[] {
try {
const list = JSON.parse(raw || "[]");
return Array.isArray(list) ? list.filter((q) => q && typeof q.id === "string" && q.type in QUESTION_TYPES) : [];
} catch {
return [];
}
}
function isEmpty(v: unknown): boolean {
if (v == null) return true;
if (typeof v === "string") return !v.trim();
if (Array.isArray(v)) return v.every(isEmpty);
return false;
}
function matchOption(options: string[], v: unknown): string | undefined {
const s = clean(v).toLowerCase();
return options.find((o) => o.toLowerCase() === s);
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Checks one answer. value is the stored form (undefined when left empty). */
export function checkAnswer(q: Question, raw: unknown): { value?: Answer; error?: string } {
if (isEmpty(raw)) return q.required ? { error: "This question is required." } : {};
const one = Array.isArray(raw) ? raw.filter((v) => !isEmpty(v)) : [raw];
const text = one.map(clean).join(", ");
switch (q.type) {
case "short_text":
case "long_text": {
const max = q.type === "short_text" ? LIMITS.shortText : LIMITS.longText;
if (one.some((v) => typeof v === "object")) return { error: "Answer with text." };
if (text.length > max) return { error: `Keep it under ${max.toLocaleString("en-US")} characters.` };
return { value: text };
}
case "email":
if (text.length > LIMITS.email || !EMAIL_RE.test(text)) return { error: "Enter a valid email address." };
return { value: text };
case "number": {
const s = text.replace(/[\s_]/g, "");
const n = Number(s);
if (!/^-?\d+(\.\d+)?$/.test(s) || !Number.isFinite(n)) return { error: "Enter a number." };
return { value: n };
}
case "date": {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
const d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
if (!m || !d || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) return { error: "Enter a date like 2026-10-18." };
return { value: `${m[1]}-${m[2]}-${m[3]}` };
}
case "choice":
case "dropdown": {
const o = matchOption(q.options || [], text);
if (!o) return { error: `Pick one of: ${(q.options || []).join(", ")}.` };
return { value: o };
}
case "checkboxes": {
const options = q.options || [];
const picked = new Set<string>();
for (const v of one) {
const exact = matchOption(options, v);
const parts = exact ? [exact] : clean(v).split(",").map((s) => matchOption(options, s));
for (const p of parts) {
if (!p) return { error: `Pick from: ${options.join(", ")}.` };
picked.add(p);
}
}
return { value: options.filter((o) => picked.has(o)) };
}
case "rating": {
const n = Number(text);
if (!Number.isInteger(n) || n < 1 || n > 5) return { error: "Pick a rating from 1 to 5." };
return { value: n };
}
case "scale": {
const n = Number(text);
if (!text || !Number.isInteger(n) || n < 0 || n > 10) return { error: "Pick a number from 0 to 10." };
return { value: n };
}
case "yes_no": {
const s = text.toLowerCase();
if (["yes", "y", "true", "on", "1"].includes(s)) return { value: "yes" };
if (["no", "n", "false", "off", "0"].includes(s)) return { value: "no" };
return { error: "Answer yes or no." };
}
}
}
/** Whether a question shows, given the answers to the questions before it (answers to hidden questions left out). */
export function isShown(q: Question, answers: Record<string, unknown>): boolean {
if (!q.showIf) return true;
const v = answers[q.showIf.question];
const want = q.showIf.is.toLowerCase();
return (Array.isArray(v) ? v : [v]).some((x) => typeof x === "string" && x.toLowerCase() === want);
}
/** Matches submitted keys (question ids, or labels in any case) to questions. Returns values by question id and the keys that matched nothing. */
export function matchKeys(questions: Question[], input: Record<string, unknown>) {
const raw: Record<string, unknown> = {};
const ignored: string[] = [];
for (const [key, v] of Object.entries(input)) {
const k = key.trim().toLowerCase();
const q = questions.find((x) => x.id === key) || questions.find((x) => x.label.toLowerCase() === k && !(x.id in raw));
if (q && !(q.id in raw)) raw[q.id] = v;
else if (key !== "_gotcha") ignored.push(key);
}
return { raw, ignored };
}
/**
* Checks a whole submission against the questions. Keys may be question ids or labels (any case).
* Returns the answers keyed by question id, an error per question id, and the keys that were not saved:
* ones that matched nothing, and answers to questions that the other answers hide.
*/
export function checkAnswers(questions: Question[], input: Record<string, unknown>) {
const { raw, ignored } = matchKeys(questions, input);
const answers: Answers = {};
const errors: Record<string, string> = {};
for (const q of questions) {
if (!isShown(q, answers)) {
if (!isEmpty(raw[q.id])) ignored.push(q.label);
continue;
}
const { value, error } = checkAnswer(q, raw[q.id]);
if (error) errors[q.id] = error;
else if (value !== undefined) answers[q.id] = value;
}
if (!Object.keys(errors).length && JSON.stringify(answers).length > LIMITS.answersJson) errors._form = "These answers are too long altogether.";
return { answers, errors, ignored };
}
/** For a form with no questions (a website endpoint): keeps the fields as given. */
export function checkFreeFields(input: Record<string, unknown>): { answers: Answers } | { error: string } {
const entries = Object.entries(input).filter(([k]) => k !== "_gotcha");
if (!entries.length) return { error: "Send at least one field." };
if (entries.length > LIMITS.freeFields) return { error: `Send up to ${LIMITS.freeFields} fields.` };
const answers: Answers = {};
for (const [key, v] of entries) {
const k = key.trim();
if (!k || k.length > LIMITS.freeKey) return { error: `Field names must be 1 to ${LIMITS.freeKey} characters.` };
const value = Array.isArray(v) ? v.map((x) => (typeof x === "object" ? JSON.stringify(x) : String(x ?? ""))).join(", ")
: v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
if (value.length > LIMITS.freeValue) return { error: `The field "${k}" is longer than ${LIMITS.freeValue.toLocaleString("en-US")} characters.` };
answers[k] = value;
}
return { answers };
}
export function parseSubmission(row: ResponseDoc): Submission {
let answers: Answers = {};
try {
const parsed = JSON.parse(row.answers);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) answers = parsed;
} catch { /* keep empty */ }
return { id: row.id, createdAt: row.createdAt, at: row._createdAt ?? row.createdAt * 1000, answers };
}
export function formatAnswer(v: Answer | undefined): string {
if (v == null) return "";
if (Array.isArray(v)) return v.join(", ");
if (v === "yes") return "Yes";
if (v === "no") return "No";
return String(v);
}
export interface Column { key: string; label: string; question?: Question }
/** One column per question, then any field found in responses that no current question owns. */
export function columns(questions: Question[], subs: Submission[]): Column[] {
const cols: Column[] = questions.map((q) => ({ key: q.id, label: q.label, question: q }));
const known = new Set(cols.map((c) => c.key));
for (const s of [...subs].sort((a, b) => a.at - b.at)) {
for (const key of Object.keys(s.answers)) {
if (known.has(key)) continue;
known.add(key);
cols.push({ key, label: questions.length ? `Removed question (${key})` : key });
}
}
return cols;
}
export interface Summary {
key: string;
label: string;
type: QuestionType | "field";
answered: number;
/** Choice-like questions: count and percent of people who answered. */
options?: { option: string; count: number; percent: number }[];
/** Rating, scale and number questions. */
average?: number;
/** Rating: how many picked 1 to 5. Scale: how many picked 0 to 10. */
distribution?: number[];
/** Scale: percent who picked 9 or 10 minus percent who picked 0 to 6 (the Net Promoter Score). */
netScore?: number;
/** Text-like questions: the latest answers, newest first. */
recent?: string[];
}
const pct = (n: number, of: number) => (of ? Math.round((n / of) * 100) : 0);
/** A summary per column. subs should be newest first so recent answers come first. */
export function summarize(questions: Question[], subs: Submission[], recentCount = 5): Summary[] {
return columns(questions, subs).map(({ key, label, question: q }) => {
const values = subs.map((s) => s.answers[key]).filter((v) => !isEmpty(v));
const base: Summary = { key, label, type: q?.type ?? "field", answered: values.length };
if (q && (OPTION_TYPES.includes(q.type) || q.type === "yes_no")) {
const options = q.type === "yes_no" ? ["yes", "no"] : q.options || [];
const counts = new Map(options.map((o) => [o, 0]));
for (const v of values) for (const o of Array.isArray(v) ? v : [String(v)]) counts.set(o, (counts.get(o) || 0) + 1);
base.options = [...counts].map(([o, count]) => ({ option: formatAnswer(o), count, percent: pct(count, values.length) }));
} else if (q && (q.type === "rating" || q.type === "scale" || q.type === "number")) {
const nums = values.map(Number).filter(Number.isFinite);
if (nums.length) base.average = Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100;
if (q.type === "rating") base.distribution = [1, 2, 3, 4, 5].map((r) => nums.filter((n) => n === r).length);
else if (q.type === "scale") {
base.distribution = Array.from({ length: 11 }, (_, r) => nums.filter((n) => n === r).length);
if (nums.length) base.netScore = pct(nums.filter((n) => n >= 9).length, nums.length) - pct(nums.filter((n) => n <= 6).length, nums.length);
} else base.recent = values.slice(0, recentCount).map((v) => formatAnswer(v));
} else {
base.recent = values.slice(0, recentCount).map((v) => formatAnswer(v));
}
return base;
});
}
/** Spreadsheet apps run cells that start with these as formulas; a leading quote keeps them as text. */
function safeCell(v: Answer | undefined): string {
const s = formatAnswer(v);
return typeof v === "string" && /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
}
/** All responses as CSV, oldest first, with a header row. */
export function toCsv(questions: Question[], subs: Submission[]): string {
const cols = columns(questions, subs);
const rows = [...subs].sort((a, b) => a.at - b.at).map((s) =>
[new Date(s.createdAt * 1000).toISOString().replace("T", " ").slice(0, 19), ...cols.map((c) => safeCell(s.answers[c.key]))]);
return [["Submitted (UTC)", ...cols.map((c) => c.label)], ...rows]
.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(","))
.join("\r\n") + "\r\n";
}
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
Change history
Quality pass: conditional questions, 0 to 10 scale, QR code, prefill links, results tabs, API docs
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync