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
- Drawer → Settings & help → FAQ: the third footer row, after Settings and
What's new (
drawerSections.ts:404,route: 'faq',HelpCircleIcon). The route is in the drawer's route list (drawerSections.ts:76) and the deep-link registry (mobile/src/dev/deepLinkRoutes.ts:85, category Core). - Deep link:
scout://faq. There is no per-entry link; the screen takes no params (mobile/app/(drawer)/faq.tsx:31-51reads none). - The screen (
mobile/app/(drawer)/faq.tsx): a flat canvas (no animated grid, matching the account screen,:59-60), inline top bar with the hamburger (:68-76), the hero "Help & answers / Frequently asked / A field guide to the field guide…" (:78-84), then exactly one of skeleton / error / empty / sections (:86-105), then theHelpCardoutside that branch so "whatever went wrong with the content, a help screen must never lose the route to a human" (:86-88,:107). - Help card (
mobile/src/components/faq/HelpCard.tsx): Send feedback pushes/feedback(:25); or contact support openshttps://scout-patches.com/supportin the in-app browser (:42,mobile/src/config/support.ts:1). - DEV screen mock:
scout://dev-screen-mock/faq?state=<slug>(mobile/src/dev/mocks/faq.tsx), frozen copies of the real published rows.
How it works
- Fetch.
useFaq()(mobile/src/hooks/useFaq.ts:21) runsfaqQuery()(mobile/src/query/queries/faq.ts:21-28), which callsapi.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). - Cache.
staleTime5 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).isErroris 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". - Group.
groupByCategory(mobile/src/components/faq/groupByCategory.ts:24-46) groups in first-appearance order; since the API returns rows already sorted bysortOrder, 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. - Render.
FaqSectiondraws the category eyebrow with a 14×1 brass rule and an "N entries" count (FaqSection.tsx:17-25,:58-66).FaqItemis the row: testIDfaq-q-<slug>,accessibilityState.expanded, a chevron that rotates 90° and tints brass over 220 ms (FaqItem.tsx:29-97). The screen holds a singleopenId; tapping the open row closes it (faq.tsx:38-40).FaqAnswerrenders the markdown behind a 1.5 px brass left rule and, whenyoutubeIdis set,FaqVideo(FaqAnswer.tsx:25-32). - 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). Ascout://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). - Video.
FaqVideois a poster, not a player: the YouTubehqdefault.jpgthumbnail with a play badge, and a tap openshttps://www.youtube.com/watch?v=<id>viaLinking.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). - Serve.
GET /api/faqis deliberately unguarded (backend/src/faq/faq.controller.ts:4-11) and returns published rows ordered bysortOrder(faq.service.ts:90-96), projected to{ slug, question, answer, category, youtubeUrl, youtubeId };id,publishedandsortOrderare omitted on purpose (:6-16,:61-70).youtubeIdis derived byyoutubeVideoId()(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=, theyoutu.bepath, or/shorts///embed/. - Author. Admin → Content → FAQ (
backend/admin-ui/src/components/layout/Sidebar.tsx:118; routesfaq,faq/new,faq/:idatApp.tsx:106-108).FaqPageis 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).FaqEditorPageis 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). - Writes.
POST /api/admin/faqderives 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 highestsortOrder+ 10 (:24-25,:103-121).PATCH /:idwrites field by field and never the slug (:123-139; the DTO has noslug,dto/faq-entry.dto.ts:60-69), because the slug is the mobile testID.PATCH /reorderdemands every id exactly once and applies in one transaction (:154-192).DELETE /:idreturns{ success: true }rather than an empty 200 (:141-152). All underAdminGuard(faq-admin.controller.ts:6-7). - Not a publish entity.
/faqis 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_entriesis not incontent-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
mobile/app/(drawer)/faq.tsx— view model + layout; the four-state branch and the always-present help cardmobile/src/hooks/useFaq.ts,mobile/src/query/queries/faq.ts,mobile/src/query/keys.ts:23-25,mobile/src/domain/api.ts:193mobile/src/components/faq/:groupByCategory.ts,FaqSection.tsx,FaqItem.tsx,FaqAnswer.tsx,FaqVideo.tsx,FaqStates.tsx(skeleton / error / empty),HelpCard.tsxmobile/src/components/markdown/— the renderer shared with messagesmobile/src/components/navigation/drawerSections.ts:394-411— the footer rowbackend/src/faq/:faq.controller.ts(public),faq-admin.controller.ts,faq.service.ts,youtube-url.ts(the single YouTube parser),dto/faq-entry.dto.tsbackend/admin-ui/src/pages/FaqPage.tsx,FaqEditorPage.tsx,components/content-editor/FaqEntryPreview.tsx,lib/faq/faqDraft.ts,lib/api.ts:904-923mobile/src/dev/mocks/faq.tsx— DEV screen-mock states, real rows frozen
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
- Loading, error and empty are three different screens (
FaqStates.tsx). Error has Try again (faq-retry); empty ("No answers yet… send us a note below") does not, and the two must never be collapsed (:34-39). Both keep the help card. - First-ever load with no network shows the error state. There is no bundled
fallback any more and "no sensible default to fall back to" (
queries/faq.ts:17-19). After one successful load the cached copy serves offline indefinitely. - An admin edit reaches devices within the 5-minute stale window or on the next mount of the screen once stale; there is no push to invalidate.
- Category is free text. A typo creates a new section; the editor offers existing
names in a datalist but does not constrain them. Section order follows the first
entry of each category in the dragged order (
FaqPage.tsx:74). - The slug never changes, even if the question is rewritten, because screen tests
and the Maestro flow select on
faq-q-<slug>(schema.prisma:1737-1740). Seeded slugs are shortened labels (why-didnt-it-unlock,unlock-from-home) thatslugifywould not reproduce; that is intended (faq.service.ts:33-39). - A half-applied reorder is refused, not attempted: an id list that is not exactly
the full set 400s (
faq.service.ts:154-178). - Delete is immediate and uncascaded; the admin dialog steers toward unpublish instead.
- A blanked YouTube field is stored as null, not
''(faq.service.ts:81-84), and a non-YouTube or id-less URL 400s the save (dto/faq-entry.dto.ts:25-35). - No published entry carries a video today in the local snapshot the mocks were
frozen from, so the
FaqVideobranch is reachable only through tests and the mock gallery (mobile/src/dev/mocks/faq.tsx:19-24). - Stale code comments.
schema.prisma:1738,faq.service.ts:126,types.ts:1448and others citemaestro/tests/faq.yaml; that file does not exist. The FAQ step lives inmobile/maestro/tests/app-shell.yaml:147-176(its header notes it absorbedfaq.yaml,:4). The selector they protect is the same. - Stale rationale.
faq.controller.ts:7-9says the screen is reachable "before any sign-in". Since account-after-tutorial (2026-10-25) every signed-out phone opens on the tutorial (docs/features/auth-account-and-profile.md:29), so in practice the drawer, and the FAQ, are reached signed in. The endpoint stays public either way.
What this feature does NOT do
- It does not search. No search box, no filter; you scroll the sections.
- It does not link to a single entry.
scout://faqopens the screen collapsed; there is no?entry=parameter and no way to deep-link an open answer. - It does not play video in the app. A video is a poster that opens YouTube.
- It does not ask "Was this helpful?", record which questions are opened, or send
any analytics event. Nothing in
mobile/src/analyticsmentions the FAQ. - It is not personalised and not localised. Every user sees the same English list.
- It is not content in the publish-pipeline sense. There is no local-author-then-
publish step and no field-level diff; an edit in the prod admin is live on the next
fetch.
npm run db:pull-proddoes not need to run first. - It does not ship copy in the app. Deleting every row in prod empties the screen for everyone (the "No answers yet" state); there is no bundled fallback.
- It does not appear on the website.
GET /api/faqhas no consumer inlanding/ortrips-web/;scout-patches.com/supportis a separate static page. - It does not gate on sign-in or feature flags, and it does not change by platform.
Tests that cover it
mobile/screen-tests/faq.test.tsx(registered atmobile/screen-tests/screenTestRegistry.ts:70): collapsed-then-open on press, slug-derived testIDs, only the pressed question expands (regression for theentry.idbug), markdown bullets rendered as items, no video without a link, poster URL and YouTube hand-off with one, skeleton, error with a refetching retry, empty not error, and the help card surviving a failed load.mobile/src/components/faq/__tests__/groupByCategory.test.ts: empty input, first-appearance order, within-category order, no "More" when every entry has a category, blank and missing categories into a trailing "More".mobile/src/query/__tests__/faq.test.ts: key['faq']unscoped by user, never evicted, returns what the API served, propagates a fetch failure rather than an empty list.backend/src/faq/faq.service.spec.ts:slugify(apostrophes, separators, fallback),listPublished(published only, sorted,youtubeIdderived or null, internal columns omitted),adminList,create(slug, +10, first entry = 10, collision suffix, published default, draft, blank URL → null),update(field-by-field, slug never written even if smuggled),remove(parseable body),reorder(step 10, one transaction, returns rows, rejects missing / unknown / duplicate ids).backend/src/faq/youtube-url.spec.ts: watch, youtu.be, shorts, embed, extra params, mobile host, id alphabet; rejects other hosts, lookalike hosts, evil suffixes, no id, short / long / bad-character ids, bare youtu.be, non-http schemes, garbage.backend/src/faq/dto/faq-entry.dto.spec.ts: create accepts minimal / valid URL /''/null, rejects Vimeo, search URLs, empty question / answer, missing category; update accepts empty and single-field patches, still rejects bad URLs and blanking; reorder shape.- Admin:
backend/admin-ui/src/lib/faq/faqDraft.test.ts,components/content-editor/FaqEntryPreview.test.tsx(placeholders, saved-only thumbnail, whitespace link, unpublished badge),ContentEditorScreen.test.tsx.FaqPage.tsxandFaqEditorPage.tsxhave no page-level test. - Maestro:
mobile/maestro/tests/app-shell.yaml§ 4 opensscout://faq, asserts the heading, "Getting started",faq-q-what-is-scoutclosed, then open with its sentence.
Open questions
- How many entries prod holds today, and whether any carries a video. The migration seeded 12 across 5 categories; the local snapshot the mocks froze shows 10 published across 4 (the Store & physical patches pair gone or unpublished). Prod was not queried for this doc.