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, a per-patch "recommend" vouch, and a photo gallery per patch. 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; it is unconditionally shipped to every user, signed in or guest. Post.isPinned is now also how announcements reach users: release notes moved off the old broadcast inbox and onto the general board as pinned official posts, published by npm run release:announce. 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)

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; guests can read, post, comment, vote, report, and block — see ContentActionsProvider.tsx comment on why guests are deliberately included; favoriting is the exception and is members-only, see favorites) and by admin role for pinning and the moderation queue.

User-facing surfaces

How it works (end-to-end)

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:255) runs assertPublishable (the text filter, throws BadRequestException before any DB write) then moderation.assertNotSuspended (throws ForbiddenException if the author is suspended), 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) and the resulting URLs are attached to the post/comment body along with parallel GPS coordinates (imageCoords, write-once).

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).

Route Guard Purpose
GET /community/general/posts?sort= Optional General feed, new/top
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
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 Gallery, block-filtered
POST /patches/:patchId/photos JWT Upload a photo
DELETE /photos/:photoId JWT Delete own photo
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

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

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