Scout — Full Product Context → feature documentation

FAQ

The FAQ screen: Help & answers from the drawer's footer, grouped by category, one answer open at a time, written in markdown with an optional YouTube video, and a Send feedback card that stays even when the answers fail to load. Content is served from the database and edited directly in the production admin, so a wrong answer is fixed in a minute, not an App Store review.

Summary

FAQ is the in-app help screen. It opens from the drawer's Settings & help footer (and from scout://faq), shows "Help & answers / Frequently asked", then every published question grouped under its category (Getting started, Collecting, Collections, Account & privacy, …), each a collapsed row that opens to a markdown answer and, when one is attached, a YouTube poster that hands off to the YouTube app. One answer is open at a time. Under the sections sits a Still stuck or got an idea? card with a gold Send feedback button and an "or contact support" link.

The copy is not in the app binary. Since 2026-08-17 it comes from GET /api/faq, a public endpoint over the faq_entries table, and is authored on the admin's FAQ page. FAQ is deliberately not part of the content-publish pipeline: like messages and the landing page it is edited directly in whichever admin you are looking at, usually production (backend/prisma/schema.prisma:1732-1734, docs/features/admin-and-content-ops.md:44).

Status

Shipped, no feature flag. The screen has existed since 2026-05-14 (70ad2377); it became data-driven on 2026-08-17 (0dc41d30, PR #146), which deleted the bundled mobile/src/data/faq.ts and seeded its 12 entries into the database inside the migration (backend/prisma/migrations/20260816120000_add_faq_entries/migration.sql). The drawer row is ungated (mobile/src/components/navigation/drawerSections.ts:394-404).

User-facing surfaces

How it works

  1. Fetch. useFaq() (mobile/src/hooks/useFaq.ts:21) runs faqQuery() (mobile/src/query/queries/faq.ts:21-28), which calls api.getFaq() → GET /api/faq (mobile/src/domain/api.ts:193). The query key is ['faq'], not user-scoped, so signing in or out never discards the cache (mobile/src/query/keys.ts:23-25).
  2. Cache. staleTime 5 minutes, gcTime: Infinity (queries/faq.ts:6, :25-26). Infinity is load-bearing: the MMKV-persisted cache replaced the bundled array as the offline story, so a returning offline user gets yesterday's FAQ, not an error (queries/faq.ts:11-15). isError is surfaced only when there is no data at all (useFaq.ts:39): a failed background refetch never replaces a good cached FAQ with "We couldn't load the answers".
  3. Group. groupByCategory (mobile/src/components/faq/groupByCategory.ts:24-46) groups in first-appearance order; since the API returns rows already sorted by sortOrder, section order is whatever the admin dragged (useFaq.ts:16-19). An entry with a blank or missing category lands in a trailing More section (:8, :28, :40-44), a runtime guard at the network boundary even though the server rejects an empty category.
  4. Render. FaqSection draws the category eyebrow with a 14×1 brass rule and an "N entries" count (FaqSection.tsx:17-25, :58-66). FaqItem is the row: testID faq-q-<slug>, accessibilityState.expanded, a chevron that rotates 90° and tints brass over 220 ms (FaqItem.tsx:29-97). The screen holds a single openId; tapping the open row closes it (faq.tsx:38-40). FaqAnswer renders the markdown behind a 1.5 px brass left rule and, when youtubeId is set, FaqVideo (FaqAnswer.tsx:25-32).
  5. Markdown. The shared renderer (mobile/src/components/markdown/), also used by broadcast messages (markdownParse.ts:8-11). It supports #/## headings, paragraphs, - bullet lists, **bold**, *italic* and [text](url) (markdownParse.ts:20-22). A scout:// link navigates inside the app; any other link leaves it (Markdown.tsx:20-25). The admin editor's legend lists exactly this set (backend/admin-ui/src/pages/FaqEditorPage.tsx:20-28).
  6. Video. FaqVideo is a poster, not a player: the YouTube hqdefault.jpg thumbnail with a play badge, and a tap opens https://www.youtube.com/watch?v=<id> via Linking.openURL (FaqVideo.tsx:17-38). An inline WebView per open answer "would mean a WebView mounted per expanded entry, a real jank and layout risk on Android" (:6-12). The id is parsed server-side; the client never parses a URL (:14-15).
  7. Serve. GET /api/faq is deliberately unguarded (backend/src/faq/faq.controller.ts:4-11) and returns published rows ordered by sortOrder (faq.service.ts:90-96), projected to { slug, question, answer, category, youtubeUrl, youtubeId }; id, published and sortOrder are omitted on purpose (:6-16, :61-70). youtubeId is derived by youtubeVideoId() (backend/src/faq/youtube-url.ts:28-54): exact-match host allowlist (youtube.com, www., m., youtu.be), http(s) only, and an 11-character id from ?v=, the youtu.be path, or /shorts///embed/.
  8. Author. Admin → Content → FAQ (backend/admin-ui/src/components/layout/Sidebar.tsx:118; routes faq, faq/new, faq/:id at App.tsx:106-108). FaqPage is the list: drag to reorder (dnd-kit, optimistic, reverts on failure), a Published switch per row, Edit, and Delete behind a confirm that says the entry "will be removed from the app immediately. To hide it without losing the text, switch it to unpublished instead" (FaqPage.tsx:109-110). FaqEditorPage is create and edit on one screen with a live phone preview (FaqEntryPreview), a category field with a datalist of existing categories, a markdown legend, an optional YouTube link, and the Published switch (FaqEditorPage.tsx:31-35, :226-252). Save needs a non-blank question, answer and category (backend/admin-ui/src/lib/faq/faqDraft.ts:33-35). The preview thumbnails a video only when the link is unchanged since load, "fails closed" (FaqEntryPreview.tsx:9-15).
  9. Writes. POST /api/admin/faq derives the slug from the question (slugify, apostrophes removed so "What's a patch?" → whats-a-patch, faq.service.ts:41-48), suffixes -2, -3, … on collision (:195-203), and appends at the highest sortOrder + 10 (:24-25, :103-121). PATCH /:id writes field by field and never the slug (:123-139; the DTO has no slug, dto/faq-entry.dto.ts:60-69), because the slug is the mobile testID. PATCH /reorder demands every id exactly once and applies in one transaction (:154-192). DELETE /:id returns { success: true } rather than an empty 200 (:141-152). All under AdminGuard (faq-admin.controller.ts:6-7).
  10. Not a publish entity. /faq is absent from the admin's content-write prefixes (backend/admin-ui/src/lib/content-routes.ts:16-26, lib/api.ts:904-906), so the production admin can write it; faq_entries is not in content-entities.ts (schema.prisma:1732-1734, admin-and-content-ops.md:44, :358).

Data model

FaqEntry → faq_entries (backend/prisma/schema.prisma:1735-1757): id (uuid), slug (unique, immutable after create), question, answer (markdown), category, sort_order (steps of 10), youtube_url (nullable, validated server-side), published (default true), timestamps; index on (published, sort_order). Migration 20260816120000_add_faq_entries creates the table and seeds the 12 shipped entries across 5 categories (Getting started, Collecting, Collections, Store & physical patches, Account & privacy).

On the device: FaqEntry (mobile/src/domain/types.ts:1444-1462) is the public shape; the TanStack entry ['faq'] is persisted to MMKV with the rest of the query cache.

API surface

Method Path Auth Purpose
GET /api/faq none Published entries, sorted, public shape (faq.controller.ts:16-19)
GET /api/admin/faq AdminGuard Every entry incl. unpublished, with id/sortOrder/published (faq-admin.controller.ts:11-14)
POST /api/admin/faq AdminGuard Create; slug derived, appended last (:16-19)
PATCH /api/admin/faq/reorder AdminGuard Full id list → renumber by 10, one transaction (:21-26)
PATCH /api/admin/faq/:id AdminGuard Partial update, never the slug (:28-31)
DELETE /api/admin/faq/:id AdminGuard Remove; returns { success: true } (:33-36)

FaqModule is registered in backend/src/app.module.ts:132.

Key files

Configuration and flags

No feature flag. No environment variable. Device cache: 5-minute staleTime, never garbage-collected (queries/faq.ts:6, :25-26). Public endpoint, no rate-limit of its own beyond the API's global throttling.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions