Bulletin Board — Feed & Posting
Overview
The bulletin board is designed to feel like a physical noticeboard in a staff room. Members of an OA can read and post announcements, organized by category, with pinned notices, emoji reactions, and comments. It suits internal team communication, shop announcements, or any community that wants a lightweight discussion space.
Its defining design choice is that member posts carry no author name (pseudonymity). The API returns only the author type (LINE user or admin) and a flag indicating whether the post belongs to the caller. The displayed label is therefore derived on the client: your own posts read as "You", admin posts show the OA name, and everything else reads as "Member".
Business Flow
- The user opens
/{hash}/bulletin. - Authentication runs as a chain: resolve the hash to a LIFF ID, sign in through LIFF, then assemble the API request headers (both ID token and access token; if no ID token is available, the access token is used in its place).
- Two datasets load in parallel:
- Board data — settings, categories, and the OA name and picture.
- Post list — cursor pagination, 20 items per page. The limit must fall between 1 and 50; otherwise the server silently resets it to 20 and returns no next cursor at all.
- The browser title is set from the OA name, and the theme's color tokens are installed across the page.
- The board renders: the category selector bar, the compose box, the pinned notices (returned only on the first page and only when no category filter is active), then the regular post list and a load-more button.
- Tapping a post navigates to its detail page (
/{hash}/bulletin/{postId}). - Posting an announcement: fill in a title (required, up to 200 characters) and body, attach images up to the limit set in board settings, and pick a category — only categories that allow member posting are offered. On success the feed refetches.
- Character counting uses code points rather than JavaScript string length, so it matches the rune count used on the Go side. This matters greatly for Thai text and emoji.
- When the board fails to load, the errors are distinguished so the message is
meaningful:
- The platform admin has disabled the feature — show that the feature is off.
- The user is not allowed to view the board — show a standalone screen without even the board header.
- The session has expired — prompt the user to sign in again.
Key Screens & Components
The board is assembled from clearly separated pieces (all under
src/app/[hash]/bulletin/):
- Board header (
BoardHeader) — the category chip bar. It lists every category, including admin-only ones, so users can still filter and read them. - Compose sheet (
ComposeSheet) — the form for title, body, images, and category. - Post card (
PostSheet) — shared with the detail page via a switchable layout. - Supporting pieces — the image grid (
ImageGrid), the anonymous author badge (AuthorMark), the stamp mark (Stamp), the reaction bar (ReactionBar), and the empty/error status screen (EmptyBoard). - Theme wrapper (
BoardRoot) — installs the theme's CSS variables.
Four theme presets are provided (staff-room, paper, slate, brand), each with
light and dark variants and contrast values that pass WCAG. Fonts are loaded through
next/font: Noto Sans Thai for general text and IBM Plex Mono for numerals only.
On the data side, responsibilities are split across dedicated hooks
(useBulletinAuth, useBulletinBoard, useBulletinFeed, useColorScheme), and all
API calls are centralized in src/service/bulletin.service.ts.
Endpoints used on this page
| Method | Path |
|---|---|
| GET | /bulletin/{hash} |
| GET | /bulletin/{hash}/posts?category=&cursor=&limit= |
| POST | /bulletin/{hash}/posts |
| PUT | /bulletin/{hash}/reactions |
Dependencies
- TanStack Query — an infinite query drives cursor pagination. The next cursor must be checked for a non-empty string before loading more, because the API omits the field when there is nothing left.
- File upload — used for image attachments on posts (see file-upload).
- Detail page and permission layer follow directly from this page (see bulletin-post-detail and bulletin-moderation).
- The board's authentication hook is reused by both the customer and staff sides of the loyalty card feature, since the authentication chain is identical (see loyalty-card and loyalty-staff).
- A test suite covers the compose sheet, post card, reaction bar, per-category posting permissions, and error normalization.
Backend Details (Client API)
First gate — the per-organization app toggle middleware
The whole /bulletin/:hash route group is wrapped in middleware that checks whether this
organization has the bulletin app enabled (read from the line_oa_app table). The CMS
already enforced this rule by hiding menus, but the public LIFF links had no check at
all, so a disabled app remained fully usable from the customer side. This middleware is
the layer client-web cannot bypass.
- A row that explicitly says disabled → 403 with message
APP_DISABLED(fail closed). That string is what the web app translates into the "this feature is not enabled yet" screen, so it must not change on one side only. - A missing
line_oa_approw reads as "disabled" (viaCOALESCEto false) rather than dropping the OA from the result set. - But if the lookup itself fails, or the hash matches no OA at all, it fails open and lets the handler decide (which answers 404) — a temporarily stumbling governance lookup should never lock customers out of an app that is genuinely enabled.
The permission ladder — resolveAccess and why the order matters
Every request in the bulletin domain, read or write, passes through resolveAccess
exactly once per request, and the resulting access context is handed downward. No
handler recomputes permissions. The five-step order is itself part of the security model,
because everything with a side effect must happen after the token is bound to a
channel:
- Resolve the OA and its LINE Login channel from
:hash— not found → 404"board not found". - Verify the LIFF token against that OA's channel — failure → 401. This is channel binding: a valid token only proves some LINE account exists, not that it belongs to this tenant.
- Create the board row for the OA if it does not exist yet.
- Look up
line_user— a miss does not prove there is no row, because the query filters on status and deleted_date. So a conflict-safe guest insert runs, which reports back what is actually in the table: a row that exists but is disabled or deleted is a deliberate rejection → 403BULLETIN_USER_INACTIVE, not a re-admission as a guest. - Load the still-active blocks and assemble the access context (user, user type, OA, board, settings, audiences, block).
Board settings and defaults
settings is a jsonb blob unmarshalled on top of the defaults, so keys absent from
the blob keep their default value and malformed JSON degrades to the full default set
rather than becoming a board that rejects everyone. One subtlety: an empty array in
view_access/write_access is a legitimate deny-all, whereas null means "not set" and
falls back to the default.
The defaults the backend uses: view_access is member and guest, write_access is
member, post and comment approval are off, 4 images per post, 2 images per comment, body
length 2000, comment length 500, comments allowed by default, user reporting enabled, and
a set of six reaction emoji.
GET /api/bulletin/:hash — board data
- Must pass
CanView()first (403BULLETIN_VIEW_FORBIDDENotherwise). - The category list is filtered a second time on the way out using the category visibility rules, because the SQL that fetches categories filters only on board/status/deleted — it knows nothing about access mode. Without this layer, guests and members outside the audience could read the names and audience ids of restricted categories. A category name can itself be confidential (think "layoff discussion"), so the whole entry has to be dropped.
- The OA picture resolves to a public URL and is an empty string when there is no image, not a half-formed URL pointing at the bucket root, so the web side can fall back on a plain falsy check.
Category visibility — two independent axes
- Read axis — inherit mode uses the board-level permissions (guests included); restricted mode with no audience means all members; restricted with an audience means members whose audiences overlap. Restricted always implies member-only, because a guest is never in a meaningful audience.
- Write axis — member (the default) means LINE users can post; admin means posts can only come from the CMS. The two axes compose: readable by everyone but writable only by admins gives you an "announcements" category.
- An
audience_idsvalue that cannot be read as an array (a literal null, or elements that are not integers) is treated as deny, not widened to "all members". Only a genuinely absent value means all members. - These rules have a twin implementation in SQL used to filter the feed. The two must agree, and a paired test pins them together.
GET /api/bulletin/:hash/posts — the feed
- An unparseable category gives 400
"invalid category", but a malformed cursor is accepted as "start from the top" rather than a 400. The asymmetry is intentional: the cursor is a value the system handed out, not something a user typed. - Pinned notices are attached only on the first unfiltered page, because that strip is not paginated and attaching it to every page would duplicate it. The cap is 5 posts, a code constant rather than a setting (the CMS pin endpoint rejects anything beyond it).
- Post visibility is encoded directly in the feed SQL: published posts are visible if their category is visible, pending posts only to their owner, and hidden/deleted posts to nobody.
- Cursor — a page that fills the limit returns a next cursor: base64 of the timestamp
and id of the last row, referencing
last_activity_date, which is the column the feed actually orders by. Usingcreated_datewould make page 2 both duplicate and skip rows. The cursor is opaque by design; clients cannot mint one. - The caller's reaction state is decorated in a single batch across both the pinned strip and the feed, so every card boots already knowing what the caller tapped — no per-card query.
- The outgoing projection reads
author_line_user_idonly to compute the "is mine" flag and never copies the value anywhere. This is the mechanism that makes pseudonymity real at the API level rather than just in the UI.
Other notable points
- Bulletin ids are a global serial, so every query must also take the
lineOaID— an id alone never proves ownership. - Every kind of failure (missing / wrong tenant / deleted / in an invisible category) collapses into the same 404, never a 403, which would confirm the thing exists.