Blueprint · Support

Surveys

Multi-question surveys with a public respond link and private results. Separate from the CSAT feedback app.

Published by Croft · version 1 · surveysquestionnaireresearchpublic formresultssupport

What this needs

Requires no connections. Runs on demand, when called directly.

Record streams

  • answers (write)
  • questions (write)
  • survey_responses (write)
  • surveys (write)

About this blueprint

Surveys is a small survey tool in two pages. The private one builds and reads: create a survey, give it an intro and an open or closed state, add questions of three kinds — free text, single choice from a list of options, or a 1 to 5 rating — reorder them, and then read the results question by question. Ratings come back as an average with the full 1 to 5 spread, choices as proportional bars with counts and percentages, and text answers as a readable list of quotes attributed to whoever left them. A responses tab shows who answered when and how many of the questions they got through. The public one is the respond page: it takes a survey id in the query string, renders that survey's questions in order, marks which ones are required, tracks a progress bar as they are filled in, and lets people answer anonymously if they would rather.

Under the hood there is a single callable workflow over four append-only record streams: surveys, questions, survey responses, and answers. Because a workflow run never hands an id back, the respond page mints the response id itself before writing, which is what lets every answer row point at the response it arrived with. Every write is a full snapshot keyed by a stable id, so editing a question is a newer append and removing one is a tombstone; answers already given stay in the history rather than vanishing with the question. Reordering rewrites two questions' positions as two snapshots, for the same reason. The public respond page can read the survey and its questions and nothing else, because the questions are meant to be read by respondents and the answers are not.

It is built for the surveys that are too long for a one-question rating and too small to justify a survey platform: a post-onboarding questionnaire, an event debrief, an internal pulse check, a pricing sanity test. Set a survey to open, send the link, and watch the bars fill in. For single-question customer satisfaction scoring, the separate Feedback template is the simpler fit; this one is deliberately the multi-question sibling.

How it works

surveys
Build row
Build row
if entity === "surveys"
if entity === "surveys"
records.append("surveys", […])
Append Records
if entity === "questions"
if entity === "questions"
records.append("questions", […])
Append Records
if entity === "survey_responses"
if entity === "survey_responses"
records.append("survey_responses", […])
Append Records
records.append("answers", […])
Append Records
done
Source — 1 file, show

index.ts

import { callable, workflow, z } from "@croft/sdk";

/**
 * Surveys — the write engine behind the multi-question survey app.
 *
 * Every entity lives in an append-only record stream as an event log: each
 * append is a full snapshot of the row, and the newest row per `id` wins.
 * Removals append a tombstone (`_op: "gone"`) rather than erasing history.
 * The artifact pages read current state with a `row_number()` window query.
 *
 * `_op` is "live"/"gone" and never the word "delete", because `records.query`
 * rejects COPY EXPORT INSTALL LOAD ATTACH CREATE INSERT UPDATE DELETE PRAGMA
 * as whole words anywhere in the SQL text — string literals included.
 *
 * The appends are written out one stream at a time, rather than through a
 * computed stream name, so the analyzer can name every stream this workflow
 * touches and an install plan can list them.
 *
 * This is the multi-question survey app. Single-question customer satisfaction
 * scoring is the separate Feedback template.
 */

const Input = z.object({
  entity: z
    .enum(["surveys", "questions", "survey_responses", "answers"])
    .describe("Which record stream to write"),
  action: z
    .enum(["save", "remove"])
    .describe("save writes a full snapshot of the row; remove appends a tombstone"),
  id: z
    .string()
    .describe("Stable id for the row. Reuse it to update; omit on create to mint one.")
    .optional(),
  data: z
    .record(z.string(), z.any())
    .describe(
      "Flat field map. surveys{title,intro,state,opened_on,closes_on} where state is draft, open or closed; " +
        "questions{survey_id,position,prompt,kind,choices,required} where kind is text, choice or rating and " +
        "choices is a pipe-separated option list; survey_responses{survey_id,respondent,contact,submitted_on}; " +
        "answers{response_id,survey_id,question_id,value}",
    )
    .optional(),
});

export default workflow(
  "surveys",
  {
    trigger: callable({
      description:
        "Create, update, or remove a survey, one of its questions, a submitted response, or a single answer within a response.",
      input: Input,
    }),
    connections: [],
    description:
      "Write engine for the surveys app: multi-question surveys with text, single-choice and rating questions, a public respond link, and private per-question results.",
  },
  async (ctx) => {
    const { entity, action, id, data } = ctx.input;

    const row = await ctx.step("Build row", () => {
      const rowId = id ?? `${entity.slice(0, 3)}_${Math.random().toString(36).slice(2, 10)}`;
      return {
        ...(action === "remove" ? {} : (data ?? {})),
        id: rowId,
        _op: action === "remove" ? "gone" : "live",
        _ts: new Date().toISOString(),
      };
    });

    if (entity === "surveys") {
      await ctx.records.append("surveys", [row]);
    } else if (entity === "questions") {
      await ctx.records.append("questions", [row]);
    } else if (entity === "survey_responses") {
      await ctx.records.append("survey_responses", [row]);
    } else {
      await ctx.records.append("answers", [row]);
    }

    return { id: row.id, entity, action, ts: row._ts };
  },
);

Want this running in your own workspace?

Installing recompiles this exact version in your workspace, lands it paused, and walks you through granting whatever it needs above. No account yet? Installing creates one.