@molecule/api-resource-forum-thread
API resource · resource-forum-thread · API (Node) · v1.0.5 · Apache-2.0
Forum threads + nested replies + voting
npm install @molecule/api-resource-forum-threadHow it works
@molecule/api-resource-forum-thread is an API resource: the routes, validation and storage for resource-forum-thread, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { createForumThreadRouter } from '@molecule/api-resource-forum-thread'
app.use(
'/threads',
createForumThreadRouter({
isModeratorFor: async (userId) => userIsMod(userId),
}),
)Works with: @molecule/api-bonds-default-express, @molecule/api-database, @molecule/api-i18n, @molecule/api-middleware-validation, @molecule/api-slug
Reference
@molecule/api-resource-forum-thread — forum threads + nested replies +
voting + author/moderator authorization.
Extracted from the forum flagship. createForumThreadRouter({ isModeratorFor })
exposes public reads + authed writes. Voting is idempotent (changing vote
adjusts score correctly; voting again with same value is a noop).
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { createForumThreadRouter } from '@molecule/api-resource-forum-thread'
app.use(
'/threads',
createForumThreadRouter({
isModeratorFor: async (userId) => userIsMod(userId),
}),
)
Type
resource
Installation
npm install @molecule/api-resource-forum-thread @molecule/api-bonds-default-express @molecule/api-database @molecule/api-i18n @molecule/api-middleware-validation @molecule/api-slug express zod
npm install -D @types/express
API
Interfaces
ForumThreadRow
Raw database row for a forum thread record.
interface ForumThreadRow {
id: string
author_id: string
category_id: string | null
title: string
body: string
slug: string
status: ThreadStatus
is_pinned: boolean
vote_score: number
reply_count: number
view_count: number
last_activity_at: string | Date
created_at: string | Date
updated_at: string | Date
}
ForumReplyRow
Raw database row for a reply to a forum thread.
interface ForumReplyRow {
id: string
thread_id: string
parent_reply_id: string | null
author_id: string
body: string
vote_score: number
is_deleted: boolean
created_at: string | Date
updated_at: string | Date
}
ForumVoteRow
Raw database row for a vote cast on a thread or reply.
interface ForumVoteRow {
id: string
user_id: string
target_type: 'thread' | 'reply'
target_id: string
value: 1 | -1
created_at: string | Date
}
Types
ThreadStatus
Possible lifecycle states for a forum thread.
type ThreadStatus = 'open' | 'closed' | 'locked' | 'archived'
Functions
createForumThreadRouter(opts?)
Express router for forum threads. Pass isModeratorFor(userId) to
allow moderator-only operations (pinning, status changes, deleting
others' threads/replies).
function createForumThreadRouter(opts?: {
isModeratorFor?: (userId: string) => boolean | Promise<boolean>
}): Router
listThreads(opts)
List forum threads with optional category/status filtering, sorting, and pagination.
function listThreads(opts: {
category_id?: string
status?: ThreadStatus
sort?: 'recent' | 'top' | 'pinned'
page?: number
limit?: number
}): Promise<{ data: ForumThreadRow[]; total: number }>
getThread(threadId)
Fetch a single forum thread by ID, or null if not found.
function getThread(threadId: string): Promise<ForumThreadRow | null>
createThread(authorId, data)
Create a new forum thread and return the persisted row.
function createThread(
authorId: string,
data: { title: string; body: string; category_id?: string | null },
): Promise<ForumThreadRow>
updateThread(threadId, userId, isModerator, patch)
Apply a partial patch to a thread; enforces author/moderator ownership and returns the updated row.
function updateThread(
threadId: string,
userId: string,
isModerator: boolean,
patch: Partial<{
title: string
body: string
category_id: string | null
status: ThreadStatus
is_pinned: boolean
}>,
): Promise<ForumThreadRow | null>
deleteThread(threadId, userId, isModerator)
Delete a thread; enforces author/moderator ownership and returns true on success.
function deleteThread(threadId: string, userId: string, isModerator: boolean): Promise<boolean>
incrementViewCount(threadId)
Atomically increment the view_count of a thread.
function incrementViewCount(threadId: string): Promise<void>
listReplies(threadId)
Return all replies for a thread in chronological order.
function listReplies(threadId: string): Promise<ForumReplyRow[]>
createReply(threadId, authorId, data)
Add a reply (or nested reply) to an open thread; bumps reply_count and last_activity_at.
function createReply(
threadId: string,
authorId: string,
data: { body: string; parent_reply_id?: string | null },
): Promise<ForumReplyRow | null>
deleteReply(replyId, userId, isModerator)
Soft-delete a reply (body → "[deleted]"); enforces author/moderator ownership.
function deleteReply(replyId: string, userId: string, isModerator: boolean): Promise<boolean>
castVote(userId, targetType, targetId, value)
Cast a vote — idempotent. If user already voted, replaces value (or noop).
function castVote(
userId: string,
targetType: 'thread' | 'reply',
targetId: string,
value: 1 | -1,
): Promise<{ score: number } | null>
Constants
THREAD_STATUSES
Allowed status values for a forum thread.
const THREAD_STATUSES: readonly ['open', 'closed', 'locked', 'archived']
threadCreateSchema
Validates the request body for creating a new forum thread.
const threadCreateSchema: z.ZodObject<
{ title: z.ZodString; body: z.ZodString; category_id: z.ZodOptional<z.ZodNullable<z.ZodString>> },
z.core.$strip
>
threadUpdateSchema
Validates the request body for updating an existing forum thread.
const threadUpdateSchema: z.ZodObject<
{
title: z.ZodOptional<z.ZodString>
body: z.ZodOptional<z.ZodString>
category_id: z.ZodOptional<z.ZodNullable<z.ZodString>>
status: z.ZodOptional<
z.ZodEnum<{ open: 'open'; closed: 'closed'; locked: 'locked'; archived: 'archived' }>
>
is_pinned: z.ZodOptional<z.ZodBoolean>
},
z.core.$strip
>
replyCreateSchema
Validates the request body for creating a reply on a forum thread.
const replyCreateSchema: z.ZodObject<
{ body: z.ZodString; parent_reply_id: z.ZodOptional<z.ZodNullable<z.ZodString>> },
z.core.$strip
>
voteSchema
Validates the request body for casting a vote (+1 or -1) on a thread or reply.
const voteSchema: z.ZodObject<
{ value: z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<-1>]> },
z.core.$strip
>
threadListQuerySchema
Validates query parameters for listing forum threads with filtering, sorting, and pagination.
const threadListQuerySchema: z.ZodObject<
{
category_id: z.ZodOptional<z.ZodString>
status: z.ZodOptional<
z.ZodEnum<{ open: 'open'; closed: 'closed'; locked: 'locked'; archived: 'archived' }>
>
sort: z.ZodOptional<z.ZodEnum<{ recent: 'recent'; top: 'top'; pinned: 'pinned' }>>
page: z.ZodDefault<z.ZodCoercedNumber<unknown>>
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>
},
z.core.$strip
>
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bonds-default-express^1.0.1@molecule/api-database^1.0.1@molecule/api-i18n^1.0.1@molecule/api-middleware-validation^1.0.1@molecule/api-slug^1.0.0express^5.0.0zod^4.0.0
Runtime Dependencies
@molecule/api-bonds-default-express@molecule/api-database@molecule/api-i18n@molecule/api-middleware-validation@molecule/api-slugexpresszod
Tables: src/__setup__/forum_threads.sql creates forum_threads,
forum_replies, and forum_votes. An mlcl-scaffolded API replays
__setup__/*.sql on migrate (a CREATE TABLE IF NOT EXISTS, so a table the app's own migrations already create keeps only its own columns: ALTER in the ones it lacks); anywhere else run it once —
nothing at runtime creates them.
Reads (GET /, GET /:id, GET /:id/replies) are PUBLIC. Writes read the
AUTHENTICATED user from res.locals.session (mount the router behind your
global auth middleware; without a session every write 401s) — the author is
always the session user, never a body field. Edits/deletes are author-only;
isModeratorFor(userId) is the ONLY escalation path and defaults to
() => false, so moderator powers are DENIED until you pass a real
implementation.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual forum screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Creating a thread persists it: submit a title + body in the composer → it returns and appears in the forum list (GET /) with your title, and opening it shows the body and YOU as the author (author is the session user, never a body field).
- Posting a reply adds it in order and bumps the count: submit a reply → it appears at the bottom of the thread's replies (chronological), the thread's reply_count increments by one, and the thread jumps to the top of the "recent" sort (last_activity_at). A nested reply (parent_reply_id) renders under its parent.
- A locked thread rejects replies, a pinned thread sorts first: a thread with status 'locked' (or 'archived') refuses a new reply with the visible "Thread is closed for replies" message and adds nothing; a pinned thread (is_pinned) sorts above non-pinned ones under the "pinned" sort.
- Counters are truthful: each open of a thread (GET /:id) increments its view_count by one, and reply_count always equals the number of replies shown — no drift.
- Edit/delete is author-only, escalation is moderator-only: only the AUTHOR can edit or delete their own thread/reply; another user's attempt is refused and nothing changes. Pinning, status changes (lock/close/archive), and deleting SOMEONE ELSE'S thread/reply require a moderator (isModeratorFor → true, which defaults to false) — a normal user invoking any of those is denied and the action never takes effect.
- Writes require a session and the author isn't spoofable: logged out, every write (create, reply, edit, delete, vote) is refused (401) and the UI cannot post; logged in, sending an author_id/user id in the body does NOT override the real author (the session user). Reads (list/detail/replies) stay public.
- Deleting cascades correctly: deleting a THREAD also removes its replies (they vanish from the forum), while deleting a REPLY tombstones it (body shows "[deleted]") so nested structure is preserved rather than removed.