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 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.
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
Bind agent address in policy constants
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync