polls-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 c06cea4889e9
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 334801de44cb0fe911d5a88b
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/polls.ts
// Polls' shared logic, used by the web app and the public API so both accept and refuse the same things:
// poll types, checking polls and answers, results summaries and CSV.

export const POLL_TYPES = {
  choice: "Multiple choice",
  wordcloud: "Word cloud",
  rating: "Rating scale",
  open: "Open text",
  ranking: "Ranking",
} as const;
export type PollType = keyof typeof POLL_TYPES;
export type PollStatus = "draft" | "open" | "closed";
export type QuestionStatus = "pending" | "live" | "answered" | "hidden";

export const LIMITS = {
  title: 120,
  question: 300,
  options: 30,
  rankingOptions: 12,
  option: 120,
  word: 30,
  words: 5,
  text: 500,
  ask: 300,
  name: 40,
  // One batch holds at most 100 writes, and the event itself takes 5.
  polls: 95,
  answerJson: 2000,
  resultsJson: 48000,
};

/** An event as stored. current is a poll id, "qa" for the questions, or "". */
export interface EventDoc {
  title: string;
  code: string;
  current: string;
  qa: boolean;
  moderate: boolean;
  allowChange: boolean;
  createdBy: string;
  createdAt: number;
}

/** A poll as stored. options is a JSON list of strings; results is the summary published for the audience. */
export interface PollDoc {
  type: PollType;
  question: string;
  options: string;
  multi: boolean;
  max: number;
  pos: number;
  status: PollStatus;
  showResults: boolean;
  results: string;
  createdAt: number;
}

export interface Poll extends Omit<PollDoc, "options"> { id: string; options: string[] }

export interface QuestionDoc { id: string; text: string; name: string; status: QuestionStatus; upvotes: number; createdAt: number }

/** A vote row as read: the voter's user id is the row id. answer is JSON. */
export interface VoteRow { id: string; answer: string; at: number }

export type Answer = number[] | string[] | number | string;

const clean = (v: unknown) => (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v).trim().replace(/\s+/g, " ") : "");

const TYPE_ALIASES: Record<string, PollType> = {
  multiple_choice: "choice", mc: "choice", poll: "choice", single: "choice", multi: "choice", multiple: "choice", quiz: "choice",
  word_cloud: "wordcloud", words: "wordcloud", cloud: "wordcloud",
  scale: "rating", rate: "rating", stars: "rating",
  text: "open", open_text: "open", open_ended: "open", free_text: "open",
  rank: "ranking", order: "ranking",
};

function pollType(raw: unknown): PollType | null {
  const t = clean(raw).toLowerCase().replace(/[\s-]+/g, "_");
  if (t in POLL_TYPES) return t as PollType;
  return TYPE_ALIASES[t] ?? null;
}

export const hasOptions = (t: PollType) => t === "choice" || t === "ranking";

/** What a poll is made of, from the editor or the API. */
export interface PollInput { type: PollType; question: string; options: string[]; multi: boolean; max: number }

/** Checks one poll from the editor or the API. Returns the cleaned poll or an error sentence. */
export function checkPoll(input: unknown, n = "This poll"): { poll: PollInput } | { error: string } {
  if (!input || typeof input !== "object") return { error: `${n} must be an object with a type and a question.` };
  const r = input as Record<string, unknown>;
  const type = pollType(r.type ?? "choice");
  if (!type) return { error: `${n}: type must be one of ${Object.keys(POLL_TYPES).join(", ")}.` };
  const question = clean(r.question ?? r.title ?? r.label ?? r.text);
  if (!question) return { error: `${n} needs a question.` };
  if (question.length > LIMITS.question) return { error: `${n}: keep the question under ${LIMITS.question} characters.` };
  const options: string[] = [];
  if (hasOptions(type)) {
    const seen = new Set<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 most = type === "ranking" ? LIMITS.rankingOptions : LIMITS.options;
    if (options.length < 2) return { error: `${n} needs at least two options.` };
    if (options.length > most) return { error: `${n} can have up to ${most} options.` };
  }
  const multi = type === "choice" && (r.multi === true || r.multiple === true || r.allowMultiple === true);
  let max = 0;
  if (type === "rating") {
    max = Number(r.max ?? r.scale ?? 5);
    if (!Number.isInteger(max) || max < 3 || max > 10) return { error: `${n}: the rating scale goes up to a whole number from 3 to 10.` };
  }
  if (type === "wordcloud") {
    max = Number(r.max ?? r.words ?? 3);
    if (!Number.isInteger(max) || max < 1 || max > LIMITS.words) return { error: `${n}: each person can give 1 to ${LIMITS.words} words.` };
  }
  return { poll: { type, question, options, multi, max } };
}

/** Checks an audience question from the web app or the API. */
export function checkAsk(rawText: unknown, rawName: unknown): { text: string; name: string } | { error: string } {
  const text = clean(rawText);
  if (!text) return { error: "Type your question first." };
  if (text.length > LIMITS.ask) return { error: `Keep the question under ${LIMITS.ask} characters.` };
  const name = clean(rawName);
  if (name.length > LIMITS.name) return { error: `Keep the name under ${LIMITS.name} characters.` };
  return { text, name };
}

export function checkTitle(raw: unknown): { title: string } | { error: string } {
  const title = clean(raw);
  if (!title) return { error: "Give the event a name." };
  if (title.length > LIMITS.title) return { error: `Keep the name under ${LIMITS.title} characters.` };
  return { title };
}

export function parseOptions(raw: string | null | undefined): string[] {
  try {
    const list = JSON.parse(raw || "[]");
    return Array.isArray(list) ? list.map(String) : [];
  } catch {
    return [];
  }
}

export function toPoll(id: string, doc: PollDoc): Poll {
  return { ...doc, id, options: parseOptions(doc.options) };
}

export const byPos = (a: { pos: number; createdAt: number }, b: { pos: number; createdAt: number }) => a.pos - b.pos || a.createdAt - b.createdAt;

/**
 * Checks one answer against its poll and returns it in stored form, or an error sentence.
 * choice: option indexes; wordcloud: words; rating: a whole number; open: text; ranking: every option index, best first.
 */
export function checkAnswer(poll: Pick<Poll, "type" | "options" | "multi" | "max">, raw: unknown): { value: Answer } | { error: string } {
  const n = poll.options.length;
  switch (poll.type) {
    case "choice": {
      const list = (Array.isArray(raw) ? raw : [raw]).map(Number);
      const picked = [...new Set(list)].filter((i) => Number.isInteger(i) && i >= 0 && i < n).sort((a, b) => a - b);
      if (!picked.length || picked.length !== list.length) return { error: "Pick an option." };
      if (!poll.multi && picked.length > 1) return { error: "Pick one option." };
      return { value: picked };
    }
    case "wordcloud": {
      const seen = new Set<string>();
      const words: string[] = [];
      for (const w of Array.isArray(raw) ? raw : [raw]) {
        const s = clean(w);
        if (!s || seen.has(s.toLowerCase())) continue;
        if (s.length > LIMITS.word) return { error: `Keep each word under ${LIMITS.word} characters.` };
        seen.add(s.toLowerCase());
        words.push(s);
      }
      if (!words.length) return { error: "Type a word." };
      if (words.length > Math.max(1, poll.max)) return { error: poll.max === 1 ? "Give one word." : `Give up to ${poll.max} words.` };
      return { value: words };
    }
    case "rating": {
      const v = Number(raw);
      if (!Number.isInteger(v) || v < 1 || v > poll.max) return { error: `Pick a number from 1 to ${poll.max}.` };
      return { value: v };
    }
    case "open": {
      const s = typeof raw === "string" ? raw.trim() : "";
      if (!s) return { error: "Type an answer." };
      if (s.length > LIMITS.text) return { error: `Keep it under ${LIMITS.text} characters.` };
      return { value: s };
    }
    case "ranking": {
      const list = Array.isArray(raw) ? raw.map(Number) : [];
      const ok = list.length === n && new Set(list).size === n && list.every((i) => Number.isInteger(i) && i >= 0 && i < n);
      if (!ok) return { error: "Put every option in order." };
      return { value: list };
    }
  }
}

/** A stored answer, checked again: anything a modified client wrote that does not fit the poll is ignored. */
export function readAnswer(poll: Pick<Poll, "type" | "options" | "multi" | "max">, raw: string): Answer | null {
  try {
    const r = checkAnswer(poll, JSON.parse(raw));
    return "value" in r ? r.value : null;
  } catch {
    return null;
  }
}

export interface Summary {
  /** People whose answer counts. */
  n: number;
  /** choice: votes per option, in option order. */
  counts?: number[];
  /** rating: average, and how many picked 1..max. */
  avg?: number;
  dist?: number[];
  /** wordcloud: [word, count], most common first. */
  words?: [string, number][];
  /** open: answers, newest first. */
  texts?: string[];
  /** ranking: average position per option (1 is best), in option order. */
  rank?: number[];
}

const round = (v: number, places = 2) => Math.round(v * 10 ** places) / 10 ** places;

/** Results for one poll from its votes. */
export function summarize(poll: Poll, votes: VoteRow[]): Summary {
  const answers = [...votes].sort((a, b) => b.at - a.at).map((v) => readAnswer(poll, v.answer)).filter((a): a is Answer => a !== null);
  const s: Summary = { n: answers.length };
  switch (poll.type) {
    case "choice":
      s.counts = poll.options.map((_, i) => answers.filter((a) => (a as number[]).includes(i)).length);
      break;
    case "rating": {
      const nums = answers as number[];
      s.dist = Array.from({ length: poll.max }, (_, i) => nums.filter((v) => v === i + 1).length);
      if (nums.length) s.avg = round(nums.reduce((a, b) => a + b, 0) / nums.length);
      break;
    }
    case "wordcloud": {
      const counts = new Map<string, { word: string; n: number }>();
      for (const list of answers as string[][]) for (const w of list) {
        const k = w.toLowerCase();
        const e = counts.get(k);
        if (e) e.n++; else counts.set(k, { word: w, n: 1 });
      }
      s.words = [...counts.values()].sort((a, b) => b.n - a.n || a.word.localeCompare(b.word)).map((e) => [e.word, e.n]);
      break;
    }
    case "open":
      s.texts = answers as string[];
      break;
    case "ranking": {
      const sums = poll.options.map(() => 0);
      for (const order of answers as number[][]) order.forEach((opt, place) => { sums[opt] += place + 1; });
      s.rank = sums.map((v) => (answers.length ? round(v / answers.length) : 0));
      break;
    }
  }
  return s;
}

/** The summary as published for the audience: long word and text lists are cut so it fits one small document. */
export function publishable(s: Summary): string {
  const out: Summary = { ...s };
  if (out.words) out.words = out.words.slice(0, 120);
  if (out.texts) {
    const texts: string[] = [];
    let size = 0;
    for (const t of out.texts) {
      size += t.length + 8;
      if (size > LIMITS.resultsJson - 2000 || texts.length >= 200) break;
      texts.push(t);
    }
    out.texts = texts;
  }
  return JSON.stringify(out);
}

export function parseSummary(raw: string | null | undefined): Summary | null {
  if (!raw) return null;
  try {
    const s = JSON.parse(raw);
    return s && typeof s === "object" && typeof s.n === "number" ? s : null;
  } catch {
    return null;
  }
}

/** Options in ranked order for a ranking poll. */
export function rankedOptions(poll: Poll, s: Summary): { option: string; avg: number }[] {
  return poll.options.map((option, i) => ({ option, avg: s.rank?.[i] ?? 0 })).sort((a, b) => a.avg - b.avg);
}

export const pct = (n: number, of: number) => (of ? Math.round((n / of) * 100) : 0);

/** Spreadsheet apps run cells that start with these as formulas; a leading quote keeps them as text. */
const safeCell = (v: string | number) => (typeof v === "string" && /^[=+\-@\t\r]/.test(v) ? `'${v}` : String(v));
const csv = (rows: (string | number)[][]) => rows.map((r) => r.map((c) => `"${safeCell(c).replace(/"/g, '""')}"`).join(",")).join("\r\n") + "\r\n";

/** Every poll's results as CSV: one row per option, scale point, word or answer. */
export function resultsCsv(polls: Poll[], summaries: Summary[]): string {
  const rows: (string | number)[][] = [["Poll", "Question", "Type", "Answer", "Votes", "Percent", "Average"]];
  polls.forEach((p, i) => {
    const s = summaries[i];
    const base = [i + 1, p.question, POLL_TYPES[p.type]];
    if (p.type === "choice") p.options.forEach((o, j) => rows.push([...base, o, s.counts?.[j] ?? 0, `${pct(s.counts?.[j] ?? 0, s.n)}%`, ""]));
    if (p.type === "rating") {
      (s.dist || []).forEach((c, j) => rows.push([...base, `${j + 1} of ${p.max}`, c, `${pct(c, s.n)}%`, ""]));
      rows.push([...base, "Average", "", "", s.avg ?? ""]);
    }
    if (p.type === "wordcloud") (s.words || []).forEach(([w, c]) => rows.push([...base, w, c, `${pct(c, s.n)}%`, ""]));
    if (p.type === "open") (s.texts || []).forEach((t) => rows.push([...base, t, 1, "", ""]));
    if (p.type === "ranking") rankedOptions(p, s).forEach((r, j) => rows.push([...base, `${j + 1}. ${r.option}`, s.n, "", r.avg]));
    rows.push([...base, "People who answered", s.n, "", ""]);
  });
  return csv(rows);
}

export const QUESTION_STATUS: Record<QuestionStatus, string> = { pending: "Waiting for approval", live: "Live", answered: "Answered", hidden: "Hidden" };

/** Most upvoted first, then oldest first. */
export const byVotes = (a: QuestionDoc, b: QuestionDoc) => b.upvotes - a.upvotes || a.createdAt - b.createdAt;

export function questionsCsv(questions: QuestionDoc[]): string {
  return csv([
    ["Question", "Name", "Upvotes", "Status", "Asked (UTC)"],
    ...[...questions].sort(byVotes).map((q) => [q.text, q.name || "Anonymous", q.upvotes, QUESTION_STATUS[q.status] ?? q.status, new Date(q.createdAt * 1000).toISOString().replace("T", " ").slice(0, 19)]),
  ]);
}

export const fileName = (title: string, part: string) => `${title.replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "event"}-${part}.csv`;

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;
}

/** A 6-digit join code that never starts with 0. */
export function randomCode(): string {
  const [n] = crypto.getRandomValues(new Uint32Array(1));
  return String(100000 + (n % 900000));
}

export const formatCode = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`;
export const CODE_RE = /^[1-9]\d{5}$/;
export const EVENT_ID_RE = /^[A-Za-z0-9]{20}$/;
export const QUESTION_ID_RE = /^[A-Za-z0-9]{16}$/;

const NOW = { operation: "time", value: "now" } as const;

export function newPollDoc(p: PollInput, pos: number, status: PollStatus = "draft") {
  return { type: p.type, question: p.question, options: JSON.stringify(p.options), multi: p.multi, max: p.max, pos, status, showResults: false, results: "", createdAt: NOW };
}

/**
 * The one batch that creates an event: the event, its join code, the creator's join and owner records,
 * the admin key, and any polls. The same shape works from the browser and from the API.
 */
export function eventWrites(o: { eventId: string; code: string; key: string; me: string; title: string; polls: { input: PollInput; id: string; status?: PollStatus }[]; qa?: boolean; moderate?: boolean; allowChange?: boolean }) {
  const base = `events/${o.eventId}`;
  return [
    { path: base, document: { title: o.title, code: o.code, current: "", qa: o.qa ?? true, moderate: o.moderate ?? false, allowChange: o.allowChange ?? true, createdBy: o.me, createdAt: NOW } },
    { path: `${base}/joins/${o.me}`, document: { at: NOW } },
    { path: `${base}/owners/${o.me}`, document: { at: NOW } },
    { path: `eventkeys/${o.eventId}`, document: { key: o.key } },
    { path: `codes/${o.code}`, document: { event: o.eventId, at: NOW } },
    ...o.polls.map((p, i) => ({ path: `${base}/polls/${p.id}`, document: newPollDoc(p.input, i, p.status) })),
  ];
}

Change history

Point OpenApps links to openapps.free
c06cea4889 · 17 hours ago
Bind agent address in policy constants
a7de116d52 · 19 hours ago
Use OpenApps service URLs and add contribution strip
e8ce0b0bf0 · 19 hours ago
Bounded source sync
dcdc4d56e6 · 1 day ago
Bounded source sync
7acdace1e1 · 1 day ago
Bounded source sync
2140a66ea0 · 1 day ago
Bounded source sync
0db2fe3ca0 · 1 day ago
Bounded source sync
996f633a3c · 1 day ago