Scout — Full Product Context → feature documentation

Community, user-generated content, and moderation

Scout has a Reddit-shaped community layer bolted onto its patch catalog: a general discussion feed, one discussion 'board' per patch, threaded comments, upvotes, a per-patch…

Summary

Scout has a Reddit-shaped community layer bolted onto its patch catalog: a general discussion feed, one discussion "board" per patch, threaded comments, upvotes, and a per-patch "recommend" vouch. A patch's photos from scouts are board posts: the patch's Gallery tab shows the board's posts that have photos ("Scout posts"), and a scout posts one of their own visit photos to the board straight from the photo lightbox ("Post from My Visit", built 2026-10-11, shipped in v1.1.106 — see Status). Since the quick photo post (built 2026-10-25, not yet in a store release — see Status) adding photos is one tap with no title or description: Gallery's Add photos posts library picks, and the lightbox's + Post posts the visit photo on screen. Each add is still a board post, so it collects upvotes and comments. The older per-patch photo gallery (PatchPhoto) is retired from the new patch screen, not deleted. All of it is user-generated content (UGC), so it ships with the App Store Review Guideline 1.2 UGC trio built directly into the backend: a pre-publication text filter, a shared report/block action sheet on every content surface, and an admin moderation queue that can delete content and suspend an author in one action. Symmetric blocking is enforced in SQL on every read path (feed, thread, photo list, board summary, search) and on every write path (vote, comment, reply, like) — not just hidden client-side. None of this is behind a feature flag. Everything except parent boards is shipped to every user (guests read; posting asks for an account — see Status); parent boards are built but not yet in a live store release (see Status).

Parent boards (built 2026-10-13; in the v1.1.108 build, not live yet): every campaign has a board too — Route 66, National Parks, Civil War… A parent board shows the posts made to the campaign itself and every human post on its member patch boards (campaign → its non-admin-only collections → patch_collections). A post is one row, so a post on a stop that sits in two campaigns (Petrified Forest is in Route 66 and National Parks) appears on both boards with the same votes and comments — posting to a stop is how you reach more people. The hub's feed is now every board, with a Boards rail (your pinned boards first, then six featured) and Sort & filter: My Patches' glass pull-down, with a shared persisted sort (Newest / Top / Most comments) replacing the New/Top toggle on every community feed. Design: mobile/research/community-boards-lab.html; spec: docs/superpowers/specs/2026-10-13-community-parent-boards-design.md. Post.isPinned is now also how announcements reach users. Release notes were pinned official posts here (npm run release:announce) until 2026-09-27; they now live on the What's new sheet, and no new RELEASE NOTES posts are created (the old ones stay). There is still no admin UI that toggles a pin — the endpoint and togglePin hook remain uncalled — but pinning is no longer reachable only through auto-generated content.

Status (shipped / beta-badged / flagged off)

Everything else below is fully shipped, unconditionally on. No feature flag gates any part of this — absent from both the mobile flag registry (mobile/src/config/feature-flags.ts) and the backend's flag definitions (backend/src/admin/feature-flag-definitions.ts); grepped for community/moderat/report/block/recommend/photo in both files and found no matches. Gating is entirely by auth state (guest vs. signed-in) and by admin role for pinning and the moderation queue. Guests can read, report and block (ContentActionsProvider.tsx comment on why guests are deliberately included); posting, commenting and voting ask a guest to create an account — every one goes through useRequireAuth, whose isSignedIn is false for a guest (mobile/src/hooks/useRequireAuth.ts, mobile/src/providers/authState.ts:43; the composer at mobile/src/components/community/PostComposerScreen.tsx:171). This doc said guests could post until 2026-10-13; that was wrong. Favoriting is members-only too, see favorites. Pinning a parent board needs no account — pins live on the device.

User-facing surfaces

How it works (end-to-end)

Parent boards (2026-10-13). A parent board's feed is CommunityPostsService.listCampaignPosts (backend/src/community-posts/community-posts.service.ts:220): posts with campaignId = <id> (the board's own) OR patchId IN the campaign's members (campaignMembers :192 — patch_collections through collections where campaignId = <id> AND adminOnly = false), the patch posts limited to ROLLUP_PATCH_POSTS (isOfficial = false AND isAutoGenerated = false, board-scope.ts:17) so the 1,976 auto-generated Visitor's Guides never bury human posts. One SQL row per post, so a post on a stop in two campaigns appears on both boards with identical counts. Blocks filter in SQL as everywhere (hiddenAuthorIds); Visited is computed per post's OWN patch (visitedPairs); a pin floats only on the board it was made on (floatHomePinned) — a patch board's pinned post sorts like any other on a parent board. Optional filters: collectionId (a chip; drops the board's own posts) and patchIds (Filter → Boards; the token own is the board's own posts). Sort is orderByFor (sort.ts:15): Newest, Top (upvotes then newest), Most comments (comment count then newest). The hub is listHubPosts (:258) — General + campaign posts + every patch's human posts, or the chosen campaigns and/or general, General's pins first. Board counts are campaignSummaries (:288): visible posts and last-7-days per campaign by the same rules, computed in one pass. Posting to a parent board is createPost({ patchId: null, campaignId }) (:479) — text filter and suspension check first, 404 for an unknown campaign. On the device, every board/patch name comes from synced content (useContent); the API adds only campaignId to each post. Pins (communityPrefs.pinnedBoardIds) and the sort (communityPrefs.sort) live in the persisted Zustand store; Boards picks and chips are screen state. Every write calls invalidateCommunityFeeds (the whole communityPosts key prefix + the summary).

Posting. Client calls POST /patches/:patchId/posts (or /community/general/posts for the general board) with a JwtAuthGuard. CommunityPostsService.createPost (backend/src/community-posts/community-posts.service.ts:479-519) first checks that the post has a title OR a photo (:486-491): since the quick photo post (2026-10-25) CreatePostDto.title is optional (backend/src/community-posts/dto/index.ts:9-15), a post with at least one image may have an empty or missing title (stored as ''), and a post with no images and a blank/whitespace title is rejected 400 A post needs a title or a photo before any DB work. A titled post is stored exactly as sent. Then it runs assertPublishable (the text filter, throws BadRequestException before any DB write) then moderation.assertNotSuspended (throws ForbiddenException if the author is suspended), validates the image side-arrays, validates the patch exists, and inserts the Post row. Images are uploaded separately first via POST /post-images (multipart, resized/re-encoded to webp, EXIF-rotated, backend/src/community-posts/community-posts.controller.ts:155-169) and the resulting URLs are attached to the post/comment body along with parallel GPS coordinates (imageCoords, write-once) and, on a post, parallel source-asset hashes (imageSourceHashes, write-once, below). Since 2026-10-11 every post path marks the patch board's cached feeds stale after a create (invalidateBoard, mobile/src/query/queries/community.ts:14, called from mobile/app/post-create/[id].tsx:32 and from usePhotoPost, mobile/src/hooks/usePhotoPost.ts:116-119), so the Community tab, the board and Gallery's Scout posts show the new post on the next read; before that nothing did, and a new post appeared only after the one-minute staleTime.

The quick photo post (2026-10-25; the visit-photo path built 2026-10-11, shipped in v1.1.106). Both one-tap entry points — the lightbox's + Post and Gallery's Add photos — go through one hook, usePhotoPost (mobile/src/hooks/usePhotoPost.ts:77-138). post(prepare, onPosted) asks a guest for an account ("Create an account to post your photos.", :25, :124-127), ignores a second call while one is in flight (a ref, not state, so a second tap before the re-render is still stopped, :85-92), resolves the photos with prepare, and hands them to createPhotoPost. Once the server has the post nothing after it can reach the failure path (a Retry would make a second post): it fires analytics.trackPostCreated({ board: 'patch', imageCount, patchId }), invalidates the board without waiting on the refetch, and calls onPosted (:108-120), each wrapped so a throw is only logged. A failure becomes postFailureFor(e) (:41-49): a 4xx whose JSON carries a message (a string, or class-validator's array) is shown as written with canRetry: false; anything else is "Couldn't post. Check your connection and try again." with canRetry: true. retry() re-runs the last attempt with the photos it already prepared, so it never reopens the picker or resolves the visit photos again; only a failure inside prepare prepares again (:99-101, :129-133).

The two halves live in mobile/src/services/visitPostSubmit.ts. prepareVisitPhotos (:68-97, lightbox only) turns visit photos into PreparedPostPhotos: it first resolves each to its library FILE (resolveAssetFile, mobile/src/lib/assetFile.ts — getAssetInfoAsync(..., { shouldDownloadFromNetwork: true }).localUri). A visit photo's uri is a ph:// PhotoKit ref, which the hasher cannot read and expo-image-manipulator never finishes compressing — until 2026-10-11 on device the post spun forever and no post was made. A photo with no readable file fails the post before anything is uploaded (the failure toast and Retry). Then, per photo and in parallel, all from that file: the SHA-256 of the original asset bytes (hashAssetBytes, the same definition as PatchPhoto.sourceHash), where it was taken (readAssetCoords, mobile/src/lib/assetLocation.ts:24-31 — PHAsset.location, Android the EXIF header) and its dimensions (the file's when the photo has none). Gallery's library picks skip this step: pickImages already returns compressed files with their own EXIF coords, and they post with hash: null (mobile/src/components/patch-detail-v3/ScoutPosts.tsx:64). createPhotoPost (visitPostSubmit.ts:103-119) refuses an empty list, caps at MAX_ATTACHED_IMAGES (10), uploads each photo to POST /post-images, then makes ONE POST /patches/:patchId/posts with title: '', body: '', images, imageCoords and imageSourceHashes. Each photo's lookup, and each photo's hash and each upload, is bounded by PHOTO_STEP_TIMEOUT_MS (60s, visitPostSubmit.ts:33): a step that never settles fails the post with the failure toast/tile and Retry instead of a frozen spinner. A photo whose hash or location cannot be read still posts with a null entry; an upload failure aborts before the create, so no post is half made (images already uploaded stay in S3 — see Edge cases). The location is public: imageCoords goes back to every client in PostResponseDto (backend/src/community-posts/dto/index.ts:87); the app shows it only as the "Taken here" marker. The hashes are not: normalizeImageSourceHashes (backend/src/common/image-source-hashes.ts:18-37) requires the same length as images, at most 10 entries, each lowercase 64-hex or null, and stores NULL when every entry is null; postView returns them only when the viewer is the post's author, null to everyone else (community-posts.service.ts:140-142). On a lightbox post's success handlePosted records the photo as posted (markVisitPhotosPosted, mobile/src/domain/store.ts:3070-3074 — which also fires the photo_published>=1 feature event, "Going Public", see achievements); a Gallery Add photos post records nothing on the device.

Knowing a visit photo is posted. useVisitPhotoPosting (mobile/src/hooks/useVisitPhotoPosting.ts:42-160) keeps two sources. (1) A persisted postedVisitPhotos record, patchId → assetId → postId (mobile/src/domain/postedVisitPhotos.ts, store.ts:858-865), answers on the first frame after a cold start. (2) The viewer's own posts on the board, matched by imageSourceHashes against each photo's hash (findPostByHash, visitPost.ts:59-62), reconcile it once the board loads. The hash is of the photo's library FILE (resolveAssetFile with download: false — a background check never pulls iCloud originals, so a photo with no local file learns nothing), the same bytes the submit hashed; it used to hash the ph:// ref, which always failed, so no post was ever learned by hash. Each lookup is a getAssetInfoAsync (~200ms of main-thread decode on iOS), so a photo is resolved only when its hash can change something: an UNrecorded photo while the loaded board holds a post of the viewer's that carries hashes and is not yet in the record, or a recorded photo whose post has more than one image (to find its page). The lookups run one at a time, and resolveAssetFile caches successful resolutions for the life of the process (a null is retried), so a board that confirms the record — the usual case once you have posted — reads no photo at all, and reopening My Visit repeats no lookup (useVisitPhotoPosting.ts:54-95, assetFile.ts:20-24). A match learns a record (a post made on another device, or after a reinstall); a record is dropped only when its stored post is ABSENT from a fresh board — loaded, not fetching, not stale — (the post was deleted or moderated away), so the photo can be posted again. The drop needs no hash. A hash miss alone never drops one: a post can be on the board with a null hash for that photo (the hash failed at submit, or an older backend sends no imageSourceHashes). A stale or failed board never drops a record (useVisitPhotoPosting.ts:97-120). Once the board has the post, fieldsFor also attaches it, built with postsToLightboxItems and paged to this photo by its hash (photo 0 when the hash is unknown), so a posted photo opens with the post block under it (useVisitPhotoPosting.ts:134-157). My Visit runs this hook; the Album place page reads the record alone (Posted pill, no post block). The record replaced the gallery-era publishedPhotos key, which a persist migration deletes (store.ts:3209-3212) because its ids were PatchPhoto rows, not posts.

Reading a feed. listPosts first calls moderation.hiddenAuthorIds(viewerId) and folds the result into the Prisma where clause (userId: { notIn: [...hidden] }) — blocked authors' posts never leave the database, they are not filtered client-side after the fact. Comment counts on a filtered feed are recomputed with a second block-aware query (visibleCommentCounts) rather than trusting Prisma's _count, so a feed row never claims "3 comments" when a blocked author's comment would make only 1 visible.

Reading one post. GET /posts/:postId additionally calls moderation.assertNotBlocked(viewerId, post.userId, 'Post'), which throws a NotFoundException (not a 403, and not a "you're blocked" message) if either party has blocked the other — closing the deep-link/stale-list-row bypass a pure feed filter would leave open.

Voting/commenting/liking. Every mutation that targets someone else's content (vote, unvote, createComment, likeComment, unlikeComment) calls assertNotBlocked before writing. A blocked party literally cannot move the vote count on, or reply to, or like something belonging to the person who blocked them (or vice versa).

Comment threading. One level deep only, enforced server-side (createComment throws BadRequestException('Cannot reply to a reply') if parent.parentId is set) and mirrored client-side in mobile/src/components/community/commentThread.ts. A reply whose parent was hidden by a block is promoted to top-level rather than dropped, both server- side (commentsForPatch) and in the client grouping function, so a visible reply is never silently swallowed.

Optimistic comment posting. mobile/src/hooks/usePost.ts:52 appends a locally-constructed temp comment (id temp-<postId>-<timestamp>, authorVisited: false) to state immediately on submit, then replaces the whole comment list with the server's authoritative response (correct ids, real authorVisited) once the request resolves. On failure, the temp comment is filtered back out and the error is rethrown to the caller.

Reporting. Every report — whether posted through the legacy POST /posts/:id/report / POST /post-comments/:id/report routes or the canonical POST /api/moderation/reports — lands in ModerationService.report (backend/src/moderation/moderation.service.ts:217). It resolves the content's author, checks the reporter isn't blocked from the author (returns "not found" rather than confirming a block — a report response was itself a block-detection oracle before this check existed), rejects self-reports, and upserts a ContentReport row keyed on (targetType, targetId, reporterId) — re-reporting the same item is idempotent but reopens a dismissed report.

Resolving a report. resolveReport runs in one Prisma transaction: content_removed deletes the underlying row; author_suspended also sets Profile.suspendedAt; either resolution additionally closes every other pending report against the same target so the queue's pending count doesn't stay permanently inflated by duplicate reports of already-actioned content.

Suspension enforcement. assertNotSuspended is checked on createPost, createComment, and patch-photos upload — a suspended account cannot post, comment, or upload a new photo. It is not checked on voting, or liking (see Edge cases).

Triage. The boards are a report source for the triage skill (.claude/skills/triage/), which files board tickets from real user reports. Post.triage and PostComment.triage are the same nullable admin tag message_comments and feedback already carry, and null (untriaged) is the single predicate the collect step selects on across all four sources. Without the column the run would have only Ticket.sourceRefs to dedupe on, so a post judged noise — which never gets a ticket — would resurface on every future run forever. CommunityReportsAdminService (backend/src/admin/api/community-reports-admin.service.ts) serves the feed and stamps the tag; it excludes isOfficial/isAutoGenerated posts (prod holds ~2000 posts, nearly all seeded Visitor's Guides) but deliberately does not filter comments that way, since a reply left on a guide is still a real person. Triage is read-and-tag only: it never deletes content or suspends an author — that is the moderation queue's job.

Data model (Prisma models)

All in backend/prisma/schema.prisma, @@schema("public").

API surface

All community routes are mounted at the root (no global prefix), except moderation's user-facing routes which are explicitly mounted under /api so the SPA catch-all in main.ts can never intercept them (moderation.controller.ts header comment).

Parent boards (2026-10-13): GET /community/feed?sort=&boards= (optional auth; boards = campaign ids and/or general, absent = every board), GET /community/campaigns/summary (optional; [{ campaignId, postCount, weekCount }], a board with no posts is absent), GET /community/campaigns/:campaignId/posts?sort=&collectionId=&patchIds= (optional; 404 unknown campaign) and POST /community/campaigns/:campaignId/posts (JWT) — community-posts.controller.ts:24,71,78,85. Every feed's sort now also takes comments; posts carry campaignId.

Route Guard Purpose
GET /community/general/posts?sort= Optional General feed, new/top/comments
POST /community/general/posts JWT Create general post
GET /community/search/posts?q= Optional Search post title/body
GET /community/collections/:collectionId/board Optional Two-row board summary card for a whole collection
GET /community/patches/:patchId/board Optional Same card for one patch
GET /patches/:patchId/posts?sort= Optional Patch board feed
POST /patches/:patchId/posts JWT Create post on a board (optional imageCoords and, since 2026-10-11, imageSourceHashes, both parallel to images)
GET /posts/:postId Optional Post + comments
PATCH /posts/:postId JWT Edit (owner or admin)
DELETE /posts/:postId JWT Delete (owner or admin)
POST/DELETE /posts/:postId/vote JWT Upvote/remove
POST /posts/:postId/pin JWT (admin-checked in-service) Toggle pin — no UI calls this (see Summary)
POST /posts/:postId/report JWT Legacy report route
POST /posts/:postId/comments JWT Add comment/reply
PATCH/DELETE /post-comments/:commentId JWT Edit/delete comment
POST/DELETE /post-comments/:commentId/like JWT Like/unlike
POST /post-comments/:commentId/report JWT Legacy report route
POST /post-images JWT Upload an image for a post/comment
GET /patches/:patchId/recommendations Optional Vouch count + own status
POST/DELETE /patches/:patchId/recommendations JWT Add/remove own vouch
GET /patches/:patchId/photos Optional Retired PatchPhoto gallery, block-filtered — kept for the flag-off screen and builds in the field
POST /patches/:patchId/photos JWT Upload a gallery photo — same, kept
DELETE /photos/:photoId JWT Delete own gallery photo — same, kept
GET /api/moderation/report-reasons None Served reason list (wording can change without an app release)
POST /api/moderation/reports JWT Canonical report endpoint
GET /api/moderation/blocks JWT List own blocks
POST/DELETE /api/moderation/blocks/:userId JWT Block/unblock
GET /api/admin/moderation/reports?status= AdminGuard Queue
GET /api/admin/moderation/reports/pending-count AdminGuard Badge count
PATCH /api/admin/moderation/reports/:id/resolve AdminGuard Resolve a report
GET /api/admin/community/reports?filter= AdminGuard Triage feed: user-authored posts + all post comments
PATCH /api/admin/community/posts/:id/triage AdminGuard Set/clear a post's triage tag
PATCH /api/admin/community/post-comments/:id/triage AdminGuard Set/clear a comment's triage tag

"Optional" = OptionalJwtAuthGuard — a signed-out reader still sees content; the guard only decides whose blocks apply.

Key files

Parent boards (2026-10-13)

Backend

Mobile

Admin

Configuration and flags

No feature flags (see Status). Two environment-shaped configuration points:

No rate limiting is applied to posting, commenting, voting, or reporting: grepped community-posts, moderation, patch-photos, and patch-recommendations for Throttle/ThrottlerModule and found none (other modules — auth, trips, album, store-analytics — do use @nestjs/throttler; these do not).

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Parent boards (2026-10-13)

Backend (Jest)

Mobile (Jest screen tests, mobile/screen-tests/)

Maestro E2E (mobile/maestro/tests/community.yaml, tags community, moderation, fixture, smoke, runs against PROD) — a single consolidated flow (absorbed four earlier separate flows on 2026-08-26): reads the real general board (read-only, since prod's general board is real user content), then exercises upvote, comments, replies, and delete on one post created on a hidden, uncollectable fixture patch (e2e-test-patch) it cleans up after itself. It opens the moderation action sheet and asserts the Report/Block rows are present and can be canceled, and separately checks the blocked-accounts screen's empty/error rendering. It does not exercise a successful report submission end-to-end: on a single-account fixture board the account only ever sees its own content (Report is hidden on your own posts, API rejects self-reports) and @Scout's pinned, overflow-less Visitor's Guide — so there is nothing a lone test account can legally report. The flow's own header comment states this gap explicitly and says covering the report happy path needs a second provisioned author. Blocking-between-two-accounts is likewise not exercised end-to-end in Maestro.

Open questions