Build Survicate

KINDAback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A survey tool whose hard problems are versioning questions so historical answers stay meaningful, keeping partial responses rather than losing them at the close of a tab, and embedding on someone else's site without breaking it.

estimated effort weekendthe files for this build are in the project pack

RuntimeNext.js 15 and TypeScript, one process, public collection route separated from the adminStorageSQLite, one answer row per question written as it arrivesVersioningPublished survey versions are immutable, responses point at oneEmbedOne small script that renders into a shadow rootIdentityOpaque invite tokens, stored hashed

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the application and the collection endpoint.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. have readya few pounds a month, or free on a small tier

    Why A survey nobody can reach is a form, and the embed must be loaded from a real origin over HTTPS to test the policy behaviour.

    Get it Any small host or tunnel that gives you an HTTPS origin.

    Verify The widget script loads on an external page over HTTPS

  3. have readyfree

    Why Embed isolation is only provable against a hostile page, and every real site is one.

    Get it A static page with global element selectors and a restrictive policy header, served locally.

    Verify The widget renders correctly and reports clearly if blocked

  4. decidefree

    Why Naming it after the registered mark in anything commercial needs written consent, and the decision is easier before the labels are in the code.

    Get it Read the current licensing terms, then write down the name you will use in the interface and the exports. open ↗

    Verify The chosen name appears in the schema, interface and README

  5. decidefree

    Why Both are enforced by code and both are tempting to set after seeing the data, which is the wrong order.

    Get it Write down how long responses are kept and the smallest sample that may be shown as a percentage.

    Verify Both are in configuration and named in tests

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- surveys(id, name, status, created_at)
- survey_versions(id, survey_id, version, schema_json, published_at) · immutable once published
- responses(id, survey_id, version_id, token_hash, started_at, last_seen_at, completed_at, source, locale)
- answers(response_id, question_key, value_json, answered_at) · written as each question is answered
- invites(id, survey_id, token_hash, label, expires_at, used_at) · the raw token is never stored
- events(id, response_id, kind, at) · started, resumed, submitted, abandoned, with nothing else recorded
- question_keys are stable identifiers whose meaning never changes; changing the wording means a new version, not a new meaning for an old key.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/surveys.dbSurveys, versions, responses and answers.
PUBLIC_BASE_URLrequiredhttps://feedback.example.comThe origin the embed script and share links are built from.
ADMIN_PASSWORD_HASHsecretrequiredargon2id$...redactedThe admin side. The collection route needs no login and must not share the session.
TOKEN_PEPPERsecretrequireda long random string, redactedMixed in before hashing invite tokens so the table is not a lookup list.
RETENTION_DAYSrequired365Responses older than this are deleted on a schedule.
MIN_SAMPLE_FOR_PERCENTrequired20Below this a report shows counts rather than a percentage.

The build, in order

  1. Questions that stay the same question

    Publishing freezes a version and every response names the version it answered.

    1. An immutable row is the only version control that survives a busy afternoon.

      Files src/lib/versions.tstests/immutable-version.test.ts

    2. Without this the data is uninterpretable within a month.

      Files src/db/schema.sql

    3. The one mistake that quietly corrupts a year of answers.

      Files src/lib/publish.ts

    done when · tick each as it passes
    watch out
    • Editing a live question in place because it was only a typo means the fifty answers collected yesterday and the fifty collected tomorrow were given to different questions, and the chart that averages them is wrong in a way no test and no reviewer will notice. Freeze on publish and version the schema, even for a typo.
  2. Answers saved the moment they are given

    A response abandoned at question nine still contains nine answers.

    1. The abandoned responses are data, and often the interesting kind.

      Files src/app/api/respond/route.ts

    2. Mobile networks retry, and people tap twice.

      Files tests/idempotent-submit.test.ts

    3. The table should not be readable as a roster of who answered what.

      Files src/lib/tokens.ts

    done when · tick each as it passes
    watch out
    • Collecting the whole survey in the browser and posting it at the end loses every long response to a closed tab, a flat battery or a lost signal, and long responses come disproportionately from the people who had the most to say. Persist per answer, and treat submit as a state change rather than as the moment the data arrives.
  3. Logic evaluated where it cannot be edited

    Branching and required questions are decided by the server.

    1. The client asks what comes next; it does not decide.

      Files src/lib/logic.tstests/logic.test.ts

    2. This is the check that makes the rest of the logic mean anything.

      Files src/app/api/respond/route.ts

    3. Better than discovering it when the data has a hole in it.

      Files src/lib/publish.ts

    done when · tick each as it passes
    watch out
    • Enforcing required questions and branch conditions in the browser means anyone with the developer tools open can submit whatever they like, and more commonly a stale client from a previous version submits answers to questions the current branch never showed, producing rows nobody can explain. Decide the next question server side and reject an answer to a question the server did not ask.
  4. An embed that cannot break someone's site

    The widget renders inside a shadow root, styles nothing outside it, and fails quietly.

    1. The only reliable boundary between your CSS and theirs.

      Files src/embed/widget.ts

    2. Inline code is what a strict policy blocks first.

      Files src/embed/loader.ts

    3. A demo page with no CSS proves nothing.

      Files tests/embed-isolation.test.ts

    done when · tick each as it passes
    watch out
    • Injecting the widget into the host page's DOM inherits their styles, so a survey that looks correct on your demo page renders as unstyled text on a real site, and any inline script you rely on is blocked outright by a content security policy with no error the site owner will ever see. Render into a shadow root, keep the script external, and log a clear failure to the console when the policy blocks you.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.