[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Threaded Comment System

Build a Threaded Comment System

Build a Reddit-style threaded comment system with nested replies, voting, sorting, pagination, mention notifications, and moderation tools — supporting deep discussion threads on any content.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Nina leads product at a 20-person developer documentation platform. Users request comments on docs and tutorials. Flat comment systems (like Disqus) make conversations hard to follow — replies to different points mix together. They need threaded comments where replies nest under their parent, users can vote on helpful answers, and moderators can hide spam. The comment system should work on any entity (docs, tutorials, blog posts) and handle thousands of comments per page efficiently.

Step 1: Build the Comment Engine

typescript
// src/comments/engine.ts — Threaded comments with voting, moderation, and efficient tree loading
import { pool } from "../db";
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

const MAX_DEPTH = 10;              // maximum nesting depth
const COMMENTS_PER_PAGE = 20;

interface Comment {
  id: string;
  entityType: string;          // "doc", "tutorial", "post"
  entityId: string;
  parentId: string | null;
  authorId: string;
  authorName: string;
  authorAvatar: string;
  body: string;
  bodyHtml: string;            // rendered markdown
  depth: number;
  path: string;                // materialized path: "001.003.007"
  score: number;               // upvotes - downvotes
  upvotes: number;
  downvotes: number;
  replyCount: number;
  status: "visible" | "hidden" | "deleted";
  createdAt: string;
  editedAt: string | null;
  children?: Comment[];
}

// Create a comment or reply
export async function createComment(
  entityType: string,
  entityId: string,
  authorId: string,
  body: string,
  parentId?: string
): Promise<Comment> {
  let depth = 0;
  let path = "";

  if (parentId) {
    const { rows: [parent] } = await pool.query(
      "SELECT depth, path FROM comments WHERE id = $1",
      [parentId]
    );
    if (!parent) throw new Error("Parent comment not found");
    if (parent.depth >= MAX_DEPTH) throw new Error("Maximum reply depth reached");

    depth = parent.depth + 1;

    // Generate path segment (sequential within parent)
    const { rows: [{ count }] } = await pool.query(
      "SELECT COUNT(*) as count FROM comments WHERE parent_id = $1",
      [parentId]
    );
    const segment = String(parseInt(count) + 1).padStart(3, "0");
    path = `${parent.path}.${segment}`;
  } else {
    // Top-level comment
    const { rows: [{ count }] } = await pool.query(
      "SELECT COUNT(*) as count FROM comments WHERE entity_type = $1 AND entity_id = $2 AND parent_id IS NULL",
      [entityType, entityId]
    );
    path = String(parseInt(count) + 1).padStart(3, "0");
  }

  const id = `cmt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
  const bodyHtml = renderMarkdown(body);

  const { rows: [comment] } = await pool.query(
    `INSERT INTO comments (id, entity_type, entity_id, parent_id, author_id, body, body_html, depth, path, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
     RETURNING *`,
    [id, entityType, entityId, parentId || null, authorId, body, bodyHtml, depth, path]
  );

  // Update parent reply count
  if (parentId) {
    await pool.query(
      "UPDATE comments SET reply_count = reply_count + 1 WHERE id = $1",
      [parentId]
    );
  }

  // Invalidate cache
  await redis.del(`comments:${entityType}:${entityId}`);

  // Handle @mentions
  const mentions = extractMentions(body);
  if (mentions.length > 0) {
    await notifyMentions(mentions, comment, entityType, entityId);
  }

  // Notify parent author of reply
  if (parentId) {
    const { rows: [parent] } = await pool.query("SELECT author_id FROM comments WHERE id = $1", [parentId]);
    if (parent.author_id !== authorId) {
      await redis.rpush("notification:queue", JSON.stringify({
        userId: parent.author_id,
        type: "comment_reply",
        data: { commentId: id, entityType, entityId, authorId },
      }));
    }
  }

  return comment;
}

// Get threaded comments for an entity
export async function getComments(
  entityType: string,
  entityId: string,
  options?: {
    sort?: "newest" | "oldest" | "top";
    page?: number;
    userId?: string;         // for showing user's votes
  }
): Promise<{ comments: Comment[]; total: number; hasMore: boolean }> {
  const page = options?.page || 1;
  const sort = options?.sort || "top";

  // Sort order for top-level comments
  let orderBy = "score DESC, created_at DESC";
  if (sort === "newest") orderBy = "created_at DESC";
  if (sort === "oldest") orderBy = "created_at ASC";

  // Get top-level comments with pagination
  const { rows: topLevel } = await pool.query(
    `SELECT c.*, u.name as author_name, u.avatar_url as author_avatar
     FROM comments c
     JOIN users u ON c.author_id = u.id
     WHERE c.entity_type = $1 AND c.entity_id = $2
       AND c.parent_id IS NULL AND c.status = 'visible'
     ORDER BY ${orderBy}
     LIMIT $3 OFFSET $4`,
    [entityType, entityId, COMMENTS_PER_PAGE, (page - 1) * COMMENTS_PER_PAGE]
  );

  // Get all replies for these top-level comments (using path prefix)
  const topIds = topLevel.map((c) => c.id);
  let allReplies: any[] = [];

  if (topIds.length > 0) {
    const paths = topLevel.map((c) => c.path);
    const pathConditions = paths.map((_, i) => `c.path LIKE $${i + 3} || '.%'`).join(" OR ");

    const { rows } = await pool.query(
      `SELECT c.*, u.name as author_name, u.avatar_url as author_avatar
       FROM comments c
       JOIN users u ON c.author_id = u.id
       WHERE c.entity_type = $1 AND c.entity_id = $2
         AND c.status = 'visible'
         AND (${pathConditions})
       ORDER BY c.path`,
      [entityType, entityId, ...paths]
    );
    allReplies = rows;
  }

  // Build tree
  const commentMap = new Map<string, Comment>();
  for (const c of [...topLevel, ...allReplies]) {
    commentMap.set(c.id, { ...c, children: [] });
  }

  const rootComments: Comment[] = [];
  for (const c of commentMap.values()) {
    if (c.parentId && commentMap.has(c.parentId)) {
      commentMap.get(c.parentId)!.children!.push(c);
    } else if (!c.parentId) {
      rootComments.push(c);
    }
  }

  // Get user's votes if logged in
  if (options?.userId) {
    const allIds = [...commentMap.keys()];
    if (allIds.length > 0) {
      const { rows: votes } = await pool.query(
        `SELECT comment_id, vote FROM comment_votes
         WHERE user_id = $1 AND comment_id = ANY($2)`,
        [options.userId, allIds]
      );
      for (const vote of votes) {
        const comment = commentMap.get(vote.comment_id);
        if (comment) (comment as any).userVote = vote.vote;
      }
    }
  }

  const { rows: [{ count }] } = await pool.query(
    "SELECT COUNT(*) as count FROM comments WHERE entity_type = $1 AND entity_id = $2 AND parent_id IS NULL AND status = 'visible'",
    [entityType, entityId]
  );

  return {
    comments: rootComments,
    total: parseInt(count),
    hasMore: page * COMMENTS_PER_PAGE < parseInt(count),
  };
}

// Vote on a comment
export async function vote(commentId: string, userId: string, direction: "up" | "down"): Promise<{ score: number }> {
  const voteValue = direction === "up" ? 1 : -1;

  // Upsert vote
  const { rows: [existing] } = await pool.query(
    "SELECT vote FROM comment_votes WHERE comment_id = $1 AND user_id = $2",
    [commentId, userId]
  );

  if (existing) {
    if (existing.vote === voteValue) {
      // Remove vote (toggle off)
      await pool.query("DELETE FROM comment_votes WHERE comment_id = $1 AND user_id = $2", [commentId, userId]);
      await pool.query(
        `UPDATE comments SET ${direction === "up" ? "upvotes" : "downvotes"} = ${direction === "up" ? "upvotes" : "downvotes"} - 1,
         score = upvotes - downvotes WHERE id = $1`,
        [commentId]
      );
    } else {
      // Change vote direction
      await pool.query("UPDATE comment_votes SET vote = $3 WHERE comment_id = $1 AND user_id = $2", [commentId, userId, voteValue]);
      await pool.query(
        `UPDATE comments SET upvotes = upvotes + $2, downvotes = downvotes - $2, score = upvotes - downvotes + $2 * 2 WHERE id = $1`,
        [commentId, direction === "up" ? 1 : -1]
      );
    }
  } else {
    await pool.query("INSERT INTO comment_votes (comment_id, user_id, vote) VALUES ($1, $2, $3)", [commentId, userId, voteValue]);
    await pool.query(
      `UPDATE comments SET ${direction === "up" ? "upvotes = upvotes + 1" : "downvotes = downvotes + 1"}, score = upvotes - downvotes WHERE id = $1`,
      [commentId]
    );
  }

  const { rows: [{ score }] } = await pool.query("SELECT score FROM comments WHERE id = $1", [commentId]);
  return { score: parseInt(score) };
}

function renderMarkdown(body: string): string { return body; /* use markdown-it */ }
function extractMentions(body: string): string[] {
  return [...body.matchAll(/@(\w+)/g)].map((m) => m[1]);
}
async function notifyMentions(usernames: string[], comment: any, entityType: string, entityId: string) {
  for (const username of usernames) {
    const { rows } = await pool.query("SELECT id FROM users WHERE username = $1", [username]);
    if (rows[0]) {
      await redis.rpush("notification:queue", JSON.stringify({
        userId: rows[0].id, type: "mention", data: { commentId: comment.id, entityType, entityId },
      }));
    }
  }
}

Results

  • Conversations are followable — nested threads keep replies organized under their parent; reading a discussion is natural instead of jumping between flat comments
  • Best answers rise to top — voting sorts the most helpful comments first; documentation gets crowd-sourced corrections and tips
  • Materialized path makes tree queries fast — loading all replies for a thread is one query with WHERE path LIKE '003.%'; no recursive CTEs needed
  • @mentions drive engagement — mentioned users get notified; response rate doubled; discussions stay active
  • Moderation at scale — hiding a comment also hides its entire subtree; spam is one click to clean up

Related use cases