Skip to main content

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:

ComponentSelectorType sentExtra fields
story-feedback/story-feedback-dialog.tssc-story-feedback-dialogstorystoryTitle
account-feedback/account-feedback-dialog.tssc-account-feedback-dialogaccount
feature-request/feature-request-dialog.tssc-feature-request-dialogfeature_requestfeatureTitle

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 / FeedbackApilibs/stretched-types/src/feedback/feedback.ts. type + message required; 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 the FEEDBACK_URL injection token (feedback-url.token.ts), attaching the Firebase ID token when signed in, with a 10s timeout. Errors map to FeedbackError codes (unauthorized, forbidden, timeout, unknown) and toUserMessage() renders them for the UI.
  • The app provides FEEDBACK_URL from environment.feedbackUrl in apps/stretched/src/app/app.config.ts (emulator / dev / prod URLs in apps/stretched/src/environments/).

The Cloud Function

apps/firebase-functions/src/feedback/feedback.tsPOST /feedback/send. Processing order:

  1. AuthverifyAuth requires a Bearer Firebase ID token.
  2. Per-user rate limit — runs before payload validation so abusive clients learn nothing from error shapes.
  3. Validation — known type, non-empty message, minimum 50 words.
  4. Tier gatefeature_request requires the tier custom claim at rank ≥ months (FEATURE_REQUEST_MIN_TIER); the claim is set by the (future) subscription webhook, see Pricing and Subscriptions.
  5. Identity overrideuserId / userEmail / displayName are overwritten from the verified token; client-supplied identity is never trusted.
  6. Daily quota, then send via Resend (https://api.resend.com/emails) to feedback@stretched.money. Until that domain is verified in Resend, the sender is the sandbox onboarding@resend.dev (swap FROM_EMAIL once 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.tscheckFeedbackRateLimit(), one Firestore doc per user, all guards inside a transaction:

GuardRule
1. Active backoff penaltyA previous violation set backoffUntil; hitting the server during a penalty grows the exponent further.
2. Cooldown30s × 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 capMax 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-onlylibs/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.money in Resend and switch FROM_EMAIL off the sandbox sender.
  • The function doc-comment still says the gate is tier: 'pro' — the code gates on months; update the comment when touching the file.
  • Client-side tier gating for the feature-request dialog (server already enforces it).