← All @molecule/* packages · App templates
@molecule/api-resource-forum-threadAPI resource · resource-forum-thread · API (Node) · v1.0.1 · Apache-2.0
Forum threads + nested replies + voting
npm install @molecule/api-resource-forum-thread@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
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.
@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).
import { createForumThreadRouter } from '@molecule/api-resource-forum-thread'
app.use(
'/threads',
createForumThreadRouter({
isModeratorFor: async (userId) => userIsMod(userId),
}),
)
resource
npm install @molecule/api-resource-forum-thread @molecule/api-bonds-default-express @molecule/api-database @molecule/api-i18n @molecule/api-middleware-validation express zod
npm install -D @types/express
ForumReplyRowRaw 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
}
ForumThreadRowRaw 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
}
ForumVoteRowRaw 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
}
ThreadStatusPossible lifecycle states for a forum thread.
type ThreadStatus = 'open' | 'closed' | 'locked' | 'archived'
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>
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
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>
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>
deleteReply(replyId, userId, isModerator)Soft-delete a reply (body → "[deleted]"); enforces author/moderator ownership.
function deleteReply(replyId: string, userId: string, isModerator: boolean): Promise<boolean>
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>
getThread(threadId)Fetch a single forum thread by ID, or null if not found.
function getThread(threadId: string): Promise<ForumThreadRow | null>
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[]>
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 }>
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>
replyCreateSchemaValidates 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
>
THREAD_STATUSESAllowed status values for a forum thread.
const THREAD_STATUSES: readonly ['open', 'closed', 'locked', 'archived']
threadCreateSchemaValidates 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
>
threadListQuerySchemaValidates 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
>
threadUpdateSchemaValidates 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
>
voteSchemaValidates 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
>
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.1express ^5.0.0zod ^4.0.0@molecule/api-bonds-default-express@molecule/api-database@molecule/api-i18n@molecule/api-middleware-validationexpresszodTables: src/__setup__/forum_threads.sql creates forum_threads,
forum_replies, and forum_votes. An mlcl-scaffolded API replays
__setup__/*.sql automatically on migrate; 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.
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: