Skip to content

Commit 79c4a6c

Browse files
committed
Beta: in-app "Send feedback" channel (Help menu + palette → API server → Discord)
Open-beta testers can now send free-text feedback from inside the app with zero friction: Help > "Send feedback…" (macOS + Linux) or the palette's "Send feedback" opens a small dialog, and the note lands on the API server and pings Discord, mirroring how error reports already flow (minus the log bundle). - api-server: new `POST /feedback` (JSON: required `feedback` ≤ 100k code points + `appVersion`/`osVersion`, optional reply-to `email` + `buildMode`). The D1 `feedback` table (migration `0007_feedback.sql`) is the durable sink, so the write is awaited and a failure returns a soft 502 the app surfaces as a retry; the Discord embed (truncated preview, `[DEV]`/`[PROD]` prefix) rides `waitUntil` and prefers the new optional `DISCORD_FEEDBACK_WEBHOOK_URL`, falling back to `DISCORD_WEBHOOK_URL` so it works with no new secret. Abuse guards mirror the sibling routes: `FEEDBACK_LIMITER` at 5/min/IP (IP never stored), 512 KB body cap, strict shape validation. 22 new vitest tests (endpoint + Discord payload). - Desktop backend: `feedback.rs` (trim + code-point-cap validation, payload assembly, send with the error reporter's CI/E2E skip gates) + thin async `send_feedback` command returning a typed `SendFeedbackResult` (`sent`/`invalid`/`softFailure`), no string matching. Registered in `ipc.rs` + `ipc_collectors.rs`; bindings regenerated. - Desktop frontend: new `lib/feedback/` feature (flow store + dialog). Same 50k/100k code-point note caps as the error reporter, the same sticky "Attach my email (…) so we can reply" checkbox (gated on `analytics.email`, persisted via `updates.attachEmailToReports`), warm success toast, inline friendly retry on failure with the text preserved. A quiet line links "browse and vote on GitHub" and "book a call" via the new shared `lib/beta-links.ts` (booking URL is a placeholder until release), routed through the opener plugin. - Command wiring: `feedback.send` in `COMMAND_IDS` + registry (palette-visible) + handler; Help-menu items on both platforms dispatch it through the usual `execute-command` path; `feedback` added to the soft-dialog registry. - Tests: TDD red-first on the api-server endpoint and the Rust validator (the naive first cut failed exactly on trim + code-point counting); dialog gets tier-3 a11y + behavior tests; flow store and IPC wrapper unit-tested; characterization + registry pins updated. Full `pnpm check` green. - Docs: api-server CLAUDE.md (route, limiter, data flow, migration pointer), new `lib/feedback/CLAUDE.md`, architecture.md entries, command-count refresh. Deploy notes: apply the D1 migration (`wrangler d1 migrations apply cmdr-telemetry`) before deploying the Worker; optionally set `DISCORD_FEEDBACK_WEBHOOK_URL` for a dedicated channel.
1 parent 219549d commit 79c4a6c

36 files changed

Lines changed: 1534 additions & 24 deletions

apps/api-server/CLAUDE.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ app versions).
1717
| `src/likes.ts` | Routes: `/likes/:slug` (GET, POST, DELETE, OPTIONS) |
1818
| `src/error-report.ts` | Route: `POST /error-report` (multipart upload to R2, Discord notify) |
1919
| `src/beta-signup.ts` | Route: `POST /beta-signup` (email-only Listmonk double-opt-in subscribe; NO install id) |
20+
| `src/feedback.ts` | Route: `POST /feedback` (in-app feedback → D1 + Discord notify) |
2021
| `src/error-report-eviction.ts` | Eviction logic: 8/6 GB watermarks, KV lock, recompute helper |
2122
| `src/discord.ts` | Discord webhook client (single-retry on 429, drop-on-failure) |
2223
| `src/scheduled.ts` | Cron handler functions (crash notifications, aggregation, DB size, eviction) |
@@ -32,6 +33,7 @@ app versions).
3233
| `src/crash-report.test.ts` | Tests for `POST /crash-report` endpoint |
3334
| `src/heartbeat.test.ts` | Tests for `POST /heartbeat` (validation, config round-trip, rate limit) |
3435
| `src/beta-signup.test.ts` | Tests for `POST /beta-signup` (Listmonk call, no-install-id invariant, soft failure, rate limit) |
36+
| `src/feedback.test.ts` | Tests for `POST /feedback` (validation, caps, D1 row, Discord ping, rate limit) |
3537
| `src/download-and-update-check.test.ts` | Tests for download redirect and update check routes |
3638
| `src/scheduled.test.ts` | Tests for cron handler (crash notifications, aggregation) |
3739
| `scripts/generate-keys.js` | Ed25519 key pair generation (run once at setup) |
@@ -56,6 +58,7 @@ app versions).
5658
| POST | `/heartbeat` | IP rate-limit | Ingest a usage heartbeat (anonymous `anal_id`) to D1 |
5759
| POST | `/error-report` | none | Multipart upload (zip + meta) → R2, Discord notify |
5860
| POST | `/beta-signup` | IP rate-limit | Subscribe a contact email to the Listmonk beta list (NO install id) |
61+
| POST | `/feedback` | IP rate-limit | Ingest in-app feedback to D1, Discord notify |
5962
| GET | `/update-check/:version` | none | Log update check to D1 (deduped), 302 → latest.json |
6063

6164
## Environments
@@ -98,6 +101,7 @@ API key the server uses. Set to `"sandbox"` by default (from `wrangler.toml`). T
98101
| `ERROR_REPORT_META` | KV namespace | `total_bytes` counter + `eviction_in_progress` lock for the eviction logic |
99102
| `HEARTBEAT_LIMITER` | Rate limit | Gates `POST /heartbeat` at 12 req/min/IP (`[[ratelimits]]`, type `RateLimit`) |
100103
| `BETA_SIGNUP_LIMITER` | Rate limit | Gates `POST /beta-signup` at 5 req/min/IP (signups are rare; tighter than heartbeat) |
104+
| `FEEDBACK_LIMITER` | Rate limit | Gates `POST /feedback` at 5 req/min/IP (real feedback is rare; spam loops aren't) |
101105

102106
**Paddle dashboards**: [sandbox](https://sandbox-vendors.paddle.com) | [live](https://vendors.paddle.com)
103107

@@ -193,6 +197,8 @@ Heartbeat: POST /heartbeat → rate-limit by IP (HEARTBEAT_LIMITER, 429 if over)
193197
194198
Beta signup: POST /beta-signup → rate-limit by IP (BETA_SIGNUP_LIMITER, 429 if over) → read ONLY the email (no install id) → validate shape → Listmonk POST /api/subscribers (list = LISTMONK_BETA_LIST_ID, status "unconfirmed", NO preconfirm = double opt-in) → 204 on 2xx or 409 (existing; no enumeration), soft 502 on Listmonk error
195199
200+
Feedback: POST /feedback → rate-limit by IP (FEEDBACK_LIMITER, 429 if over) → validate shape (required feedback text ≤ 100k code points + appVersion/osVersion, optional email/buildMode) → AWAITED D1 write to `feedback` (failure → soft 502 so the app offers a retry) → Discord ping in waitUntil (DISCORD_FEEDBACK_WEBHOOK_URL, falls back to DISCORD_WEBHOOK_URL) → 204
201+
196202
Update check proxy: GET /update-check/:version → hash IP with daily salt → INSERT OR IGNORE into D1 (fire-and-forget) → 302 to latest.json
197203
198204
Cron (every 12h): scheduled handler runs three jobs:
@@ -249,12 +255,12 @@ Read by `/admin/stats`. The counter starts from zero when deployed; initialize v
249255
needed.
250256

251257
**D1 for telemetry:** Crash reports, downloads, update checks, and heartbeats are stored in D1 (binding: `TELEMETRY_DB`,
252-
database: `cmdr-telemetry`). Migrations live in `migrations/` (latest: `0006_crash_diag_email.sql`, which adds the
253-
nullable `diag_id` + `email` columns to `crash_reports`; `0005_heartbeat.sql` adds the `heartbeat` table). Apply with
254-
`wrangler d1 migrations apply cmdr-telemetry` before deploying changes that add new tables or columns. The only
255-
remaining Analytics Engine dataset is `DEVICE_COUNTS` for fair-use monitoring. All other state (license codes,
256-
activation counter, device sets) lives in Cloudflare KV. Short codes never expire (perpetual licenses last forever);
257-
subscription validity is checked live via Paddle API.
258+
database: `cmdr-telemetry`). Migrations live in `migrations/` (latest: `0007_feedback.sql`, which adds the `feedback`
259+
table for in-app feedback; `0006_crash_diag_email.sql` adds the nullable `diag_id` + `email` columns to `crash_reports`;
260+
`0005_heartbeat.sql` adds the `heartbeat` table). Apply with `wrangler d1 migrations apply cmdr-telemetry` before
261+
deploying changes that add new tables or columns. The only remaining Analytics Engine dataset is `DEVICE_COUNTS` for
262+
fair-use monitoring. All other state (license codes, activation counter, device sets) lives in Cloudflare KV. Short
263+
codes never expire (perpetual licenses last forever); subscription validity is checked live via Paddle API.
258264

259265
**Validation error granularity:** `/validate` distinguishes "Paddle says invalid" (HTTP 200 + `status: "invalid"`) from
260266
"Paddle is unreachable" (HTTP 502 + `{ error: "upstream_error" }`). `paddle-api.ts` throws `PaddleApiError` on
@@ -313,6 +319,15 @@ the address existed: no enumeration). On a Listmonk network/5xx failure it retur
313319
as a gentle "try again" (NOT fire-and-forget: we want the user to know it didn't land). Missing Listmonk config
314320
returns 500. The list id is set as a wrangler secret at deploy time; see `docs/tooling/listmonk.md`.
315321

322+
**In-app feedback:** `POST /feedback` is the open-beta "Send feedback" channel. JSON body: required `feedback` text
323+
(trimmed, 1–100 000 Unicode code points; the cap matches the desktop dialog and the Rust validator) plus `appVersion` /
324+
`osVersion`, optional reply-to `email` (loose shape check) and `buildMode`. Body capped at 512 KB. The D1 `feedback`
325+
table is the durable sink, so unlike the other telemetry writes this one is AWAITED: a D1 failure returns a soft 502 the
326+
desktop app surfaces as a gentle retry. The Discord ping (truncated preview, `[DEV]`/`[PROD]` title prefix from
327+
`buildMode`) rides `waitUntil` after the 204; it prefers `DISCORD_FEEDBACK_WEBHOOK_URL` and falls back to
328+
`DISCORD_WEBHOOK_URL` so feedback works with no new secret. No install id of any kind is read or stored, so feedback
329+
can't be joined to the analytics stream. Rate-limited at 5/min/IP via `FEEDBACK_LIMITER` (IP never stored).
330+
316331
**Device tracking (fair use):** On each `/validate` call with a `deviceId`, the server tracks the device in KV
317332
(`devices:{seatTransactionId}`) and logs to Analytics Engine (binding: `DEVICE_COUNTS`, dataset: `cmdr_device_counts`).
318333
Devices older than 90 days are pruned on each write. If 6+ devices are active and no alert was sent in the past 30 days,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- In-app "Send feedback" messages (open beta). One row per submission, written by
2+
-- POST /feedback. This table is the durable sink: the Discord notification only carries
3+
-- a truncated preview. `email` is the optional reply-to address the sender chose to
4+
-- attach; no install id of any kind is stored, so feedback can't be joined to the
5+
-- analytics stream.
6+
CREATE TABLE feedback (
7+
id INTEGER PRIMARY KEY AUTOINCREMENT,
8+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
9+
feedback TEXT NOT NULL,
10+
email TEXT, -- optional reply-to, nullable
11+
app_version TEXT NOT NULL,
12+
os_version TEXT NOT NULL,
13+
build_mode TEXT -- 'release' | 'debug', nullable
14+
);
15+
16+
CREATE INDEX idx_feedback_created ON feedback(created_at);

apps/api-server/src/discord.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
22
import {
33
buildErrorReportPayload,
44
buildEvictionPayload,
5+
buildFeedbackPayload,
56
formatBytes,
67
postErrorReportNotification,
78
postEvictionNotification,
89
type ErrorReportNotification,
10+
type FeedbackNotification,
911
} from './discord'
1012

1113
const baseNotification: ErrorReportNotification = {
@@ -112,6 +114,42 @@ describe('buildErrorReportPayload', () => {
112114
})
113115
})
114116

117+
describe('buildFeedbackPayload', () => {
118+
const baseFeedback: FeedbackNotification = {
119+
buildMode: 'release',
120+
appVersion: '0.14.0',
121+
osVersion: 'macOS 26.0',
122+
feedback: 'Love the app! The Brief mode columns are perfect.',
123+
}
124+
125+
it('puts the feedback text in the embed description with a [PROD] title prefix', () => {
126+
const payload = buildFeedbackPayload(baseFeedback) as {
127+
embeds: { title: string; description: string; fields: { name: string; value: string }[] }[]
128+
}
129+
expect(payload.embeds[0].title).toBe('[PROD] Feedback')
130+
expect(payload.embeds[0].description).toBe(baseFeedback.feedback)
131+
expect(payload.embeds[0].fields.map((f) => f.name)).toEqual(['App version', 'OS'])
132+
})
133+
134+
it('prefixes [DEV] for debug builds and adds a reply-to field when an email is attached', () => {
135+
const payload = buildFeedbackPayload({
136+
...baseFeedback,
137+
buildMode: 'debug',
138+
email: 'tester@example.com',
139+
}) as { embeds: { title: string; fields: { name: string; value: string }[] }[] }
140+
expect(payload.embeds[0].title).toBe('[DEV] Feedback')
141+
expect(payload.embeds[0].fields).toContainEqual({ name: 'Reply to', value: 'tester@example.com', inline: true })
142+
})
143+
144+
it('truncates very long feedback below the Discord description cap', () => {
145+
const payload = buildFeedbackPayload({ ...baseFeedback, feedback: 'x'.repeat(10_000) }) as {
146+
embeds: { description: string }[]
147+
}
148+
expect(payload.embeds[0].description.length).toBeLessThanOrEqual(4096)
149+
expect(payload.embeds[0].description).toContain('full text in the feedback table')
150+
})
151+
})
152+
115153
describe('buildEvictionPayload', () => {
116154
it('formats a plain content message', () => {
117155
const payload = buildEvictionPayload({

apps/api-server/src/discord.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ export interface ErrorReportNotification {
2525
userNote?: string
2626
}
2727

28+
export interface FeedbackNotification {
29+
/**
30+
* `[DEV]`/`[PROD]` prefix logic mirrors error reports: dev-build feedback (mostly
31+
* the maintainer testing) stays visually separate from real beta-tester traffic.
32+
*/
33+
buildMode: 'release' | 'debug'
34+
appVersion: string
35+
osVersion: string
36+
/** Reply-to email the sender chose to attach; absent means they want to stay anonymous. */
37+
email?: string
38+
feedback: string
39+
}
40+
2841
export interface EvictionInfo {
2942
evictedCount: number
3043
freedBytes: number
@@ -33,6 +46,12 @@ export interface EvictionInfo {
3346

3447
const ERROR_REPORT_EMBED_COLOR = 0xff6b6b
3548
const USER_NOTE_EMBED_CAP = 500
49+
const FEEDBACK_EMBED_COLOR = 0x5bc0de
50+
/**
51+
* Discord caps embed descriptions at 4096 chars. The full text always lives in the
52+
* D1 `feedback` table, so a truncated embed never loses data.
53+
*/
54+
const FEEDBACK_EMBED_CAP = 3500
3655

3756
/** "1.23 GB", "456 MB", "789 KB", "12 B". */
3857
export function formatBytes(bytes: number): string {
@@ -79,6 +98,34 @@ export function buildErrorReportPayload(n: ErrorReportNotification): unknown {
7998
}
8099
}
81100

101+
/** Build the Discord webhook JSON body for a new in-app feedback message. */
102+
export function buildFeedbackPayload(n: FeedbackNotification): unknown {
103+
const truncated =
104+
n.feedback.length > FEEDBACK_EMBED_CAP
105+
? n.feedback.slice(0, FEEDBACK_EMBED_CAP) + '… (full text in the feedback table)'
106+
: n.feedback
107+
108+
const fields: { name: string; value: string; inline?: boolean }[] = [
109+
{ name: 'App version', value: n.appVersion, inline: true },
110+
{ name: 'OS', value: n.osVersion, inline: true },
111+
]
112+
if (n.email) {
113+
fields.push({ name: 'Reply to', value: n.email, inline: true })
114+
}
115+
116+
const titlePrefix = n.buildMode === 'debug' ? '[DEV] ' : '[PROD] '
117+
return {
118+
embeds: [
119+
{
120+
title: `${titlePrefix}Feedback`,
121+
description: truncated,
122+
color: FEEDBACK_EMBED_COLOR,
123+
fields,
124+
},
125+
],
126+
}
127+
}
128+
82129
/** Build the Discord webhook JSON body for an eviction summary. */
83130
export function buildEvictionPayload(info: EvictionInfo): unknown {
84131
return {
@@ -127,3 +174,7 @@ export async function postErrorReportNotification(
127174
export async function postEvictionNotification(webhookUrl: string, info: EvictionInfo): Promise<void> {
128175
await postWithRetry(webhookUrl, buildEvictionPayload(info), 'eviction')
129176
}
177+
178+
export async function postFeedbackNotification(webhookUrl: string, notification: FeedbackNotification): Promise<void> {
179+
await postWithRetry(webhookUrl, buildFeedbackPayload(notification), 'feedback')
180+
}

0 commit comments

Comments
 (0)