forms-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 ce303909347f
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.
← All files
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
ce30390934 · 4 hours ago
Bounded source sync
7d82add8ae · 1 day ago
Bounded source sync
8230fd7d59 · 1 day ago
Bounded source sync
d92a6e3b44 · 1 day ago
Bounded source sync
b548641922 · 1 day ago
Bounded source sync
b89074ffd2 · 1 day ago