Bulletin App
Overview
Bulletin is one of the Apps — optional modules a platform administrator enables or disables per organisation, rather than being governed by role-based permissions like the rest of the CMS.
The app provides a forum-style noticeboard that LINE OA members read, post to, and comment on through the customer-facing web app. On the CMS side it acts as a complete moderation toolkit.
CMS users are administrators and moderators, working across seven tabs: triaging pending notices, reviewing comments, adjudicating user reports, managing categories, blocking members, configuring the board and its theme, and reviewing the audit log.
Several things make this module distinctly different from the rest of the product:
- It contains a stamp state machine that is a cross-repository contract. The same file is duplicated verbatim in the customer web app, and both repositories run contract tests against the same fixtures.
- It contains a theme resolver with a WCAG contrast guard that mirrors the equivalent logic on the Go server.
- The single-notice renderer exists in two implementations by design, because the CMS and the customer web app use different styling toolchains.
Business Flow
Access and app visibility
- The side menu queries the app registry on load and keeps only the enabled entries. If Bulletin is present, a submenu item is added along with a badge counting pending items — the sum of pending notices, comments, and reports.
- The badge is fetched only when the registry confirms the app is enabled, because that endpoint rejects requests when the app is off, and firing a rejected request on every page load would fill the logs with a falsehood.
- Every route under
/appssits behind a shared route guard that reads the app ID from the URL, checks it against the registry, redirects home when the app is disabled, and allows access when the registry call itself fails, since the server still protects the data. - The app layout renders the seven tabs, with the first three carrying their own badges. Breadcrumbs and active menu state are set by each page individually.
Notices and the triage queue
- The notices screen loads the board settings once, keeping the resolved theme (so previews draw in the tenant's real colours) and the limits it passes to the composer. It also loads the category list.
- Status is selected between pending, published, hidden, and deleted, alongside a category selector and a search box.
- Selecting pending forces the whole queue into one page, because a triage queue needs to show everything rather than twenty at a time, and the screen switches to the queue view instead of the table view.
Triage view
- The left pane is the queue, showing the timestamp, title, author, and the first line of the body, with a red tag when reports have come in.
- The right pane renders the full notice on a surface using the board's real theme, forced to remount on each selection so the stamp animation replays.
- Selection is positional rather than by ID. After an item is actioned it leaves the queue, and the same position becomes the next item automatically — so the queue advances rather than jumping back to the top.
- Keyboard shortcuts cover moving up and down, approving, hiding, and deleting, with guards that suppress them while the user is typing in a field, while a modal is open, or when the queue is empty.
- Approve and hide have no confirmation because they are reversible, but delete does, because the audit system records deleted as an unrecoverable terminal state.
- Other actions available from the queue are pinning, toggling comments, editing the notice, and blocking the author — with the block button disabled on notices the administrator wrote themselves.
Table view
- Columns cover the row number, the notice (a clickable title that opens editing, plus a body snippet), the author with a type tag, the category, the status as a coloured tag, engagement counts, the creation date, and actions.
- Actions carry explicit conditions: edit is unavailable on deleted notices, pinning applies only to published notices, and hidden notices show a republish button in place of the hide button.
- This view paginates server-side.
Notice composer
- The composer deliberately has no status field. The server always publishes notices created by an administrator, even when the board requires post approval, because the administrator is the approver — and an administrator's edit never downgrades a published notice back to pending. The screen therefore explains this in prose rather than offering a control the API would ignore.
- Limits come from the board settings rather than constants — both the images-per-notice cap and the body length. The title length is a code constant, because it reflects a database column width and should not be configurable beyond what can actually be stored.
- Images upload through the content management service, are held as a list of URLs, and can be removed individually.
- On save, the category is explicitly submitted as empty when the user clears it, because omitting the key would leave the value unchanged.
Comments
- This is a board-wide comment queue split by the same statuses as notices.
- Columns show the truncated but expandable comment body, the author, the source notice, the report count, the date, and the actions.
- Source notice titles load lazily — one request per distinct notice, triggered on hover or focus, with in-flight tracking to prevent duplicate requests.
- Actions appear according to the current status, with the hide button's wording changing to "reject" while a comment is still pending.
- Every successful action reloads the list and refreshes the menu badge.
User reports
- Reports are split across three statuses: open, resolved, and dismissed.
- Columns show the report target, the reason, the details, the reporter as a raw LINE user ID, the date, and the actions.
- The reporter is shown as a raw ID because the API does not join profile data, and guessing at a name from another endpoint would mean displaying someone else's identity on speculation.
- Open reports offer resolve and dismiss buttons; other statuses render as a tag only, because an adjudication cannot be reversed.
- Rows expand to reveal the actual target. A notice target renders as a full notice with hide and delete buttons; a comment target renders as a detail block with the same controls. If the target cannot be loaded, a warning explains that it is gone.
- Looking up a comment by ID is a workaround for an API gap. With no endpoint to read a single comment, the screen walks the board's comment list, capped at five pages — an uncapped walk would amount to a denial-of-service attack on the platform's own API.
Categories
- This screen loads the categories together with the board settings, because it needs the board-level view access to explain what inheritance means.
- Categories have two clearly separated axes, and the UI works hard to stop users reading them as a single value.
- The view axis decides who sees the category: inherit from the board, all members, or restricted to specific audiences.
- The write axis decides who can post into it: members or administrators only. An admin-write category is still readable by members.
- A critical client-side guard: choosing audience restriction without picking any audience blocks the save, because submitting a restricted mode with an empty audience list means "all members", not "nobody".
- The category form covers the name, colour, view access options, an audience picker (shown only when relevant), write access options, sort order, and status.
Blocked members
- This screen lists all blocks. The API accepts pagination parameters only — no status or scope filters.
- Columns show the raw LINE user ID (for the same reason as the reports screen), the block scope (everything, or comments only), the state, the expiry date, the reason, and the block date.
- The state is derived from three fields: revoked, expired, or still in force.
- Unblocking is a soft revoke rather than a real deletion, even though the API responds with a success message that reads as a delete.
- The block modal — shared with the triage view — takes a LINE user ID, scope, reason, and expiry. Two design rules matter: the administrator need not revoke an existing block first, because the server handles that in the same transaction, so widening a scope is simply a fresh create; and an empty expiry is never submitted, because omitting it means a permanent block, which is the intended default.
Board settings
- Settings are organised into six cards: sharing, board information, access, moderation, limits, and theme.
- The sharing card shows the board URL in both web and LIFF form with copy controls, distinguishing "no OA hash" from "hash present but no LIFF ID".
- The access card configures view access and write access independently, each opening to members or guests.
- The moderation card has three switches: require post approval, require comment approval, and allow user reports.
- The limits card covers images per notice, body length, comment length, the default state of comments on new notices, and the reaction emoji set (capped at eight).
- The stored theme state is only the administrator's overrides, never the resolved set. A new board has no theme values at all, and seeding from the resolved set would write a full override on the very first save, permanently detaching that board from the preset ladder.
- Contrast is validated client-side before saving. Problems are reported immediately without a round trip. The server checks the same conditions and remains the authority; the client check is purely a fast path.
Theme editor and contrast guard
- What is stored is a patch, not a full set. Clearing an advanced token sets it to empty, which the server reads as "drop this override" so the value falls back to the accent colour rather than to nothing.
- Precedence runs three deep: the preset, then the custom accent colour, then per-token overrides. Four presets are available, each defining six colour tokens. Corner radius and layout density are also adjustable.
- The contrast guard checks three text-on-background pairs against a minimum ratio of 4.5 to 1 per WCAG.
- One pair is deliberately excluded from that list, because including it would fail the product's own default palette — and a guard that rejects the product's defaults is a guard administrators learn to work around.
- The guard receives resolved tokens rather than raw overrides. Judging from overrides alone would only fire when both halves of a pair were customised, letting a single-sided override slip through and render below threshold.
- The board surface is the single point where tenant colours touch the DOM, written as CSS custom properties on a wrapper element, and colour formats are re-validated there even though the server already checked them.
- The phone preview renders three sample notices chosen to exercise every part of the palette at once: a pinned official notice, an ordinary notice, and a pending one.
Audit log
- Three filters are available: target type (a closed set of six values, since anything outside the set is rejected rather than returning an empty page), target ID, and action type (chosen from a list of twenty-two).
- The action list covers everything that can be done to notices, comments, reports, blocks, categories, and settings — with no comment-edit action, deliberately.
- Action tag colours are derived from the action name's suffix: deletes and rejections are red, approvals and creations green, hides and revocations orange.
- The actor column uses the name captured at the time of the action, not a live join, because a later rename must not overwrite history.
- Rows expand to show before and after values. The two sides are always rendered separately and never zipped by key, because the before side uses entity field names while a partial edit's after side is built from the ORM update map and therefore uses column names. Zipping by key would make fields appear to have been emptied when only the spelling differs.
- The vertical layout of that block is load-bearing rather than decorative: a horizontal layout narrows the value column until Thai text and URLs wrap one or two characters at a time, which was the real cause of the reported "overlap" bug.
Stamp state machine
This resolves which stamp a notice should display, from the notice status, the viewer's perspective, and the author type.
- A pending notice is invisible to others entirely, while its author sees it marked pending and administrators see it in the queue.
- Hidden and deleted notices show that state only to administrators.
- A published notice written by an administrator carries the official stamp.
- Moderation state outranks author class. An official notice that has been hidden shows the hidden stamp, not the official one, because "is this live?" is the more urgent fact — inverting the precedence would leave a suspended notice still carrying institutional authority.
- The stamp carries a screen-reader label, because rotation and colour are its only carriers and neither survives assistive technology. It is explicitly not a verification badge.
Key Screens & Components
Shared route guard (src/app/apps/layout.tsx) and app layout (src/app/apps/bulletin/layout.tsx) — enablement checking, the seven tabs, and their badges.
Tab pages (src/app/apps/bulletin/*/page.tsx) — notices, comments, reports, categories, blocked members, settings, and the audit log. The notices screen splits into the triage and table views and owns its own composer modal.
Shared app components — the single-notice renderer, the board surface that carries tenant colours, the stamp, and the block-user modal.
App-local libraries — the stamp state machine (lib/stamp.ts, which imports nothing at all because it is duplicated verbatim in the customer web app), the theme resolver and contrast guard (lib/theme.ts), the pending counter, the board link builder, the comment-by-ID finder, the clipboard helper, formatting helpers, and the Thai copy used inside previews.
Service — src/services/bulletin.service.ts covers every endpoint plus a shared error translator handling three error shapes: validation errors that arrive as an array of messages, the pin-limit error whose code must be turned into an actionable sentence, and generic errors that may carry no message at all.
Dependencies
- App registry and platform administrator — decide whether the app appears at all; the CMS can only read the registry.
- Permissions — subjects are declared but have no backend mapping, so they serve only as menu and breadcrumb keys. Real access control comes from the registry plus the API-side guard.
- Customer web app — hosts the member-facing board and shares two files as contracts with the CMS: the stamp state machine (duplicated verbatim and tested against the same fixtures) and the preview copy (which must remain diffable, key for key and word for word).
- Go API — the source of truth for the data shapes and the theme resolution logic; the client-side theme file is a mirror pinned by tests.
- Audience — restricts category visibility.
- Content Management — the app borrows this module's image upload service.
- LIFF and the LINE Developers console — the LIFF endpoint URL is fixed at the customer web app's root, so board links must append their path; otherwise the link opens the home page rather than the board.
- API gaps worked around on the client — no endpoint to read a single comment, no filters on the block list, and neither the block list nor the report list joins LINE profile data.
Backend Details (CMS API)
The module lives in internal/modules/bulletin/, with every endpoint under /api/apps/bulletin/. Unlike its siblings it was written fresh against a design spec rather than ported from the previous system, which is why it carries an explicit set of data-safety rules enforced module-wide.
Six data-safety rules
These rules are documented in every service file in the module, and they explain much of what the UI sees.
- Every database call goes through a helper that applies scoping automatically, constraining both the organisation ID and the LINE OA ID together rather than either alone. There is no way to accidentally write a query that crosses organisation boundaries.
- Caller-supplied IDs are checked for ownership, not merely existence, and a miss returns 404 rather than 403 on purpose: a 403 would confirm the row exists in some other organisation, which is itself a leak.
- Every mutation and its audit log row share one transaction, so there is no case where an action succeeds but its history is lost, or the reverse.
- Application code must never write the counter columns (comment count, reaction count, report count) because database triggers own those numbers. The figures the UI shows come straight from the database and cannot drift through double-counting.
- Posts and comments must not be mutated in the same transaction, because the triggers take advisory locks keyed by target type; reversing the lock order deadlocks.
- Deletion is always a status flip to "deleted" plus a timestamp — never a hard delete, so the data remains auditable.
Required permissions
- Every route is wrapped in the
AppEnabledGuard— the organisation must have the app enabled or the request gets a 403, and enabling an app is a platform-administrator power only. - The check order is: app-enabled, then policy, then handler.
- Policy metadata is declared against the
line-oamodule and split by action, but the check that actually bites is the app-enabled test — consistent with the frontend, where the subject serves only as a menu key.
Moderation queue behaviour
GET /api/apps/bulletin/pending-countexists specifically to feed the menu badge, so it can be polled more often than the queues themselves.- Report rows are never deleted, even once dismissed, because an index prevents the same person reporting the same item twice. Deleting the row would let them report repeatedly, so "handled" is expressed as a status change on the report.
- Removing a block (
DELETE /api/apps/bulletin/blocks/:id) is a revocation, not a row deletion — the record that someone was once blocked survives. GET /api/apps/bulletin/audit-logis read-only; no endpoint exists to edit or delete the team's action history.
Tables and database mechanisms
The bulletin table group (board, posts, comments, engagement, reports, blocks, and audit log), with database triggers owning the counter columns and taking the advisory locks — which is precisely why rules 4 and 5 are so strict.