Bulletin Board (Forum)
Overview
An add-on app: a noticeboard/forum running inside LIFF where LINE friends can post threads, reply, react with emoji, and report content, paired with CMS-side moderation tools for approving, hiding posts, and blocking users.
All of its tables live in the bulletin schema, separate from public, with no Prisma
definitions — they're applied with psql. The most recent migration (numbered 005) changed the
app's meaning from a "social feed" into an actual "forum": every thread now requires a
title, and listings sort by latest reply instead of creation date.
Core Data Structure
bulletin.board — the board
One board per LINE OA, enforced with a UNIQUE constraint on line_oa_id.
name,description, and a JSONBsettingscolumn that bundles every setting in one place — e.g. max images per post and posting permissions
bulletin.category — categories
Categories have two completely independent permission axes:
| Axis | Column | Meaning |
|---|---|---|
| Visibility axis | access_mode (default inherit) + audience_ids | Who can see this category |
| Posting axis | post_access (member or admin, default member) | Who can post in this category |
The most common combination is inherit paired with admin, which gives an "Announcements"
category everyone can read but only the CMS can write to. The permission is actually enforced
server-side (client-api replies with 403 and code BULLETIN_CATEGORY_ADMIN_ONLY); the database
CHECK constraint is the last line of defense.
bulletin.post — threads
titleVARCHAR(200) is NOT NULL with intentionally no default — this column was added in migration 005 alongside a cleanup of existing content, because giving it a default would have let title-less threads slip through foreverbodyandimagesare JSONB- Authorship follows an XOR pattern:
author_type(line_useroradmin) paired with eitherauthor_line_user_idorauthor_user_id, but never both, enforced by thechk_post_authorCHECK constraint - Status and placement:
status,allow_comments,is_pinned, andpinned_at - Counters that triggers own exclusively — the application must never write to them:
comment_count,reaction_count, andreport_count last_activity_dateis computed by a trigger from the latest comment, and is used as the forum's sort key- Two keyset indexes exist because two screens sort differently:
(board_id, status, created_date DESC, id DESC)for the CMS, and(board_id, status, last_activity_date DESC, id DESC)for LIFF
Supporting tables
bulletin.comment— comments, using the same XOR authorship pattern as posts, with a self-referencingquote_comment_idfor quoting and a JSONBimagescolumnbulletin.reaction— unique on(target_type, target_id, line_user_id), enforcing one reaction per person per targetbulletin.report— unique on(target_type, target_id, reporter_line_user_id), with astatuscolumn (defaultopen)bulletin.block— user blocks, using a partial unique index(board_id, line_user_id) WHERE revoked_date IS NULL. A note in the code warns that an expired block still occupies this slot, so a new block must revoke the old one within the same transactionbulletin.audit_log— records every moderator action withbefore/aftersnapshots as JSONB
Counter triggers
The triggers that maintain the counters use pg_advisory_xact_lock (lock space 4201 for
threads, 4202 for comments) instead of SELECT ... FOR UPDATE. The migration's comments cite
real measurements: with 30 concurrent inserts and no locking, the counts end up permanently
wrong; with FOR UPDATE, 25 out of 30 attempts deadlocked; with the advisory lock, all 30 came
out correct.
Related Files
apps/bulletin/migrations/001_create_schema.sql— theCREATE SCHEMA bulletinstatementapps/bulletin/migrations/002_create_tables.sql— all 8 core tablesapps/bulletin/migrations/003_create_counter_triggers.sql— the comment/reaction/report counter triggersapps/bulletin/migrations/004_add_category_post_access.sql— the posting permission axisapps/bulletin/migrations/005_forum_semantics.sql— thread titles, comment images, andlast_activity_date, with the reasoning for the data cleanup documented in detail
Connections to Other Services
- client-api-go serves the feed, threads, comments, reactions, and all reporting from LIFF, enforcing both permission axes and the block check
- cms-api-go handles the moderation queue, posting on the admin's behalf, category
management, and blocking — fields owned by triggers are marked read-only in GORM with the
->tag - Uses
audience_idsfrom Audience, references users via the LINE userId from LINE Friends, and is toggled on/off vialine_oa_app - Note: the
bulletinschema doesn't appear inschema-dumps/2026-07-24/schema.sql, since that dump only coverspublicandappointment