← All @molecule/* packages · App templates

@molecule/api-resource-forum-thread

API resource · resource-forum-thread · API (Node) · v1.0.1 · Apache-2.0

Forum threads + nested replies + voting

npm install @molecule/api-resource-forum-thread

npm · Source on GitHub

How 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

Reference

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.ts JSDoc, 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).

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 express zod
npm install -D @types/express

API

Interfaces

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
}

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
}

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

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>

Constants

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
>

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
>

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
>

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
>

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
>

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
  • express ^5.0.0
  • zod ^4.0.0

Runtime Dependencies

  • @molecule/api-bonds-default-express
  • @molecule/api-database
  • @molecule/api-i18n
  • @molecule/api-middleware-validation
  • express
  • zod

Tables: 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.

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.