Apps — Bulletin
Overview
Bulletin is one of the "Apps" — optional add-on modules that run on a completely different permission model from every other menu in the system. A normal menu (Campaign, for example) is gated by a ModuleGate tied to whether a module is switched on for an organization. Apps are gated by an AppEnabledGuard, which reads a single line_oa_app row asking one question: "is this organization allowed to use the app named bulletin?" No row, or a row with enabled = false, and every route in the app returns 403 immediately. And that on/off switch is not something an organization can flip for itself — only a platform admin can (see Access control).
The app's job is a forum-style notice board that LINE OA members read, post to and comment on through the customer-facing web (client-web / LIFF). This CMS side is the full moderation toolkit, across 7 tabs — Posts, Comments, Reports, Categories, Members, Settings, Activity.
Who uses it — an admin or board content moderator. The menu only appears once a platform admin has enabled this app for the organization.
User journey
1. Review notices awaiting approval (the triage queue)
- Open Apps > Bulletin — the Posts / Comments / Reports tabs carry count badges for what's waiting (from
GET /apps/bulletin/pending-count, which returns three separate figures). - Go to the Posts tab and pick status "pending" — the screen switches from a table to a triage view, and also changes how it fetches: one page at
page=1, limit=100instead of paginated. - The left side is the queue; the right shows the full notice rendered in the board's real theme. Keyboard shortcuts work immediately — J / K to move down/up, A to approve, H to hide, X to delete (only delete asks for confirmation; approve and hide are treated as reversible).
- Once an item is handled it leaves the queue, but the selected position does not move — the index is never incremented, so it now points at the next item automatically. You keep working straight down the queue rather than jumping back to the top.
2. Handle comments and user reports
- Switch to the Comments tab — this is a board-wide queue (not per-notice), filterable by status and by post id.
- Switch to the Reports tab, review still-open reports, expand a row to see the actual reported content, then decide: resolved or dismissed.
- Deciding a report does not touch the reported content at all — hiding or deleting that post/comment is a separate action taken on its own tab.
3. Configure the board and its categories
- Go to the Categories tab. Create a category with a view permission (
inheritfrom the board, orrestrictedto specific target groups picked from Segment audience) and a write permission (member/admin). These are two independent axes — setting "admin only" does not hide the category; members still read it. - Go to the Board settings tab. Adjust access (
view_access/write_access, which are sets ofmember/guest, not single values), the moderation rules (require_post_approval/require_comment_approval), the limits (images per post 1–9, body and comment length 1–10,000, at most 8 reaction emojis), and the board's colour theme. - The theme runs a WCAG contrast check before every save — failing it means the save is refused, not merely warned about.
- Copy the board link (web or LIFF) from the share-link card to publish it to members.
4. Block members and audit what the team did
- Go to the Blocked members tab and block an individual — pick the scope (
all, orcommentfor comments only), an internal reason (optional), and an expiry date (leave it blank for permanent). - Lift an existing block. The state column in the table (Active / Expired / Lifted) is derived from the data, not stored as its own field.
- Go to the Activity tab for the full history of every action the team took, filterable by target type (post/comment/report/block/category/board), target id, and action type (22 values).
Key details verified against the source
Confirmed from internal/modules/bulletin/{controller,dto,service_board,service_moderation,service_safety,audit}.go and internal/modules/apps/{guard,service}.go in line-management-cms-api-go:
- The board creates itself on first access, and that creation is already race-proofed — every endpoint calls
ensureBoard, which runsINSERT INTO bulletin.board (line_oa_id, organization_id) ... ON CONFLICT (line_oa_id) DO NOTHINGas raw SQL rather than a GORM Create, deliberately: a zero-valued struct would write Go zeros over the DDL defaults forname/settings/status. It'sON CONFLICTbecause cms-api and client-api can both hit a fresh OA concurrently. The subtler part is that the re-select is scoped by organization_id too — if that LINE OA already has a board under a different organization, the INSERT does nothing and the select misses, so the caller gets a 404 instead of another tenant's board. - The "stamp state" system is a cross-repo contract held in place by a shared test fixture —
src/app/apps/bulletin/lib/stamp.tsis duplicated verbatim intoline-management-client-web, and both repos ship atests/bulletin-stamp-contract.test.tsthat runs the same fixture (tests/fixtures/bulletin-stamp-states.json) against their copy — change the rules in one repo and the other repo's test fails, which is the entire point of the arrangement. The rules themselves have a deliberate precedence: moderation state outranks author class — a hidden official notice stamps HIDDEN, not OFFICIAL, because "is this live?" is the more urgent fact, and reversing it would let a suppressed notice keep wearing the OA's authority. Thepending/hidden/deletedstates are visible only to admins (plus the author themselves forpending); everyone else getsnone— they never learn the notice exists. - The theme's contrast guard judges the RESOLVED tokens, not what you typed — the theme is a layered ladder (
preset < accent/official_accent < tokens) andvalidateTokenContrastruns on the result of collapsing all layers. Judging the raw overrides made the guard fire only when both halves of a pair were present, so{"tokens":{"ink":"#777777"}}on its own sailed through and then rendered at 4.48:1 against the preset's white paper — one field away from a no-op. Exactly three pairs are checked (paper/ink,paper/inkSoft,board/ink);board/inkSoftis excluded on purpose, because no body text is set in the secondary ink directly on the board surface, and including it would reject the product's own defaultstaff-roompreset at 4.12:1 — a guard that rejects its own shipped default is a guard admins learn to route around. The threshold is WCAG 2.1 AA's 4.5:1, and it is enforced twice: the frontend stops before firing the PUT, and the backend re-validates. - The 5-pin cap is enforced on the WRITE, not the read, and locks the board row before counting —
SetPinnedopens a transaction and takesSELECT ... FOR UPDATEon the board row before counting current pins, because two admins pinning at the same moment would both read 4 and both write, leaving 6 pinned posts on a strip that renders 5. Capping only the fetch would let the sixth pin succeed, report success, and then never render — the worst failure mode, because the admin has no way to see it happened. Hitting the cap returns 400 with codeBULLETIN_PIN_LIMIT. - Admin-authored notices skip the approval queue on both create and edit —
newPostRowsetsstatus = 'published'even while the board hasrequire_post_approvalon, because the admin is the approver. AndpostStatusAfterEditkeeps two explicit branches: an admin edit never demotes a post, and only an owner's edit from LIFF pulls a published post back into the queue. Both branches live in one function so the CMS's behaviour is a visible choice rather than the absence of code. - A
restrictedcategory with no groups selected means ALL members, not nobody — it's the easiest rule in the system to get backwards, so the backend returns a pre-resolvedaudienceScopefield so the CMS never has to re-derive it, and the frontend separately refuses to save "restricted + nothing selected" before the API is called. A related detail:audience_idsis compared decoded, never as marshalled bytes, because jsonb does not preserve its input text ('[3,7]'::jsonb::textcomes back as[3, 7]) — a byte comparison reports every non-empty audience as changed, so re-saving an untouched edit form would mint phantomcategory.editrows in the audit log. Also, deleting a category does not cascade to its posts — a post in a deleted category is treated as uncategorised and stays visible under board-global access. - Re-blocking and escalating a block require revoking the incumbent first, because of a unique-index slot —
idx_bul_block_activeis UNIQUE (board_id, line_user_id) WHERE revoked_date IS NULL, which means an expired block still occupies that slot (itsrevoked_dateis still NULL), and acomment-scoped block occupies the same slot anallblock needs. SoCreateBlockrevokes the existing row first and inserts second, inside one transaction — insert-first is already rejected by the index, and revoking in a separate transaction means a crash between the two leaves the user unblocked with no trace. That automatic revoke is recorded as theblock.createaction's "before", not as ablock.revokethe moderator never made. - Closing a report never touches the post — and that's a deadlock constraint, not just a UX decision — the report status transaction already makes the
report_counttrigger take the target's advisory lock (4201 = post, 4202 = comment) from inside that transaction, so mutating a post row in the same transaction would be exactly the opposing lock order that deadlocks. Separately,status = 'open'is repeated in the UPDATE's WHERE clause rather than only checked beforehand, so two moderators clearing the queue at once can't overwrite each other's decision. - The module has 23 routes, and the spec's "24" was judged a miscount —
controller.go's header says so outright: the spec's own table lists 23 rows, independently confirmed by the three services' method inventories (BoardService 7 + ModerationService 10 + SafetyService 6 = 23). The deliberate choice was not to invent a 24th route with no service method behind it. - The reaction limit is tied to a real column width — at most 8 per board, and each at most 16 characters, because
bulletin.reaction.emojiis VARCHAR(16): an emoji the client API could never store back is not a usable choice, so it's refused here. - Search terms are escaped against their own wildcards —
likeEscapeneutralises\,%and_before the LIKE is built. Without it, searching for "50%" matches every notice on the board.
Related menus in this journey
- Content Management — this app has no image upload endpoint of its own. The post composer calls the Content Management module's uploader directly (
CONTENT_PAGE_SERVICE.uploadImage→POST /content-pages/upload-image, multipart with the field namedimage). How many images you may attach comes from the board's ownmax_images_per_post. - Segment audience — the source of the target groups used to restrict a category's view access (
restrictedmode). - Apps (platform admin console) — the only place this app can be switched on or off for an organization; the organization cannot do it itself.
Things to watch out for
- Notices an admin writes go live immediately, even with the board's approve-first mode on — and an admin editing a member's notice doesn't push it back into the queue either.
- The tab badges don't refresh right after you handle something — each page holds its own copy of the pending-count hook, so the page you acted on refreshes its own copy but not the tab bar's. The numbers reconcile when you navigate.
- If the app is disabled, the screen won't say so — a 403 from
pending-countis deliberately swallowed to zeros across all three figures, so the badges simply vanish rather than raising an error banner. - The contrast error message is intentionally left untranslated, so it matches the backend's response verbatim (e.g.
...has a contrast ratio of 4.12:1, below the 4.5:1 minimum for body text) — translating it would make the two impossible to correlate. - At most 5 pinned notices per board — unpin something first.
- Unblocking is a revoke, not a delete. The record of who was blocked, by whom and for how long stays auditable forever, and revoking twice is a no-op that won't overwrite the first revoke's timestamp and actor.
- Clearing "who can read the board" hides it from everyone, including members — allowed on purpose: it's how a board is taken offline without being deleted.
- The audit log has no
comment.editaction — of the 22 values, editing a comment isn't recorded as its own action.
Access control (verified against the source)
- Backend —
AppEnabledGuardis genuinely chained onto every route in this module.bulletin/controller.gobuildsappEnabled := apps.AppEnabledGuard(d)and puts it as the first middleware on all 23 routes, line by line, with none missed. The guard itself (apps/guard.go) extracts the app id from the path with the regex/apps/(\w+)and callsIsAppEnabled(orgID, "bulletin"), which looks for aline_oa_approw whereorganization_id = ? AND app_id = ? AND enabled = true— no row means disabled (it does not default to enabled) — and returns 403App "bulletin" is not enabled for this organization. Worth noting: this guard scopes by organization_id only, not per LINE OA — enabling the app for an organization enables it for every OA in it. - Enabling/disabling the app is a platform-admin-only endpoint.
PUT /api/apps/:appIdis wrapped inmodulegate.PlatformOnly(d), which reads the caller'suser.typefrom the database and rejectscustomer-type callers outright. The code comment gives the reason plainly: if an org could toggle its own apps, it could simply re-grant something the platform admin had withheld, which would make the console's governance advisory rather than real. There's a deliberate asymmetry here: a normal module'sModuleGatefails open (a lookup hiccup must never lock an org out of a module it legitimately has), whereasPlatformOnlyfails closed (a caller we can't identify is treated as a customer and denied), because the failure mode here is privilege escalation, not lockout. - The action-level policy annotations you'll see in the code enforce nothing. Every route declares
auth.CheckPolicies(auth.Can(auth.PolicyRead/ReadAll/Create/Update/Delete, auth.PolicyModuleLineOa)), butauth.CheckPoliciesininternal/modules/auth/policy.gois a genuine no-op — the comment in the file reads "Enforcement disabled — allow all authenticated requests (TS parity)". So be precise about this: the only real enforcement layer on this module is the app-levelAppEnabledGuard. There's noModuleGatehere the way Campaign has one, and no separation between someone who may read and someone who may approve — anyone who can reach the app can do everything in it. - Cross-tenant separation is enforced at the query layer, not the permission layer. Every query in this module goes through
repo.scoped/repo.tx, which constrain bothorganization_idandline_oa_id, andscope_test.gois an AST guard that breaks the build if any other file in the package names a raw gorm handle. The consequence is that caller-supplied ids (category, report, block) are checked for ownership rather than existence, and a miss returns 404, not 403 — deliberately, because a 403 would confirm the row exists somewhere the caller can't see.
Source file reference
Frontend (cms-web-2026)
src/app/apps/bulletin/layout.tsx— the 7-tab bar and the pending-count badgessrc/app/apps/bulletin/posts/—page.tsx(triage-vs-table switch),triage-pane.tsx(the J/K/A/H/X shortcuts and queue advance),post-table.tsx,post-composer.modal.tsx(image upload via Content Management)src/app/apps/bulletin/settings/—page.tsx(contrast gate before save),theme-editor.tsx,phone-preview.tsxsrc/app/apps/bulletin/{comments,reports,categories,members,activity}/page.tsx— comment queue, reports, categories, blocked members, audit logsrc/app/apps/bulletin/lib/stamp.ts— the "stamp" state machine duplicated verbatim into client-websrc/app/apps/bulletin/lib/theme.ts— the WCAG maths and the pairs that get checkedsrc/services/bulletin.service.ts— every endpoint the frontend calls
Backend (line-management-cms-api-go)
internal/modules/apps/guard.go—AppEnabledGuardand theline_oa_applookupinternal/modules/apps/controller.go—PUT /apps/:appIdwithmodulegate.PlatformOnlyinternal/modules/modulegate/modulegate.go—PlatformOnly, which fails closed (unlikeModuleGate, which fails open)internal/modules/bulletin/controller.go— all 23 routes and theAppEnabledGuardon eachinternal/modules/bulletin/service_board.go— ON CONFLICT board bootstrap, settings, categories, pending countsinternal/modules/bulletin/service_moderation.go— post/comment statuses, the 5-pin cap with its board-row lock,postStatusAfterEditinternal/modules/bulletin/service_safety.go— reports, the block slot's unique index, the audit-log readinternal/modules/bulletin/dto.go— settings bounds, the theme ladder,validateTokenContrastinternal/modules/bulletin/scope.go+scope_test.go— the two-column scoping and the AST guard that enforces its use
See the documentation team's earlier technical write-up at Bulletin App