Skip to main content

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

  1. Authorization runs through resolveAccessCanView()visiblePostForCaller(postID). A post the caller cannot see returns 404, not an empty comment list.
  2. Cursor + limit paging (default 20) works exactly like the main feed. A malformed cursor simply restarts from the beginning of the thread.
  3. ListComments returns published comments plus the caller's own pending comments (a UNION ALL in SQL). Comments in hidden or deleted state are visible to nobody.
  4. ResolveQuotes runs one query per page.
    • It collects every quoteCommentId on the page and dedupes first — a popular comment is often quoted several times on the same page — then fetches them all with a single id = 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 commentVisibleToCaller predicate used for listing applies here, so a quote can never expose a comment the caller is not allowed to read in the thread.
    • QuoteView carries only {id, authorType, isMine, snippet} — no field can leak author_line_user_id, preserving pseudonymity. The snippet is truncated at 120 runes.
  5. 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)

  1. CanComment() gates the request. It differs from CanWrite() in that a block in either scope prevents commenting.
  2. visiblePostForCaller returns 404 when the caller cannot see the post. Returning 404 here rather than BULLETIN_COMMENTS_CLOSED matters — the latter would confirm the post exists.
  3. A post with comments closed returns 403 BULLETIN_COMMENTS_CLOSED.
  4. A body that is empty after trimming returns 400 BULLETIN_EMPTY_BODY; one longer than max_comment_length returns BULLETIN_BODY_TOO_LONG.
  5. 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) returns BULLETIN_IMAGE_LIMIT.
  6. quoteCommentId must exist, belong to this post, and be published; otherwise the request fails with 400 "invalid quote comment". The lookup is tenant-scoped.
  7. Initial status depends on the require_comment_approval setting: pending when enabled, published otherwise.
  8. 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 the comment.create audit entry.
  9. The response is a CommentView with 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(), not CanWrite(): someone blocked in the comment scope should be locked out of every comment action, including deleting their own.
  • ownedComment returns 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.delete audit entry is written, and the endpoint responds with 204.

Key Files & Functions

RouteRate limitHandler
GET /api/bulletin/:hash/posts/:id/comments(*Handler).ListComments
POST /api/bulletin/:hash/posts/:id/comments10/60s(*Handler).CreateComment
DELETE /api/bulletin/:hash/comments/:id(*Handler).DeleteComment
  • internal/bulletin/service.goListComments, CreateComment, DeleteComment, ResolveQuotes, ownedComment, commentVisibleToCaller, visiblePostForCaller, postVisibleToCaller, categoryVisible, GetPost
  • internal/bulletin/repository.goListComments (the UNION ALL predicate), InsertComment, FindCommentByID, FindCommentsByIDs (batched), SoftDeleteComment, UpdateCommentImages
  • internal/bulletin/view.goCommentView, QuoteView, NewCommentView, NewCommentViews, quoteSnippet, maxQuoteSnippetRunes
  • internal/bulletin/committer.go — the committer instance for imageKindComment

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 CanComment and 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