HumanOnlyWeb

🍁 writer of code. drinker of coffee. human on the web.

Building a Commenting System [Part 5]: Moderation Without the Headaches

By HumanOnlyWebUpdated

Comments sections can attract spam, harassment, and bad-faith arguments. I wanted moderation tools that let me act quickly without destroying conversation context.

The Philosophy

Before writing code, I thought about what moderation should feel like:

For users:

  • Deleting your own comment should be easy
  • The action should feel immediate
  • Replies to deleted comments shouldn't disappear

For admins:

  • Quick access to common actions
  • Ability to hide without permanently deleting
  • See reported comments in one place
  • Block repeat offenders

For the conversation:

  • Preserve thread structure when possible
  • Make it clear something was removed
  • Don't leave orphaned replies

With that in mind, I built three levels of comment removal.

Three Levels of Removal

1. Soft Delete (User Action)

When a user deletes their own comment, I don't remove the row. I replace the content:

async softDelete(id: string): Promise<Comment | undefined> {
  const [deleted] = await this.db
    .update(comments)
    .set({
      isDeleted: 1,
      content: "[Comment deleted]",
      updatedAt: new Date(),
    })
    .where(eq(comments.id, id))
    .returning();

  return deleted;
}

The comment still exists. Replies still have a valid parent. Users see "Comment deleted" where the content was.

This matches how Reddit and most forums handle deletion. If someone posted something embarrassing and wants it gone, they can remove it without breaking the thread.

2. Hide (Admin Action)

Sometimes a comment is problematic but I'm not ready to delete it entirely. Maybe I want to review it later, or I'm not sure if it crosses the line.

Hidden comments are invisible to regular users but still exist in the database:

async hide(id: string): Promise<Comment | undefined> {
  const [hidden] = await this.db
    .update(comments)
    .set({
      isHidden: 1,
      updatedAt: new Date(),
    })
    .where(eq(comments.id, id))
    .returning();

  return hidden;
}

When fetching comments, I filter out hidden ones for non-admins:

const conditions = [
  eq(comments.postSlug, postSlug),
  isNull(comments.parentId),
];

// Only show hidden comments to admins
if (!isAdmin) {
  conditions.push(eq(comments.isHidden, 0));
}

This is reversible. If I hide something by mistake, I can unhide it:

async unhide(id: string): Promise<Comment | undefined> {
  const [unhidden] = await this.db
    .update(comments)
    .set({
      isHidden: 0,
      updatedAt: new Date(),
    })
    .where(eq(comments.id, id))
    .returning();

  return unhidden;
}

3. Hard Delete (Admin Only)

For obvious spam or content that should never have existed, hard delete removes the row entirely:

async hardDelete(id: string): Promise<void> {
  await this.db.delete(comments).where(eq(comments.id, id));
}

The onDelete: "cascade" on the reactions foreign key means associated reactions are cleaned up automatically.

I use this sparingly. Once a comment is hard-deleted, it's gone. No recovery, no audit trail.

The Report System

Users can report comments they find problematic. This creates a queue for me to review rather than requiring me to constantly monitor every thread.

The report schema:

export const reports = sqliteTable(
  "reports",
  {
    id: text("id").primaryKey().$defaultFn(() => randomId("reports")),
    commentId: text("comment_id")
      .notNull()
      .references(() => comments.id, { onDelete: "cascade" }),
    userId: text("user_id")
      .notNull()
      .references(() => users.id),
    reason: text("reason").notNull(),
    details: text("details"),
    status: text("status").default("pending").notNull(),
    ...timestamps,
  },
  (table) => [
    index("idx_reports_comment_id").on(table.commentId),
    index("idx_reports_status").on(table.status),
    uniqueIndex("idx_reports_unique").on(table.commentId, table.userId),
  ],
);

Design decisions:

Categorized reasons: Users pick from predefined options (spam, harassment, offensive, misinformation, other). This helps me prioritize and spot patterns.

Optional details: A free-text field for context, capped at 500 characters.

One report per user per comment: The unique index on (commentId, userId) prevents spam-reporting. If you've already reported a comment, you can't report it again.

Status tracking: Reports are "pending" by default, then "resolved" or "dismissed" after review.

Creating a report is straightforward:

async create(params: {
  commentId: string;
  userId: string;
  reason: ReportReason;
  details?: string;
}): Promise<Report> {
  const [report] = await this.db
    .insert(reports)
    .values({
      commentId: params.commentId,
      userId: params.userId,
      reason: params.reason,
      details: params.details,
    })
    .returning();

  return report;
}

The controller checks that users can't report their own comments:

if (comment.userId === userId) {
  throw Errors.badRequest("Cannot report your own comment");
}

User Blocking

Some users could be repeat offenders. Rather than playing "Tom & Jerry" with individual comments, I can block them:

async setBlocked(params: { userId: string; blocked: boolean }): Promise<User | undefined> {
  const [updated] = await this.db
    .update(users)
    .set({
      isBlocked: params.blocked ? 1 : 0,
    })
    .where(eq(users.id, params.userId))
    .returning();

  return updated;
}

Blocked users can't:

  • Create new comments
  • React to comments
  • Report comments

Their existing comments stay visible. I debated whether blocking should hide all their comments, but decided against it. If they've contributed legitimate content, I don't want to erase it. I can always manually hide specific comments if needed.

The check happens in controllers before any mutation:

const user = await this.userService.findById(userId);

if (user?.isBlocked) {
  throw Errors.forbidden("Your account has been blocked");
}

Per-Post Settings

I don't think every post needs comments. And some posts might need different rules. The post_settings table lets me configure each post:

export const postSettings = sqliteTable("post_settings", {
  id: text("id").primaryKey().$defaultFn(() => randomId("postSettings")),
  postSlug: text("post_slug").notNull().unique(),
  commentsEnabled: int("comments_enabled").default(1).notNull(),
  reactionsEnabled: int("reactions_enabled").default(1).notNull(),
  maxNestingDepth: int("max_nesting_depth").default(3).notNull(),
  ...timestamps,
});

commentsEnabled: Toggle comments on/off entirely. Could be useful for announcement posts, or posts where I just want to share information without discussion.

reactionsEnabled: Toggle reactions separately. Sometimes I want comments but not necessarily reactions.

maxNestingDepth: How deep replies can go. Default is 3, but I might increase it for posts that generate deep discussions.

If a post doesn't have settings, it gets the defaults. Settings are only created when an admin explicitly changes something.

The Admin Panel

I built a very minimal floating admin panel (if you've used Nuxt DevTools, then you get the idea) that appears only for admin users:

const { isAdmin } = useAuth();
const {
  isOpen,
  activeTab,
  postSettings,
  users,
  reports,
  toggle,
  updatePostSettings,
  blockUser,
  unblockUser,
  dismissReport,
  resolveReportAndHideComment,
  resolveReportAndDeleteComment,
} = useAdminPanel();

The panel has four tabs:

Post Settings: When viewing a blog post, shows toggles for comments and reactions. Changes take effect immediately.

Users: List of all users with block/unblock buttons. Shows their username, avatar, and admin/blocked status.

Reports: Queue of pending reports with context. Each report shows:

  • Who reported it and when
  • The reason they selected
  • The actual comment content
  • Actions: Dismiss, Hide Comment, Delete Comment

When I take action on a report, it's marked as resolved:

async resolveReportAndHideComment(reportId: string, commentId: string) {
  await $fetch(`/api/admin/comments/${commentId}/hide`, { method: "PUT" });
  await $fetch(`/api/admin/reports/${reportId}/status`, {
    method: "PUT",
    body: { status: "resolved" },
  });
}

Assets: Upload and manage images and other files. You can set a path prefix to organize files into folders, and copy the asset path for use in blog posts.

The panel is context-aware. If I'm on a blog post page, it shows that post's settings. If I'm elsewhere, the post settings tab is hidden.

My approach leans toward preservation:

  • Soft delete keeps the thread intact
  • Hide is reversible
  • Block stops future damage without erasing the past
  • Hard delete is a last resort

For a personal blog with moderate traffic, this feels right. If I were building a larger platform, I'd invest more in adding features like automation, audit trails, and (maybe) shadowbanning and user appeals,

Series Wrap-up

That's a wrap on this series! We covered:

  1. : Why build instead of using an off-the-shelf solution
  2. : Database schema for nested comments
  3. : Real-time updates with Server-Sent Events
  4. : Optimistic UI and the useComments composable
  5. Part 5: Moderation tools (you are here)

Is this the most perfect commenting system? Nope, but it works. Building it taught me a lot about real-time patterns, optimistic updates, and the many small decisions that go into something that seems simple on the surface.

If you've made it this far, thanks for reading!

Leave me a comment below to let me know what you think, and I'll be sure to tag you when I open source the code.

Happy coding, and happy holidays! 🎉

All Comments 0

Be the first to comment.