Feedback System
See also: Firestore and Security Rules · Secrets Management · Pricing and Subscriptions
User feedback flows from three dialog components, through a shared FeedbackService, to the feedback Cloud Function, which rate-limits per user, enforces a global daily email quota, and emails the team via Resend.
The three dialogs
libs/stretched-components/src/components/feedback/ — one folder per dialog, shared base styles in _feedback-dialog.scss:
| Component | Selector | Type sent | Extra fields |
|---|---|---|---|
story-feedback/story-feedback-dialog.ts | sc-story-feedback-dialog | story | storyTitle |
account-feedback/account-feedback-dialog.ts | sc-account-feedback-dialog | account | — |
feature-request/feature-request-dialog.ts | sc-feature-request-dialog | feature_request | featureTitle |
Each is an open input + closed/submitted outputs dialog composing the lib's Input/Textarea services and surfacing errors via ToastService. The feature-request dialog is the premium one — server-side it is gated to the Months tier and above (see below); a client-side gate is still a TODO in the component.
Client service and contract
FeedbackPayload/FeedbackApi—libs/stretched-types/src/feedback/feedback.ts.type+messagerequired; everything else optional so context can be appended without schema breaks;meta?: Record<string, unknown>is the open extension point.FeedbackService(libs/stretched-components/src/components/feedback/feedback.service.ts) — POSTs to theFEEDBACK_URLinjection token (feedback-url.token.ts), attaching the Firebase ID token when signed in, with a 10s timeout. Errors map toFeedbackErrorcodes (unauthorized,forbidden,timeout,unknown) andtoUserMessage()renders them for the UI.- The app provides
FEEDBACK_URLfromenvironment.feedbackUrlinapps/stretched/src/app/app.config.ts(emulator / dev / prod URLs inapps/stretched/src/environments/).
The Cloud Function
apps/firebase-functions/src/feedback/feedback.ts — POST /feedback/send. Processing order:
- Auth —
verifyAuthrequires a Bearer Firebase ID token. - Per-user rate limit — runs before payload validation so abusive clients learn nothing from error shapes.
- Validation — known
type, non-emptymessage, minimum 50 words. - Tier gate —
feature_requestrequires thetiercustom claim at rank ≥months(FEATURE_REQUEST_MIN_TIER); the claim is set by the (future) subscription webhook, see Pricing and Subscriptions. - Identity override —
userId/userEmail/displayNameare overwritten from the verified token; client-supplied identity is never trusted. - Daily quota, then send via Resend (
https://api.resend.com/emails) tofeedback@stretched.money. Until that domain is verified in Resend, the sender is the sandboxonboarding@resend.dev(swapFROM_EMAILonce verified).
The RESEND_API_KEY secret comes from Firebase Secret Manager via defineSecret — see Secrets Management.
Rate limiting — feedback_limits/{uid}
apps/firebase-functions/src/shared/rate-limit.ts — checkFeedbackRateLimit(), one Firestore doc per user, all guards inside a transaction:
| Guard | Rule |
|---|---|
| 1. Active backoff penalty | A previous violation set backoffUntil; hitting the server during a penalty grows the exponent further. |
| 2. Cooldown | 30s × 2^backoffExponent since the last allowed submission (exponent capped at 6 → ~32 min; hard cap 1 h). Violation sets a fresh penalty; a successful submission resets the exponent to 0. |
| 3. Hourly cap | Max 10 successful submissions per rolling 1-hour window (a quota, not abuse — no exponent bump). |
Rejections send 429 with a Retry-After header. Firestore failures fail open so infra issues never block legitimate users.
Daily email quota — email_quota/global
checkDailyEmailQuota() (same file): a single transaction-guarded doc { date, count } that self-resets when the stored UTC date changes. The cap is 80/day (DAILY_EMAIL_LIMIT), deliberately below Resend's free-tier 100/day to leave retry headroom. Exhaustion → 429 + Retry-After: 86400; Firestore errors fail open.
Both collections are function-only — libs/firebase-permissions/firestore.rules denies all client reads/writes on feedback_limits/{userId} and email_quota/{document} (the Admin SDK bypasses rules). See Firestore and Security Rules.
Open items
- Verify
stretched.moneyin Resend and switchFROM_EMAILoff the sandbox sender. - The function doc-comment still says the gate is
tier: 'pro'— the code gates onmonths; update the comment when touching the file. - Client-side tier gating for the feature-request dialog (server already enforces it).