Skip to main content

Bulletin Board — Board Data & the Post Feed

Overview

Two read endpoints for the board: board-level data (settings, the OA's identity, and the categories the caller is allowed to see) and a keyset-paginated post feed complete with a pinned strip and the caller's reaction state already decorated onto each card.

The board is deliberately pseudonymousPostView carries no author name or picture at all. The only identity returned is the OA's, used to render CMS-authored posts as coming "from the page". Since that value is identical for every post, it is sent once alongside the board data rather than repeated on every card.

Business Flow

GET /api/bulletin/:hash

  1. Call resolveAccess, then check CanView() — failure returns 403.
  2. Load the board's categories and filter them through CanSeeCategory on the way out. findCategoriesSQL filters only on board, status, and deleted_date; it knows nothing about access_mode. Without this filter, guests and members outside the audience could read the names and audience ids of restricted categories.
  3. Return {boardId, settings, oaName, oaPictureUrl, categories}. oaPictureUrl resolves line_oa.cover to a public URL and is an empty string when absent — never a half-formed URL — so clients can fall back on a plain falsy check.

GET /api/bulletin/:hash/posts

Query parameters: category (optional int64), cursor (optional, opaque), and limit (default 20, capped at 50 by the repository).

  1. Call resolveAccess, then check CanView().
  2. An unparseable category returns 400 with invalid category. A malformed cursor is deliberately accepted as "start from the top" rather than returning 400.
  3. The pinned strip is attached only to the first unfiltered page — when both categoryID and cursor are absent — because the strip is not paginated and attaching it to every page would duplicate content. It is capped at five posts by the package-level constant maxPinnedPosts, which is not a setting; the CMS pin endpoint already rejects anything beyond that number.
  4. Fetch one page of the feed with FindFeed(boardID, accessCtx, categoryID, cursor, limit). The SQL encodes the visibility rules directly: published posts are visible if the caller can see their category, pending posts are visible only to their owner, and hidden and deleted posts are visible to nobody.
  5. Cursor — a page that fills the limit returns a nextCursor, the base64 encoding of "unixMilli:id" for the last row, keyed on last_activity_date because that is the column the feed actually orders by. Using created_date instead would cause page two to both duplicate and skip rows. The cursor is opaque by design: a client that was not handed one cannot fabricate it.
  6. Decorate reactions in a single batch covering both the pinned strip and the feed, via ReactionStates(targetType, ids, lineOaID, lineUserID) followed by DecoratePostViews. Every card therefore arrives already knowing the caller's own reaction, with no per-card query.
  7. Return {pinned, posts, nextCursor}, where nextCursor is present only when a further page exists.

PostView and CommentView

Both are projections that read author_line_user_id solely to compute isMine, and never copy that value anywhere else. The images field is decoded from jsonb into an array of keys.

Key Files & Functions

RouteHandler
GET /api/bulletin/:hashinternal/bulletin/handler.go(*Handler).GetBoard
GET /api/bulletin/:hash/posts(*Handler).ListPosts
  • internal/bulletin/repository.goFindPinned, FindFeed, ReactionStates, FindCategories, categoryVisibilitySQL, filterByCategory
  • internal/bulletin/view.goPostView, NewPostView, NewPostViews, DecoratePostViews, CategoryView, visibleCategoryViews, decodeImages, ownedBy, publicURLOf
  • internal/bulletin/entity.goPost, Category, ReactionState, FeedCursor, EncodeCursor, DecodeCursor, maxPinnedPosts
  • Internal response types — boardView, feedView

Connections to Other Services

  • Database — tables bulletin.board, bulletin.category, bulletin.post, bulletin.reaction, and line_oa
  • Object storageinternal/storagex resolves line_oa.cover to a public URL, accepting nil and yielding an empty string
  • Bulletin access control — uses resolveAccess, CanView, and CanSeeCategory
  • App-enabled guard — governs whether the feature group is switched on at the platform level
  • client-web — corresponds to the bulletin-board feature