Blueprint · Support

Feedback

A public CSAT form (1 to 5 plus a comment) and a private averages dashboard.

Published by Croft · version 1 · csatfeedbackcustomer satisfactionratingspublic formsupport

What this needs

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

Record streams

  • responses (write)

About this blueprint

Feedback is a customer satisfaction score in two pages. The public one is a single question — how did we do, 1 to 5 — with five large targets that change the follow-up prompt depending on what was tapped: a 1 or 2 asks what went wrong, a 3 asks what would have made it a 5, a 5 asks what worked. The comment, the name, and the email are all optional, so a rating on its own is a complete answer. The private one is the dashboard behind it: average CSAT to two decimals, the full spread across 1 to 5 as proportional bars, the average week by week for the last eight weeks, and every comment in a readable list that filters down to praise, middling, problems, or just the ones that came with words.

Under the hood there is a single callable workflow and one append-only record stream, `responses`. The workflow clamps and rounds the rating to a whole number between 1 and 5 on the way in, so nothing malformed ever reaches the average. Every write is a full snapshot keyed by a stable id — an edit is a newer append, a removal is a tombstone — which means a response that gets pulled out of the average is still there in the history rather than gone. Both pages compute current state from a newest-row-per-id window query. The public page deliberately declares no record streams: it is write-only, because a page anyone can open should not be able to read back every comment and every email address attached to them.

It is built for a team that wants a satisfaction number without buying a survey tool for it. Drop the public link into a closing email, a receipt, a ticket resolution, or a thank-you page, and watch the average and the weekly trend on the private side. The distribution is the part that earns its place — an average of 4.0 made of fours and an average of 4.0 made of fives and ones are different businesses, and the bars say which one you are. For multi-question surveys, use the separate Surveys template instead.

How it works

feedback
Build row
Build row
if entity === "responses"
if entity === "responses"
records.append("responses", […])
Append Records
done
Source — 1 file, show

index.ts

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

/**
 * Feedback — the write engine behind the CSAT feedback 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.
 */

const Input = z.object({
  entity: z
    .enum(["responses"])
    .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. responses{rating,comment,name,contact,channel,reference,submitted_on}. rating is a whole number from 1 to 5.",
    )
    .optional(),
});

export default workflow(
  "feedback",
  {
    trigger: callable({
      description:
        "Record a customer satisfaction response: a 1 to 5 rating with an optional comment, or update or remove an existing one.",
      input: Input,
    }),
    connections: [],
    description:
      "Write engine for the CSAT feedback app: a public form appends a 1 to 5 rating with an optional comment, and the private dashboard averages them.",
  },
  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)}`;
      const fields = action === "remove" ? {} : (data ?? {});
      const rating = Number((fields as Record<string, unknown>).rating ?? 0);
      return {
        ...fields,
        ...(action === "remove"
          ? {}
          : { rating: Number.isFinite(rating) ? Math.min(5, Math.max(1, Math.round(rating))) : null }),
        id: rowId,
        _op: action === "remove" ? "gone" : "live",
        _ts: new Date().toISOString(),
      };
    });

    if (entity === "responses") {
      await ctx.records.append("responses", [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.