ProgTalk
A full-stack discussion forum with real-time chat, hierarchical topics, and a multi-tier role system - built entirely solo.

Overview
Every programmer knows the feeling - you dig through a forum, the thread is three pages deep, the last relevant answer was posted in 2019, and there is no way to quickly ask a follow-up. ProgTalk is an attempt to build something better: a focused, community-driven discussion platform built specifically for technical conversation.
The project covers the full lifecycle of a modern web app - REST API design, database modelling, WebSocket infrastructure, authentication and authorisation, automated email flows, admin tooling, and a reactive SPA frontend - all deployed via Docker on a single machine.
The Architecture & Tech Stack
The stack was chosen deliberately, not randomly.
Backend
- Node.js + Express 5 - async-first, minimal footprint, and a great fit for an I/O-heavy forum workload
- MongoDB + Mongoose - document model maps naturally to nested topics, and flexible schemas made iterating on features fast
- Socket.IO 4 - battle-tested WebSocket abstraction with built-in rooms, namespaces, and fallback transport
- Passport.js + JWT - stateless auth scales horizontally without sticky sessions
- Helmet + express-rate-limit - security hardening applied at the framework level, not as an afterthought
- Nodemailer - transactional emails for account activation and notifications
- Swagger UI - auto-generated API docs served alongside the app in development
Frontend
- Vue 3 (Composition API) -
<script setup>and composables kept the component code clean and testable - Pinia - ergonomic state management that replaced what would otherwise be a tangle of prop-drilling
- PrimeVue 4 + PrimeFlex - a comprehensive component library that allowed fast delivery without sacrificing polish
- highlight.js - syntax-highlighted code blocks rendered client-side
Infrastructure
- Docker Compose - single-command local and production parity; the backend serves the compiled Vue SPA as static assets so there is only one container to expose
- Nginx - reverse proxy handling SSL termination and static file caching
Key Features
- Hierarchical Topic Tree - topics can have subtopics (with
parentandancestorsfields), enabling subreddit-style nesting without recursive queries thanks to the materialised path pattern - Threaded Posts - posts support
replyToreferences, likes, and soft-delete so moderation history is preserved - Real-Time Private Chat - users can open private conversations; messages are delivered instantly via Socket.IO rooms and persisted in MongoDB
- Live Notifications - WebSocket events push new replies, moderator application updates, and admin alerts without polling
- Role-Based Access Control - three distinct roles (
user,moderator,admin) with fine-grained permission checks via a dedicatedauthorizationService - Moderator Application System - users can formally apply to moderate a topic; applications are reviewed through a dedicated admin/moderator UI
- Topic-Level User Blocking - moderators can block specific users from a topic while allowing access to subtopics
- Admin Panel - real-time statistics, system logs with filters, user management (block/unblock/promote), and bulk tag management
- Email Account Activation - new accounts are inactive until the user clicks a verification link, reducing spam
- Syntax Highlighting - inline and block code in posts is automatically highlighted with highlight.js
- Fully Documented API - Swagger UI available at
/api-docscovering almost every endpoint
The Challenge & Solution
The Problem: Securing a WebSocket Layer
Building the REST API was straightforward - Express middleware handles JWT verification on every HTTP request. But Socket.IO connections are long-lived: authentication happens once at handshake time, yet the connection is used for private chat, admin rooms, and per-topic presence - all of which have different permissions.
The naive approach would be to trust the client about who they are. That was a non-starter.
The Solution: JWT Middleware at the Socket Layer
Socket.IO exposes a middleware API (io.use(...)) that runs before any connection is accepted. I wrote an authentication middleware that:
- Extracts the JWT from
socket.handshake.auth.token - Verifies and decodes it synchronously using
jsonwebtoken - Fetches the current user record from MongoDB to validate their role live (catching edge cases like a blocked user who still holds a valid token)
- Attaches
socket.user(_id,role,username) for use in all subsequent event handlers
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id).select("+role");
if (!user) return next(new Error("User not found"));
socket.user = { _id: user._id, role: user.role, username: user.username };
next();
});
From there, connections are automatically placed into typed rooms (user_<id>, topic_<id>, admins) on connect, so targeted events - a new chat message, a moderation alert, a topic notification - can be emitted to exactly the right audience with a single io.to(room).emit(...) call.
This pattern meant that the socket layer had the same security guarantees as the REST layer, with no duplication of business logic.
Lessons Learned
Building ProgTalk end-to-end solo was a genuinely demanding exercise. A few things stuck:
-
Model your data for your queries, not your intuitions. The materialised path pattern for topic ancestry (
ancestors: [ObjectId]) was a late-stage refactor, but it eliminated a class of recursive lookup problems and made ancestor-aware queries trivially indexable. -
Separate concerns at the service layer early. Extracting authorisation logic into
authorizationService.jsand notification dispatch intonotificationService.jsbefore they grew meant that controllers stayed readable even as the feature set expanded. Adding moderator application logic on top of existing permission checks was a one-liner -await authService.canManageTopic(...). -
Docker parity eliminates entire categories of bugs. Running the exact same container configuration locally and in production means "works on my machine" effectively stops being a valid failure mode.
-
Swagger is not optional. An always-up-to-date API reference, served by the app itself, saved enormous debugging time and would be invaluable for onboarding a second developer.