# Feedback

Published by Croft · Category: Support

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

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.

## Requirements

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

### Record streams

- responses (write)

## Source

#### `index.ts`

```typescript
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 };
  },
);

```

## Install

Install this blueprint: https://croft.now/blueprints/croft-now/feedback/install

---
View online: https://croft.now/blueprints/croft-now/feedback
