Moderation
User-facing safety tools: reporting content for review, blocking another user, and post authors hiding or deleting comments on their own posts. All are reachable from one three-dot action sheet (hooks/useModerationSheet.tsx) on posts, comments, the media viewers and profiles. The sheet shows only what the shared policy (packages/types/src/content-policy.ts) allows the viewer; the API enforces the same rules.
Reporting
| Method | Path | Purpose |
|---|---|---|
POST | /reports | File a report against a post, comment, user, or tip message |
{
"targetType": "post",
"targetId": "clx…",
"reason": "harassment",
"details": "optional free text, max 1000 chars",
"blockUser": false
}A tip message (targetType: "tip") can be reported by whoever saw it: a post tip's recipient, or anyone in a live stream's chat except the sender. The report is against the sender; Report.tipId links it, and the snapshot carries the message as body (so the admin report view shows it) plus coins. The recipient can also hide the message (POST /tips/:id/hide-message); the tip and its coins stand.
reason comes from REPORT_REASONS in @playpals/types — the API's zod schema and the mobile picker are both built from that one list, so they cannot drift. The slug is what gets stored in the Report.reason column, never the display label.
The service resolves reportedId from the target (for a post, its author), rejects self-reports with a 400, and 404s a target that does not exist. A repeat report of the same target by the same reporter while an earlier one is still open returns the existing row with duplicate: true and a 200 — a double-tap on the confirm sheet should not stack rows, and an error toast there is worse than doing nothing. Once a moderator dismisses a report the same user can report again.
Rate limited to 20 reports per hour per user, keyed on the user id. The global limiter (300/min) is no deterrent for report spam, and keying on IP would let one abuser lock out everyone behind the same carrier NAT.
blockUser: true blocks the reported user in the same request — the mobile flow is usually "report and block", and folding it in avoids a half-applied state. A failure there is logged but does not lose the report.
Review surface
Reports are triaged in the admin panel (/reports). A moderator can dismiss a report, remove the content (closes every open report on it), or suspend the reported user (closes all of their open reports). Every decision is written to AdminAuditLog.
Blocking
| Method | Path | Purpose |
|---|---|---|
GET | /user/blocked | List blocked users |
POST | /user/block/:userId | Block |
DELETE | /user/block/:userId | Unblock |
Blocking is symmetric for visibility: if either party blocked the other, neither sees the other's content. getBlockedUserIds(viewerId) returns the union of both directions, and that one set drives every filtered read.
Blocking also, in a transaction: removes follow rows in both directions, and deletes existing notifications between the pair.
It deliberately does not delete posts, comments, reactions, or messages. Those are hidden at read time instead — deleting would decrement public counters on third-party content and could not be undone when the block is lifted. Unblocking restores everything.
What filters
| Surface | Where |
|---|---|
| Home feed | services/feed.ts — getFeed |
| Profile feed | services/feed.ts — getUserFeed, short-circuits to empty |
| Profile | services/user.ts — returns blocked / isBlocked, strips content |
| Post detail | services/post.ts — 404s, so a 403 can't confirm the post exists |
| Comments | services/comment.ts |
| Search (users + clips) | services/search.ts |
| Discover | services/discover.ts |
| Trending | services/trending.ts — filtered in memory, see below |
| Notifications | services/notify.ts — list and delivery |
| DM inbox / send | services/conversation.ts |
| Mentions | services/mention.ts — no Mention row is created at all |
Write paths (follow, DM, comment, reaction) call assertNotBlocked, which throws a 403 with a direction-agnostic message — telling the blocked party "they blocked you" hands them something to act on.
Two things to know before changing this
Trending caches are global, not per viewer. trending:clips, trending:playpalz, and trending:coaches are one shared payload, so a SQL predicate cannot filter them. They over-fetch 40, filter against the viewer's blocked set in memory, then trim to 20 — otherwise blocking one trending creator would leave a short list.
The feed cache key does not encode block state. blockUser and unblockUser invalidate both users' feed caches. Without that, a blocked user's posts stay visible for the rest of the 60-second TTL.
Comment counts
Every comment count goes through countedCommentWhere (@playpals/db): not deleted, not hidden by the post's author, and not inside a hidden thread. On top of that, getBlockHiddenCommentCounts returns the number of counted comments authored by blocked users (or under a blocked author's comment) per post, and the feed and trending transforms subtract it. ("Block-hidden" is unrelated to a comment the post's author hid.)
That is one grouped query per page, not a per-post recount, and it is skipped entirely when the viewer has blocked nobody — so the common path pays nothing.
Post authors moderating comments
On their own post, the author can hide or delete anyone's comment, but never edit it.
- Hide is reversible: the comment (and, for a top-level comment, its thread) moves under "Hidden comments" at the end of the thread, which anyone can open. The commenter is not notified and still sees their own comment as normal. The app confirms with an Undo toast.
- Delete is permanent and asks first. Replies by other people survive under a "This comment was deleted" placeholder.
Both are recorded in ContentAuditLog. Report and Block sit in the same comment menu. See Content Lifecycle for the full rules.
