Bulletin Board — Comments & Quoting
Overview
This feature powers the comment thread beneath a bulletin post: reading comments page by page (oldest first), writing a comment with attached images and a quote of another comment, and deleting one's own comments.
The defining design choice is that quotes are resolved server-side in a single batch rather than letting the client match quoted comments itself. That decision solves a correctness problem and a visibility problem at the same time.
Business Flow
List comments — GET /api/bulletin/:hash/posts/:id/comments
- Authorization runs through
resolveAccess→CanView()→visiblePostForCaller(postID). A post the caller cannot see returns 404, not an empty comment list. - Cursor + limit paging (default 20) works exactly like the main feed. A malformed cursor simply restarts from the beginning of the thread.
ListCommentsreturns published comments plus the caller's ownpendingcomments (a UNION ALL in SQL). Comments inhiddenordeletedstate are visible to nobody.ResolveQuotesruns one query per page.- It collects every
quoteCommentIdon the page and dedupes first — a popular comment is often quoted several times on the same page — then fetches them all with a singleid = ANY(...), avoiding an N+1 pattern. - Why this belongs on the server: the old client matched quotes against comments it already held, so a quote pointing at a comment from an earlier page never rendered, and worse, it rendered 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 vanished
entirely all collapse into one identical state:
{deleted:true, snippet:""}. The client can then say "this comment was deleted" without guessing. - The same
commentVisibleToCallerpredicate used for listing applies here, so a quote can never expose a comment the caller is not allowed to read in the thread. QuoteViewcarries only{id, authorType, isMine, snippet}— no field can leakauthor_line_user_id, preserving pseudonymity. The snippet is truncated at 120 runes.
- It collects every
- The response is an object,
{comments, nextCursor?}, not a bare array: the cursor is opaque, and without it the client cannot reach page two. Comments are ordered ASC, so the cursor is the last row on the page.
Create a comment — POST /api/bulletin/:hash/posts/:id/comments (rate limit 10/60s)
CanComment()gates the request. It differs fromCanWrite()in that a block in either scope prevents commenting.visiblePostForCallerreturns 404 when the caller cannot see the post. Returning 404 here rather thanBULLETIN_COMMENTS_CLOSEDmatters — the latter would confirm the post exists.- A post with comments closed returns 403
BULLETIN_COMMENTS_CLOSED. - A body that is empty after trimming returns 400
BULLETIN_EMPTY_BODY; one longer thanmax_comment_lengthreturnsBULLETIN_BODY_TOO_LONG. - More images than
max_images_per_comment(default 2 — a setting kept separate from the post-side default of 4, so raising the cap on one side does not silently raise the other) returnsBULLETIN_IMAGE_LIMIT. quoteCommentIdmust exist, belong to this post, and be published; otherwise the request fails with 400"invalid quote comment". The lookup is tenant-scoped.- Initial status depends on the
require_comment_approvalsetting:pendingwhen enabled,publishedotherwise. - Everything happens in one transaction: insert the comment → commit images through the comment
committer (key format
bulletin/comment/{id}/{index}.{ext}, which must run after the insert because the key embeds the comment id) →UpdateCommentImages→ write thecomment.createaudit entry. - The response is a
CommentViewwith its quote already resolved, returned with status 201, so the client receives the same shape it gets from listing and needs no special case.
Delete a comment — DELETE /api/bulletin/:hash/comments/:id
- Gated by
CanComment(), notCanWrite(): someone blocked in thecommentscope should be locked out of every comment action, including deleting their own. ownedCommentreturns an identical 404 whether the comment does not exist, belongs to another OA, or belongs to another user.- The comment is soft-deleted, a
comment.deleteaudit entry is written, and the endpoint responds with 204.
Key Files & Functions
| Route | Rate limit | Handler |
|---|---|---|
GET /api/bulletin/:hash/posts/:id/comments | — | (*Handler).ListComments |
POST /api/bulletin/:hash/posts/:id/comments | 10/60s | (*Handler).CreateComment |
DELETE /api/bulletin/:hash/comments/:id | — | (*Handler).DeleteComment |
internal/bulletin/service.go—ListComments,CreateComment,DeleteComment,ResolveQuotes,ownedComment,commentVisibleToCaller,visiblePostForCaller,postVisibleToCaller,categoryVisible,GetPostinternal/bulletin/repository.go—ListComments(the UNION ALL predicate),InsertComment,FindCommentByID,FindCommentsByIDs(batched),SoftDeleteComment,UpdateCommentImagesinternal/bulletin/view.go—CommentView,QuoteView,NewCommentView,NewCommentViews,quoteSnippet,maxQuoteSnippetRunesinternal/bulletin/committer.go— the committer instance forimageKindComment
Connections to Other Services
- Database tables
bulletin.comment,bulletin.post,bulletin.audit_log - Object storage via the comment committer — see bulletin-post-write
- bulletin-access-control supplies
CanCommentand the settings caps GET /api/bulletin/:hash/posts/:id(GetPost) is the companion endpoint sharing the same visibility predicates- Corresponding client-web feature:
bulletin-post-detail