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
- Community hub —
mobile/app/(drawer)/community.tsx, deep linkscout://community. General (patch-less) feed sortable new/top and a live search box that queries local patch names instantly and backend posts on a 300ms debounce (mobile/src/hooks/useCommunitySearch.ts). Floating "New post" button. There is no favorited-boards strip — it was removed when favorites became a screen of their own; see favorites. - New general post —
mobile/app/community-new.tsx, deep linkscout://community-new. Thin wrapper aroundPostComposerScreenposting to the general board. - Per-patch board —
mobile/src/screens/PatchCommunityScreen.tsx, behind the routemobile/app/patch-community/[id].tsx(an eight-line re-export), deep linkscout://patch-community/<patchId>. Header with back/title and a bookmark owned by favorites (testIDcommunity-favorite) — favoriting a board IS favoriting its patch, one row for both, theRecommendBar(heart icon + live vouch count + Recommend/Recommended toggle), new/top sort, and the post feed. Floating "New post" button routes to/post-create/<patchId>. - Post composer —
mobile/app/post-create/[id].tsx(patch board) andmobile/app/community-new.tsx(general board), both wrappingPostComposerScreen. Title, optional body, up toMAX_ATTACHED_IMAGES(10) photos, and a context chip naming the board. The Post button's only gate is a non-empty trimmed title — the body and the photos are optional, and nothing on the screen says why the button is grey. A failed post and a failed upload are both a nativeAlert; the screen has no error text, no retry and no confirmation panel. - Post detail —
mobile/src/screens/PostScreen.tsx, behind the routemobile/app/post/[id].tsx(an eight-line re-export), deep linkscout://post/<id>. Title, byline with visited badge, image carousel, upvote, comment count, overflow menu (report/block, or delete for the owner/admin), and the one-level comment thread with reply/like/delete. - Overview tab (patch detail) — the vouch toggle (heart icon, no
visible count) sits in the hero image pager's top chrome
(
mobile/src/components/patch-detail-v2/HeroSwiper.tsx:219), which renders only while the Overview tab is active (mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:117-124).OverviewTabV2.tsxitself renders no recommend UI. The vouch count is only visible on the patch's Community board viaRecommendBar(mobile/src/screens/PatchCommunityScreen.tsx, the board's list header), not on the Overview tab — correct this if reused verbatim from prior copy. - Community tab (patch detail) —
mobile/src/components/patch-detail-v2/tabs/CommunityTab.tsx, the same board content embedded as one of the patch detail screen's tabs. - Gallery / photo lightbox — patch photo uploads and viewing; the shared
report/block sheet is wired into
mobile/src/components/lightbox/LightboxChrome.tsxso it appears on photo attachments from posts, comments, and the gallery. - Blocked accounts —
mobile/app/(drawer)/blocked-accounts.tsx, deep linkscout://blocked-accounts, reached from Settings. Lists everyone the user blocked with an unblock action that confirms first. Split intoBlockedAccountsScreenViewModelImpl(every hook) and a pureBlockedAccountsScreenLayout, the house view-model pattern, with its five branches decided by one exported pure helper,buildBlockedAccountsData('loading' | 'error' | 'signed-out' | 'empty' | 'list'). Every branch is in the DEV Screen mocks gallery —mobile/src/dev/mocks/blocked-accounts.tsx,scout://dev-screen-mock/blocked-accounts— because reaching any of them for real means blocking real people. - Admin moderation queue —
backend/admin-ui/src/pages/ModerationPage.tsx, admin-app-only (out of scope for detail here beyond confirming it exists and what it does).
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").
-
Post(schema.prisma:315) —patchIdnullable (null = general board),title(VarChar 200),body(VarChar 4000),images: String[],imageCoords: Json?(positionally parallel toimages, write-once),upvoteCount(denormalized cache, recomputed fromPostVoterows on every vote/unvote),mentions: String[],isPinned,isOfficial,isAutoGenerated, andtriage(nullable —bug/idea/handled/noise, the vocabulary inbackend/src/common/triage.ts; null means untriaged).posts_official_per_patch_boardreplaced the oldposts_official_per_boardindex. The original allowed exactly one official post per board including the general one, which made a second announcement impossible to insert alongside "Welcome to Scout". The replacement keeps the guarantee where it has teeth — one Visitor's Guide per PATCH board, whichseedVisitorGuide's find-then-create relies on — and lifts it for the general board. -
PostVote(:342) — unique on(postId, userId). -
PostComment(:355) — self-referential one level deep (parentId/replies),textVarChar 500, ownimages/imageCoords, andmentions: String[]— user IDS of people tagged in the text, never the parsed@namestring. Populated only by the composer's mention picker. Also carries the same nullabletriagetag asPost— see "Triage" below. -
PostCommentLike(:378) — unique on(commentId, userId). -
BoardFavorite— deleted. Replaced byUserFavorite/user_favorites, which covers patches, collections and campaigns; existing rows were copied forward as patch favorites. See favorites. -
PatchPhoto(:405) —url,blurhash,sourceHash(SHA-256 of the original device asset bytes, client-computed, nullable; unique on(patchId, userId, sourceHash)so re-publishing the same camera-roll photo is a no-op, scoped per-uploader so two people photographing the same scene isn't flagged as a duplicate). -
PatchRecommendation(:275) — unique on(patchId, userId); the "vouch" record. No comment/reason field — it is a bare upvote of the place. -
ContentReport(:560) —targetType(string, not an enum/relation — targets span five unrelated tables),targetId,reporterId,authorId(denormalized at report time so the report survives the content being deleted),reason,details(VarChar 1000),status(pending/actioned/dismissed),resolution(content_removed/author_suspended/no_action). Unique on(targetType, targetId, reporterId). -
UserBlock(:595) —blockerId,blockedId, unique on the pair, indexed both directions. -
Profile.suspendedAt(:636) — nullable timestamp; presence means ejected from community surfaces. A suspension is reversible (no UI to reverse it was found — see Open questions) and does not touch the account's own collection, sign-in, or purchases.
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
backend/src/community-posts/community-posts.service.ts— all post/comment/ board-summary/official-content logic. Favorites moved out tobackend/src/favorites/.backend/src/community-posts/official-content.ts— the fixed welcome post copy and thebuildVisitorGuidetemplate (patch metadata → plain-text guide, capped at 4000 chars).backend/src/moderation/moderation.service.ts—hiddenAuthorIds(the one method the rest of the app depends on),assertNotBlocked,assertNotSuspended, report/resolve logic.backend/src/moderation/text-filter.ts— the pre-publication filter (see Configuration below).backend/src/moderation/report-reasons.ts— shared vocabulary; mobile carries a mirrored copy atmobile/src/domain/moderation-types.tsthat must agree by hand (no shared package).backend/src/patch-photos/patch-photos.service.ts— gallery upload/list/ delete, sourceHash dedupe.backend/src/patch-recommendations/patch-recommendations.service.ts— the vouch add/remove/status.backend/src/admin/api/community-reports-admin.service.ts+community-reports-admin.controller.ts— the triage feed and tag writer. Insrc/admin/api/rather thancommunity-posts/becauseAdminModulealready importsCommunityPostsModule; the reverse import would be a DI cycle.backend/scripts/seed-official-posts.ts— idempotent backfill for the welcome post + per-patch Visitor's Guides; run on every backend deploy (.github/workflows/deploy.yml:120, non-fatal on failure) and also triggered per-patch on patch creation (backend/src/admin/api/patches-api.controller.ts:415).
Mobile
mobile/src/components/moderation/ContentActionsProvider.tsx— the single report/block bottom sheet mounted once at the app root; every content surface callsuseContentActions().showContentActions(...).mobile/src/components/moderation/contentActionsContext.ts— the context/ hook, deliberately import-light so render-free unit tests don't need to mockModal/Alert/theme.mobile/src/hooks/usePost.ts,usePosts.ts,useRecommendations.ts,useBoardFavorites.ts,useCommunitySearch.ts.mobile/src/screens/PostScreen.tsx— the post detail screen, split intoPostScreenViewModelImpl(every hook) and the purePostScreenLayout, with the derived state in the exported purebuildPostScreenData(the three-wayPostScreenStatus, the threading, the per-comment delete rights, the board label, the @-mention roster).mobile/src/dev/mocks/post.tsxrenders that layout in the gallery — see screen mocks.mobile/src/screens/PatchCommunityScreen.tsx— one patch's board, split intoPatchCommunityScreenViewModelImpl(every hook) and the purePatchCommunityScreenLayout, with the derived state in the exported purebuildPatchCommunityScreenData(the four-wayPatchCommunityBoardStatusand the singular/plural count line).mobile/src/dev/mocks/patch-community.tsxrenders that layout in the gallery — see screen mocks.mobile/app/(drawer)/community.tsx— the general-board hub, split intoCommunityScreenViewModelImpl(every hook) and the pureCommunityScreenLayout, with the derived state in the exported purebuildCommunityScreenData(the five-wayCommunityFeedStatus, plusCommunitySearchResults.noMatches, which is the one condition the search half's empty copy renders under).mobile/src/dev/mocks/community.tsxrenders that layout in the gallery — see screen mocks.mobile/src/components/community/PostComposerScreen.tsx— the new-post composer both routes render, split intoPostComposerScreenViewModelImpl(every hook; it takes the two routes' props as arguments) and the purePostComposerScreenLayout, with the Post gate in the exported purepostComposerFormState. It stays insrc/components/community/rather than moving tosrc/screens/because it has TWO consumers, andcontextKindis a view-model field so one gallery entry renders both the general-board and the patch-board face.mobile/src/dev/mocks/post-composer.tsxrenders that layout in the gallery — see screen mocks.mobile/src/components/community/commentThread.ts— one-level thread grouping, orphan-promotion logic.mobile/src/components/community/PostCard.tsx,CommentCard.tsx,VisitedBadge.tsx,RecommendBar.tsx.mobile/src/api/moderation.ts,mobile/src/domain/moderation-types.ts.
Admin
backend/admin-ui/src/pages/ModerationPage.tsx— the queue UI (filter by status, view reported content/attachments, remove / remove+suspend / dismiss).
Configuration and flags
No feature flags (see Status). Two environment-shaped configuration points:
OFFICIAL_AUTHOR_PROFILE_ID(optional env var) — the profile id official posts (welcome, Visitor's Guides) are authored under. Falls back to the firstProfilewithisAdmin: true; throws if none exists.- The text filter (
text-filter.ts) has no runtime configuration — the term lists are hardcoded constants, changed only by editing and redeploying the file.
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
- A failed community SEARCH is indistinguishable from no results.
useCommunitySearchcatches a rejectedpostsApi.searchPostsand sets an empty array (mobile/src/hooks/useCommunitySearch.ts), and it exposes noerrorat all — so the hub renders "No boards or posts match “…”" whether nothing matched or the request failed. The board half is unaffected: it comes from the locally-synced catalog and never touches the network. - A failed FEED refetch over posts you already have is silent. The hub's
CommunityFeedStatus(mobile/app/(drawer)/community.tsx) puts anyposts.length > 0inready, so a failure is only ever visible on a board with nothing in it. This is deliberate — a stale feed beats an error page over content that is already on screen — but it means "the board is out of date" has no indicator. - The hub's
refetchingframe is unreachable.ListEmptyComponenthas a spinner branch forloading && error, andusePostsreportsloadingas TanStack'sisPending, which is already false once the query has an error. The branch is dead code, kept rather than deleted. - Suspension does not block voting or liking (nor favoriting, which now
lives in favorites) —
assertNotSuspendedis called fromcreatePost,createComment, and photouploadonly. A suspended account can still upvote posts, like comments, recommend patches, and save favorites. - Usernames are the email local-part, not a chosen handle.
displayNameFor(backend/src/patch-photos/username.ts) rendersalansax@gmail.comasalansaxfor any signed-in member; only guest profiles (no email) fall back to a generatedanon-<animal>-<n>handle, and only when even that is absent does it showGuest. There is no UI or API to set a custom display name for a real account (searched forupdateDisplayName/changeDisplayName/setDisplayNameacross both codebases — none found). This means every post/comment/photo byline for a signed-in member leaks their email's local part. - Reporting your own content, or reporting/blocking while blocked, is
rejected — self-reports throw
BadRequestException; a report against content whose author has a block relationship with the reporter throwsNotFoundException(not a "you're blocked" message — see next point). - A block never announces itself. Interacting across a block — fetching a
blocked author's post by id, reporting their content, replying, voting,
liking — always returns/throws as if the target does not exist (404 /
"Post not found" / "Comment not found"), never a message that reveals a
block relationship exists. This is deliberate
(
moderation.service.ts:88-90comment: "telling someone they've been blocked is an invitation to make a second account"). - A FAILED read of the block list is a distinct frame, deliberately. On
the Blocked accounts screen
'error'outranks'empty'(buildBlockedAccountsData), so a failedGET /api/moderation/blocksshows "Couldn't load your blocked accounts… your blocks are still in effect" and a Try again, never "You haven't blocked anyone." The screen used to set an empty list on failure, which rendered a network error byte-for-byte identically to the real empty state — on a Guideline 1.2 compliance surface that told someone being harassed their block had not taken. The status enum is what keeps the two apart. - A FAILED unblock has no on-screen state at all. The catch in
confirmUnblockreports to Sentry and raises a nativeAlert("Couldn't unblock"), then clears the busy flag — so the screen returns to a roster identical to the one before the tap, and the row that failed to unblock is not marked in any way. The confirmation prompt is likewise an OSAlert. Neither is a frame the screen draws, which is why neither appears in the screen-mock gallery. - A patch board whose PATCH did not resolve renders no name and says so
nowhere.
PatchCommunityScreenViewModelImplreads onlypatchoffusePatch(patchId)and drops that hook'sisLoadinganderror, so a patch still syncing, anadminOnlypatch reached by deep link, and a lookup that outright failed all produce the same frame: the header reads "Community", the kicker is absent, and the feed below is unaffected (the posts come from a different request that does not need the patch row). The same collapse PostScreen documents for its board label. Not a data-loss case — the feed, the bookmark and the composer all work off the route id alone — but there is no state that says the name is missing rather than merely late. - The board feed's "loading over an empty list" branch is unreachable.
The route file this screen came from rendered an
ActivityIndicatorinListEmptyComponentforloading && !posts.length && error.usePosts.loadingis TanStack'sisPending, which is false whenever the query is in itserrorstate, soloadinganderrorare never both set. A retry after a failed board read therefore re-renders the failure card, not a spinner.PatchCommunityBoardStatushas four values because the screen reaches four states. - Blocking is symmetric by construction, not by two rows. One
UserBlock(blockerId, blockedId)row makes both directions invisible to each other; there is no separate "mute" or one-directional hide. - Pin is effectively unreachable from any UI.
POST /posts/:postId/pinrequiresisAdminand togglesPost.isPinned;usePost.tsexposestogglePin, but no screen (mobile or admin) renders a control that calls it. The only pinned posts in production are the auto-generated welcome post and Visitor's Guides, which are created withisPinned: truedirectly by the seed service — never through this endpoint. - Vouch count visibility is board-only, not Overview-tab. See "User-facing surfaces" above — the Overview tab shows only a countless toggle heart in the hero chrome; the numeric vouch count lives on the Community board screen.
- A hand-edited official post stops the backfill from touching it.
Editing an auto-generated Visitor's Guide as admin clears
isAutoGenerated; the seed script's--refreshmode only rewrites guides still flagged auto-generated. - Non-place patches never get a Visitor's Guide.
seedVisitorGuideskips any patch with nocityand nostate(e.g. the Animals collection) — "parking, best time to go" text would be nonsensical for it. - UGC is excluded from the content-publish pipeline. Posts, comments,
votes, reports, blocks, and photos are per-environment runtime data, not
authored/published content — they are absent from
backend/src/content-publish/content-entities.tsand never move through the local→prod publish flow that patches/collections use.
What this feature does NOT do
- It is not anonymous. A signed-in member's posts and comments are attributed to their email's local-part by default, with no way to change it. (See Edge cases.)
- It does not rate-limit posting, commenting, voting, or reporting. There is no throttle on any of these endpoints beyond requiring authentication and (for posting/commenting/uploading) not being suspended. A determined bad actor with one account can post, comment, or file reports as fast as the network allows.
- The text filter does not catch general profanity, harassment, spam, or
misinformation. By explicit design (
text-filter.tsheader comment) it matches only a fixed list of slurs and explicit sexual-solicitation terms, whole-word, with leetspeak/spacing evasion handling. It will not stop "you are an idiot," a spam link, or a lie about a location's hours — those rely entirely on the report → moderator-review → removal/suspension path, which is manual and not guaranteed within any particular time window by the code itself (the admin UI's copy claims a 24-hour turnaround commitment, but that is a stated policy, not an enforced SLA in code). - There is no automated moderation — no ML classifier, no image-content
scanning of uploaded photos, no third-party moderation API integration.
Every
content_removed/author_suspendedresolution is a human admin clicking a button inModerationPage.tsx. - A "suspended" author is not deleted, banned from sign-in, or stripped of
their collection.
Profile.suspendedAtonly blocks posting, commenting, and photo upload (see Edge cases); the account still authenticates, keeps its collected patches, and can still buy from the store. - Suspension has no found reversal path. No UI or endpoint clears
suspendedAtback to null was found in either app (see Open questions) — as implemented, it functions as permanent unless someone runs a manual database update. - There is no way to appeal a moderation decision, edit a resolved report, or view moderation history as the affected user. The moderated user is never notified in-app that their content was removed or that they were suspended; they discover it the next time they try to post and receive the suspension error message. (An admin CAN now send them a direct message — see messages-and-notifications — but nothing in the moderation flow does so automatically.)
- Triage is not moderation, and has no admin UI. Tagging a post or comment
noise/handledrecords that an operator read it; the content stays live on the board and its author is untouched. Nothing rendersPost.triageorPostComment.triagein the admin app — the column is written only bynpm run triage:applyand read only bynpm run triage:collect. - Comment threads do not nest beyond one level. A reply to a reply is
rejected by the API (
BadRequestException('Cannot reply to a reply')). @-mentions reach only people already in the thread. The composer's picker offers the post author and existing commenters and nobody else; there is no global handle search. Typing@someoneby hand notifies nobody — only choosing a person from the picker records the id that drives a notification.- There is no photo-specific report/delete-by-id API beyond the gallery
list and lightbox action sheet — reporting a photo goes through the
generic
patch_phototarget type in the moderation reports endpoint, not a dedicated/photos/:id/reportroute. - The moderation queue's "content" preview is best-effort, not guaranteed.
If content was already deleted (by its author or an earlier resolution)
before a moderator opens the report,
contentrendersnulland the admin UI shows "This content is no longer available."
Tests that cover it
Backend (Jest)
backend/src/community-posts/community-posts.service.spec.ts— post CRUD, voting/recount, pin-requires-admin, sort order, official-post identity rendering, image-coordinate validation, comment reply-depth rejection, a dedicateddescribe('block enforcement')block covering feed exclusion, search exclusion, direct-fetch 404 on a blocked author's post, comment exclusion + orphan-reply handling, block-aware comment counts, and blocked vote/comment/like rejection; adescribe('content filter is wired into every write path')block asserting the filter is checked on create/update for both posts and comments; extensivecollectionBoard/patchBoardcoverage (empty states, ranking rules, block exclusion).backend/src/community-posts/official-content.spec.ts— welcome post and Visitor's Guide template content.backend/src/admin/api/community-reports-admin.service.spec.ts— the triage feed's default untriaged bucket, the official/auto-generated exclusion on posts and its deliberate absence on comments, board-name and author rendering, the Guest fallback, reply flagging, single-lookup author resolution, truncation on either list, andNotFoundon tagging a post or comment deleted between collect and apply.backend/src/moderation/moderation.service.spec.ts—hiddenAuthorIdssymmetry (blocked-by-me, blocking-me, mutual collapse), block idempotency/self-block rejection,assertNotBlocked's not-found-not-reveal behavior, report validation/self-report rejection/author denormalization/ re-report-reopens-dismissed,assertNotSuspendedenforcement,resolveReport(deletion, sibling-report closure, already-gone handling, transactional atomicity, block-oracle prevention, suspension + removal, dismissal without content change).backend/src/moderation/text-filter.spec.ts— ordinary posts pass; exact/ stem/plural slur matching; leetspeak folding; punctuation-in-token collapsing; letter-run squeezing (with an explicit "ordinary doubled letters are not squeezed" case); spaced-out letter evasion (with a false-positive-prevention case: only consecutive single-letter runs join); explicit-solicitation terms; returns the matched term, not user text.backend/src/patch-photos/patch-photos.service.spec.ts,backend/src/patch-photos/username.spec.ts.
Mobile (Jest screen tests, mobile/screen-tests/)
community.test.tsx— general feed render, empty state, single-fetch-per- visit.patch-community-id.test.tsx— board header/feed render, empty state. The screen's seven gallery states (empty board · read failed · first load · patch never resolved · one post · a busy board · a 61-character patch name) are additionally mounted and content-asserted byscreen-mocks.test.tsx's rot guard.post-id.test.tsx— general-board post with no comments, patch-board post with a real comment, 404/unavailable state.blocked-accounts.test.tsx— blocked row render for signed-in user, sign-in prompt instead of a fetch when signed out. The screen's eight gallery states (nobody blocked · one · a long roster · unblock in flight · long handle · loading · read failed · signed out) are additionally mounted and content-asserted byscreen-mocks.test.tsx's rot guard.community-new.test.tsx,post-create-id.test.tsx— composer context chip rendering (the general board's hardcoded label; the patch board's name resolved throughusePatch, with a paired test for the patch that never resolved). The composer's ten gallery states (empty general form · a whitespace-only title · the one-character minimum · ready · both fields at the character ceiling · an upload in flight · one photo · the ten-photo cap · a post in flight · a 63-character board name) are additionally mounted and content-asserted byscreen-mocks.test.tsx's rot guard.mobile/src/components/community/__tests__/commentThread.test.ts,PostCard.test.tsx,VisitedBadge.test.tsx— unit tests for the thread grouper, card rendering, and badge visibility.
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
- Is there any path that clears
Profile.suspendedAt? No admin UI control or endpoint was found. If reversal exists only as a raw SQL update run manually, that should be stated explicitly rather than implied as a normal admin action. - Does the 24-hour response commitment referenced in the admin UI's copy ("We commit to a 24-hour turnaround in our Terms") correspond to an actual Terms of Service clause, and is anything enforcing it operationally (e.g. an alert if the queue goes stale)? Not verified in code — the queue has a pending-count badge but no code-level SLA enforcement was found.
- Is
POST /posts/:postId/pinreachable from anywhere at all (e.g. a direct API call from a support workflow, or truly fully dead)? Confirmed no UI caller exists; did not check for external/manual usage. - Is there any server-side image-content moderation on uploaded photos (patch photos, post/comment attachments) beyond the file-type/size validators? Nothing found, but a dedicated image-moderation service outside these five directories was not exhaustively ruled out.