HAR-21: Survey module — DB-backed 1–5 surveys (API, CLI, bridge, widget)#78
Merged
Conversation
## What changed - apps/api/src/db.rs — migrate() defines the `survey` and `survey_response` tables: survey holds prompt + optional 1-5 anchor labels + status; survey_response holds one row per rating, with a UNIQUE(survey, voter_id) dedup index, a (survey) tally index, and a DB ASSERT that rating is 1..=5 - apps/api/src/routes/surveys/models.rs (new) — the `Survey` DB row and a pure `SurveyTally` aggregator (zero-filled 1-5 distribution + average + total) with unit tests - apps/api/src/routes/surveys/mod.rs (new) — two routers, six handlers: authed (AuthDeveloper, ownership-checked) create / list / results / close; public/unauthenticated list-active and submit-vote. Plus input validators (prompt, anchors, voter_id, rating, survey-id shape) and ownership / record-id / unique-error helpers - apps/api/src/routes/mod.rs — register `pub mod surveys;` - apps/api/src/main.rs — nest `/surveys` (authed) and `/public/surveys` (public) ## Why PR 2 of the Survey module (HAR-21): the backend. Surveys are DB-backed (not CDN), so this adds the schema and the API that both the CLI (developer) and the launcher (player) talk to. The vote endpoint is Harbor's first public, unauthenticated player-side write, so it is hardened per the security review: every query value is parameterized (no injection); authed handlers filter by owner_id and 404 (not leak) on another developer's survey id; voter_id is never logged (the vote error path logs a category, not the raw DB string); inputs are validated before any DB write; a malformed survey id fast-fails to a clean 404 instead of a DB round-trip + 500; and the public body size is capped. Dedup is honest-client only — UNIQUE(survey, voter_id) gives one rating per install for a real launcher, but voter_id is client-supplied, so it is not ballot-stuffing-resistant. Per-IP rate limiting (which also covers the identically-exposed /analytics/event) is tracked as a separate deploy-gating item before this endpoint goes public. Runtime DB-query behavior (the GROUP BY tally, type::record id loading, the dedup 200 path) is to be confirmed by manual QA against a live SurrealDB; the unit tests here cover the validators and the pure tally fold.
## What changed
- apps/api/src/routes/surveys/mod.rs — the public survey id is now the bare
record key (e.g. `k3f9x`) instead of the full `survey:k3f9x`, so URLs read
`/surveys/{key}/...` with no colon. Added a `survey_key` helper (strips the
`survey:` prefix for the wire) and reworked `load_survey` to rejoin the key
and load via `type::record` — SurrealDB 3.x renamed `type::thing` →
`type::record`. Renamed `is_valid_survey_id` → `is_valid_survey_key`.
- apps/api/src/routes/surveys/mod.rs — new `#[cfg(test)] mod integration`: a
`#[tokio::test]` that spins up an in-memory SurrealDB (the kv-mem feature),
migrates it, seeds a game, and walks the full flow through the real handlers
(create → vote → re-vote/dedup → results → close → 409 → active-list),
asserting the tally/average/distribution and the idempotent dedup.
## Why
Two review-driven changes: (1) the doubled `surveys/survey:...` was redundant,
and the colon in the URL was the single riskiest unverified bit (axum routing
+ the HTTP client). The bare key removes both — cleaner URLs and one fewer
failure mode. (2) The codebase had zero integration tests, so the actual
SurrealDB queries (type::record loads, the GROUP BY tally, the unique-index
dedup) were unexercised — compiling and unit tests can't catch a wrong query.
The new in-memory suite closes that gap, establishes the first integration
pattern for the repo, and immediately caught a real bug: `type::thing` does
not exist in SurrealDB 3.x.
## What changed
- apps/cli/src/commands/survey.rs (new) — run_create (draft a 1-5
prompt with optional low/high anchor labels, confirm, then
POST /surveys) and run_results (list -> select -> render a text
histogram with average + total, then optionally close the survey,
folding "stop survey" into the results view). Pure render_results
helper is unit-tested.
- apps/cli/src/api/client.rs — survey DTOs (CreateSurveyRequest,
SurveySummary, SurveyResults, SurveyRatingCount), a json_or_bail
helper, and four methods: create_survey, list_surveys,
survey_results, close_survey. list_surveys builds the ?app_id=
query directly in the URL because reqwest 0.13 under our trimmed
feature set exposes no RequestBuilder::query; app_id is a
server-issued studio/game slug so it needs no percent-encoding.
- apps/cli/src/validation.rs — validate_survey_prompt (<=200) and
validate_survey_anchor (<=80), char-counted to match the API's
bounds exactly; +11 tests including a multi-byte char case.
- apps/cli/src/commands/mod.rs — register pub mod survey.
- apps/cli/src/session/{menu,state,session_loop,suggestion}.rs — add
MenuId/ActionKind SurveyCreate + SurveyResults gated on
logged_in && has_config, dispatch to the new commands, menu labels
and next-step hints; updated the menu-vector and suggestion tests.
- apps/cli/src/commands/init.rs — add Survey to ALL_MODULE_TYPES /
MODULE_LABELS and a default_enabled() helper so Survey is opt-in
(default-off) while the three content modules stay default-on.
- apps/cli/src/commands/configure.rs — add Survey to the Modules
toggle diff and the section label.
- apps/cli/Cargo.toml — bump harbor-cli 1.0.3 -> 1.1.0.
- apps/api/src/routes/content.rs — drive-by: a pre-existing
clippy assertions_on_constants lint (surfaced by clippy 1.92.0)
blocked the workspace gate. Move an all-const invariant in a
content test into a const { assert!(...) } block (compile-time
check). Unrelated to surveys; bundled so the branch gate is green
in this push rather than a separate cycle.
## Why
This wires the developer side of the Survey module (HAR-21) into the
CLI so a survey can be walked start to finish: create a prompt, watch
responses as a 1-5 histogram with the average, and stop it -- all from
the harbor session menu. Surveys are DB-backed, so these talk to the
API's /surveys endpoints rather than the presigned-URL push flow.
Validators mirror the API's char-count limits so an overlong prompt is
rejected at the prompt instead of round-tripping to a 400. Survey is
wired into the same module on/off surface as the other modules
(requirement 6) but defaults off -- a new game shouldn't surface a
survey widget until the developer deliberately enables it and authors a
question, matching default_modules() in harbor-types.
The CLI version bump reflects new user-visible functionality. Per the
feature's merge policy this lands as a commit on the shared
har-21-survey-api branch only -- no PR flip until the whole module
(CLI + launcher bridge + frontend) works end-to-end.
## What changed - apps/harbor/src-tauri/src/install_id.rs (new) — persistent install UUID at %LOCALAPPDATA%\Paracosm\Harbor\data\install_id, cached in a OnceLock; corrupt content regenerated; FS failure degrades to a process-ephemeral id so surveys never block launcher UX - apps/harbor/src-tauri/src/surveys.rs (new) — on-demand survey client: list_active GETs /public/surveys and filters out locally-seen ids; submit_vote validates rating/key, attaches the voter id, and treats 200 and 409 (closed-since-fetch) both as "finished — advance" and marks the survey seen; dismiss records a Skip locally only. Seen-set persists at ...\data\surveys_seen.json under a poison-recovering Mutex. HARBOR_API_BASE override honored via analytics::api_base() - apps/harbor/src-tauri/src/lib.rs — three thin async commands (list_active_surveys, submit_survey_vote, dismiss_survey) wrapping tauri::async_runtime::spawn_blocking; registered in generate_handler!. Survey instrument spans are debug-level, with rating and the raw dismiss input skipped, so player response data can never reach Sentry transactions - apps/harbor/src-tauri/src/analytics.rs — api_base() is now pub(crate), shared with surveys.rs - launcher version 0.2.8 -> 0.3.0 in apps/harbor/package.json, src-tauri/Cargo.toml, and tauri.conf.json (+ Cargo.lock) - riding small fixes: stale type::thing doc comment in apps/api/src/routes/surveys/mod.rs; bare-key doc wording and test fixtures in crates/harbor-types/src/survey.rs; .claude/settings.local.json permission noise ## Why Fourth slice of HAR-21 (the "PR 4 — Launcher bridge" checklist on the Linear issue): the launcher side of the survey loop, so the upcoming frontend slice only has to consume typed commands. One deliberate deviation from the checklist: submit_vote takes no app_id parameter — the API ignores client-supplied app_id and copies it from the survey row server-side, so passing it would only suggest a trust relationship that doesn't exist. The seen-set is a UX convenience (which surveys to SHOW); the API's UNIQUE(survey, voter_id) index remains the real dedup (which ratings to COUNT) — deleting local state merely re-shows surveys and repeat ratings are idempotent 200s. The review pipeline shaped three details: a shared OnceLock reqwest client (votes are player-watched, pooling saves a TCP+TLS handshake per click), debug-level spans with skip(rating) (INFO command spans become sampled Sentry transactions and #[instrument] auto-captures all args — ratings must not leave the machine outside the vote body), and webview-input validation before any URL build or log line. Known follow-up: install_id::data_dir() re-derives the data root that main.rs::log_dir also derives; centralizing needs a main.rs touch and is deferred.
## What changed
- apps/harbor/src/types/survey.ts (new) — PublicSurvey mirroring the
Rust wire type (camelCase; optional lowLabel/highLabel)
- apps/harbor/src/types/config.ts — ModuleConfig.type union gains
"survey"
- apps/harbor/src/api/surveys.ts (new) — typed invoke wrappers:
listActiveSurveys (silent-fail to []), submitSurveyVote (bool),
dismissSurvey (fire-and-forget); snake_case arg keys pinned by tests
- apps/harbor/src/api/analytics.ts — recordSurveyRendered: fires
ModuleRendered{survey} directly (surveys have no ContentDir)
- apps/harbor/src/hooks/useActiveSurveys.ts (new) — fetches the queue
once per session, steps through it (survey/index/total), optimistic
vote/skip that advance immediately and POST fire-and-forget
- apps/harbor/src/components/SurveyWidget.tsx (new) — presentational
Deadlock-style card: SURVEY eyebrow, N / M counter (hidden when 1),
prompt, 1-5 button row, end-anchor labels, Skip. All remote-origin
strings render as React text nodes only (csp: null webview)
- apps/harbor/src/App.tsx — calls useActiveSurveys (gated on the
module toggle), mounts the widget in the left column above HeroArea
- apps/harbor/src/api/surveys.test.ts (new) + analytics.test.ts —
10 tests pinning command names, snake_case keys, and the
never-throws contracts
## Why
Final slice of HAR-21 (the "PR 5 — Launcher frontend" checklist on the
Linear issue): players can now see and rate surveys in the launcher,
closing the loop the API, CLI, and bridge slices built. Two deliberate
deviations from the checklist: submitSurveyVote takes no appId (the
bridge dropped it — the API resolves app_id server-side), and the
queue state lives in App rather than the widget. The home <main>
unmounts on tab navigation; holding the state in App means returning
from Patch Notes doesn't re-fetch, re-fire the impression analytic,
or reset the N / M position, and the widget stays purely
presentational. Votes advance optimistically (the result was ignored
by design anyway — failures aren't marked seen and re-show next
launch), so a dead network can't freeze the card for its 5s timeout.
The hook and widget have no unit tests: the repo has no React test
renderer (Vitest only, node environment) — covered by manual QA.
## What changed - apps/harbor/src/components/SurveyWidget.tsx — rebuilt to match HomeCardList: same max-w-[500px] box, radius, surface, and blur. The SURVEY pill uses --primary and the rating buttons highlight with --accent on hover, so the widget carries the developer's theme. The optional 1/5 anchor labels now render inside the end buttons (absolute, truncated) instead of on their own row, so a survey that names its anchors is exactly as tall as one that doesn't. - apps/harbor/src/App.tsx — moved the survey out of the left column into the right-hand home feed as the top card; the content feed below trims to 3 so the stack stays at most four boxes. The "no content" empty state is suppressed while a survey is showing. - apps/harbor/src-tauri/Cargo.toml — line-ending normalization only (no content change). ## Why Live QA feedback: the survey should read as one of the home "wide box" modules, not a separate left-column panel — so it takes the top slot of the existing card stack and matches their dimensions. Driving its colors from the config's --primary/--accent keeps the developer's theme consistent across the launcher. Anchoring the 1/5 labels inside the end buttons rather than a dedicated row keeps the box a single fixed height whether or not the developer names the scale ends (the explicit ask); long labels truncate to the button width.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the Survey module (HAR-21) end-to-end: DB-backed 1–5 rating surveys (Deadlock-style), opt-in and default-off, authored by developers in the CLI and rated inline by players in the launcher. Built in five slices on one branch:
crates/harbor-types) —ModuleType::Survey+PublicSurvey/VoteRequestwire types (bare record-key ids).apps/api) —survey+survey_responsetables with aUNIQUE(survey, voter_id)dedup index; authed create / list / results / close (all ownership-checked) and public list / vote endpoints;app_idcopied server-side, 4KiB public body cap, idempotent duplicate votes,409on closed surveys.apps/cli) — create a survey, view results (1–5 histogram + average + total), and close it from the REPL; survey wired into the module on/off surface.apps/harbor/src-tauri) — persistent install-id voter dedup (UUID at%LOCALAPPDATA%\Paracosm\Harbor\data\), blocking survey client (list / vote / dismiss) with a local seen-set, three thin Tauri commands. Launcher bumped0.2.8 → 0.3.0.apps/harbor/src) — typed invoke wrappers,useActiveSurveysqueue hook, andSurveyWidgetrendered as the top card of the home feed, styled to matchHomeCardListand themed from the config's--primary/--accent.The public vote endpoint is intentionally unauthenticated; rate limiting / abuse hardening is tracked separately as HAR-147 (deploy-gating, not in this PR).
Test plan
cargo test --workspace(Rust) andnpm test(Vitest, 113) green;cargo clippy --workspace -- -D warningsclean;npm run buildclean.N / Mqueue stepping; install-dedup across restarts; close-while-open race advances cleanly with nothing counted; API down → home page and Play button unaffected, widget silently absent; module toggle hides the widget; named vs. unnamed 1/5 anchors keep an identical box height.Linked Issues
HAR-21