Bulletin Board — Post Detail & Comments
Overview
The detail page shows a single announcement in full, with a discussion section below where users can comment. Comments support quoting another comment, attaching images, reacting, and deleting your own contributions.
One difference from the feed is worth knowing: comments are ordered oldest to newest, as conversation naturally flows, while the announcement feed runs newest first. Both use the same cursor format, so "load more" appends to the existing list in either case.
Business Flow
- The user opens
/{hash}/bulletin/{postId}. The page first validates that the post ID is a positive integer; if not, it renders a not-found response immediately. - Authentication runs the same way as on the feed, then three datasets load in parallel: board settings and categories, the post itself, and the comment list (cursor pagination, 20 per page).
- The post renders in full: the title as a heading, then the author line, the complete body, any attached images, and the reaction bar.
- Each comment in the list shows:
- The anonymous author badge.
- If it quotes another comment, a snippet of the source comment (up to 120 characters). If that source has been deleted or hidden, every case renders the same "deleted" state so removed content cannot leak through a quote.
- Tapping the quote scrolls to the source comment when it is present on the page.
- A quote button, and a delete button shown only on your own comments.
- The comment composer appears when the post accepts comments and the user is not
blocked.
- Length is capped by the board setting.
- The image limit for comments is a separate value from the one for posts (its default is 2).
- On success, both the comment list and the post are refetched so the comment count stays accurate.
- If the post has comments turned off, an explanatory message replaces the composer.
- Deleting a comment calls an endpoint keyed by comment ID directly, not nested under the post path.
- Error handling: a 404 covers every case where the post is simply not visible to this user — deleted, hidden, not theirs, or in a category they cannot access. A 401 means the session has expired.
- A back-to-board button is provided in-page so users need not rely on the browser's back control.
Key Screens & Components
- Detail page (
src/app/[hash]/bulletin/[postId]/page.tsx) — the entry point and post-ID validation. - Page container (
post-detail.container.tsx) — coordinates the three data loads, manages quote state, and handles comment deletion. - Post card (
PostSheet) — the same component the feed uses, switched into detail mode, which renders the full body and promotes the title to the page heading. - Comment list (
CommentList) — includes quote rendering and per-comment actions. - Comment composer (
CommentComposer) — with a length counter and image attachment. - Shared pieces — the image grid, author badge, reaction bar, and status screens are all shared with the feed page.
On the data side, useBulletinPost and useBulletinComments call through the same
service used by the feed.
Endpoints used on this page
| Method | Path |
|---|---|
| GET | /bulletin/{hash}/posts/{id} |
| GET | /bulletin/{hash}/posts/{id}/comments?cursor=&limit= |
| POST | /bulletin/{hash}/posts/{id}/comments |
| DELETE | /bulletin/{hash}/comments/{commentId} |
The comment deletion route sits directly under /comments, not under /posts,
because the ID it takes is a comment ID rather than a post ID.
Dependencies
- Shares the authentication, theming, and error-normalization layers with bulletin-board.
- The report button and post-owner menu belong to the permissions and moderation layer (see bulletin-moderation).
- File upload — used for image attachments on comments (see file-upload).
- A test suite covers the comment list, the composer, and comment quoting.
Backend Details (Client API)
Reading the comment thread
GET /api/bulletin/:hash/posts/:id/comments
- Runs
resolveAccess→CanView()→ and then always checks the post's visibility first. A post the caller cannot see answers 404, not an empty comment list — an empty list would hint that the post exists. - Cursor and limit behave as on the feed (default 20), and a malformed cursor is accepted as "start from the beginning" rather than a 400.
- The SQL returns published comments plus the caller's own
pendingcomments (a UNION ALL); hidden and deleted comments are visible to nobody. So on a board that requires approval, users still see their own comments awaiting review. - Comments are ordered ASC, so the cursor is the last row on the page, and the response
is an object carrying
nextCursorrather than a bare array — the cursor is opaque, and without it the client cannot reach page 2.
Quote resolution — server-side and batched
This is the endpoint's defining design decision: the snippet of a quoted comment is resolved on the server with one query per page, never matched up by the client.
- The quote ids on the page are collected and deduplicated (a popular comment tends to be quoted several times on one page), then fetched in a single round trip — avoiding an N+1 that would be 20 round trips in a lively thread.
- Why it must be server-side: the previous client matched quotes against comments it already held in memory, so a quote whose target sat on an earlier page rendered nothing — and worse, it could render a stale copy of a comment that had since been deleted.
- Targets that were soft-deleted, hidden by a moderator, belong to another tenant, or have vanished entirely all collapse into the same state: deleted, with an empty snippet. That lets the client say "this comment was deleted" without guessing, and leaks nothing about which case applies.
- Quotes use the same visibility predicate as the listing, so a quote never exposes a comment the caller could not read in the thread itself.
- The outgoing quote projection carries only the id, author type, "is mine" flag, and
snippet — no field can carry
author_line_user_idoutward — and the snippet is cut at 120 runes.
Writing a comment
POST /api/bulletin/:hash/posts/:id/comments — rate limited to 10 per 60 seconds
- Uses
CanComment(), which differs fromCanWrite(): a block in either scope forbids commenting. Someone muted for comments only can still post and react, but not comment. - Checks post visibility → 404 if invisible. It must be a 404, not a disclosure that the post has comments closed.
- A post with comments turned off → 403
BULLETIN_COMMENTS_CLOSED. - A body that is empty after trimming → 400
BULLETIN_EMPTY_BODY(a lower bound the previous system lacked — an empty comment is a blank row in everyone's thread). Overmax_comment_length→BULLETIN_BODY_TOO_LONG. - Images over
max_images_per_comment(default 2, deliberately a separate setting from the post-side default of 4, so widening one side never silently widens the other) →BULLETIN_IMAGE_LIMIT. - A quoted comment must exist, live on this post, and be published; otherwise 400
"invalid quote comment". The lookup is tenant-scoped. - The initial status depends on
require_comment_approval: on →pending, otherwisepublished. - Everything runs in one transaction: insert the comment → commit the images from
temp/to their permanent path (which must come after the insert, because the permanent key contains the comment id) → update the image list → write the audit log. - Returns the comment with its quote already resolved, status 201 — so the client gets the exact shape it gets from the listing, with no special case.
Deleting a comment
DELETE /api/bulletin/:hash/comments/:id
- Gated by
CanComment(), notCanWrite()— someone blocked in the comment scope should be locked out of all comment actions, including deleting their own. - Every failure mode (no such comment / different OA / not theirs) answers the same 404.
- It is a soft delete with an audit entry, never a SQL DELETE, and answers 204.