A real-time chat platform with group rooms and direct messages, media sharing, voice recording, role-based access control, typing indicators, online presence, and read receipts.
| Layer | Technology | Purpose |
|---|---|---|
| API Server | Express.js + TypeScript | REST endpoints |
| WebSocket | ws library |
Real-time messaging |
| Database | PostgreSQL 16 + Prisma ORM | Persistent storage |
| Cache / Pub-Sub | Redis 7 (ioredis) | Presence, typing, cross-instance broadcast |
| Object Storage | MinIO (S3-compatible) | Media files |
| Auth | JWT (HS256) + bcrypt | Stateless authentication |
| Validation | Zod | Request schema validation |
| Frontend | Next.js + Tailwind CSS | Client UI |
| Infrastructure | Docker Compose | Local development |
# 1. Start infrastructure
docker compose up -d # Postgres, Redis, MinIO
# 2. Install backend dependencies
npm install
# 3. Run database migrations
npx prisma migrate dev
# 4. Start backend (port 3000)
npm run dev
# 5. In another terminal — start frontend (port 3001)
cd frontend && npm install && npm run devOpen http://localhost:3001 in your browser.
group-chat/
├── docker-compose.yml
├── Dockerfile
├── prisma/
│ └── schema.prisma # 7 models
├── src/
│ ├── index.ts # Server entry point
│ ├── config/
│ │ ├── index.ts # Environment config
│ │ ├── db.ts # Prisma client
│ │ ├── redis.ts # Redis + Redis subscriber clients
│ │ └── minio.ts # MinIO client + bucket init
│ ├── middleware/
│ │ ├── auth.ts # JWT verification
│ │ └── roleGuard.ts # Role-based access control
│ ├── routes/
│ │ ├── auth.ts # Signup, login, refresh, lookup
│ │ ├── rooms.ts # Room CRUD + message history
│ │ ├── members.ts # Add/remove/change role
│ │ ├── upload.ts # Presigned upload/download URLs
│ │ └── presence.ts # Online members query
│ ├── ws/
│ │ └── index.ts # WebSocket server + message routing
│ └── types/
│ └── index.ts
└── frontend/
└── src/
├── app/
│ ├── login/page.tsx
│ ├── signup/page.tsx
│ └── chat/
│ ├── page.tsx # Main chat page
│ └── components/
│ ├── sidebar.tsx # Room list
│ ├── chat-view.tsx # Messages + input
│ └── room-settings.tsx
└── lib/
├── api.ts # Fetch wrapper with auto-refresh
├── auth-context.tsx # Auth state provider
└── use-websocket.ts # WS hook with reconnect
users id, name, email, password, avatar_url, created_at
rooms id, name, type (direct|group), created_by, archived_at, created_at
room_members room_id, user_id, role (admin|member|viewer), joined_at
messages id, room_id, sender_id, type (text|media|system), content (jsonb), deleted_at, created_at
message_acks message_id, user_id, status (delivered|read), updated_at
reactions id, message_id, user_id, emoji, created_at
media_uploads id, room_id, uploader_id, object_key, mime_type, filename, size_bytes, created_at
Key indexes: messages(room_id, created_at) for paginated history.
Unique constraints: reactions(message_id, user_id, emoji) prevents duplicate reactions.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/signup |
No | Register. Body: {name, email, password} |
| POST | /auth/login |
No | Login. Body: {email, password} |
| POST | /auth/refresh |
No | Refresh tokens. Body: {refreshToken} |
| GET | /auth/me |
Yes | Get current user profile |
| GET | /auth/lookup?email=X |
Yes | Find user by email |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /rooms |
Yes | List user's rooms (with unread count, last message) |
| POST | /rooms |
Yes | Create room. Body: {name, type, memberIds?} |
| GET | /rooms/:id |
Yes | Room details with members |
| GET | /rooms/:id/messages |
Yes | Paginated history. Query: ?before=ISO&limit=N |
| Method | Path | Auth | Role |
|---|---|---|---|
| GET | /rooms/:id/members |
Yes | Any member |
| POST | /rooms/:id/members |
Yes | Admin only |
| PATCH | /rooms/:id/members/:uid |
Yes | Admin only |
| DELETE | /rooms/:id/members/:uid |
Yes | Admin only |
| Method | Path | Description |
|---|---|---|
| POST | /upload/presign |
Get presigned upload URL |
| GET | /upload/download/:key |
Get presigned download URL |
| GET | /rooms/:id/presence |
Online member IDs |
Connect: ws://localhost:3000/ws?token={JWT}
All messages are JSON envelopes:
{
"type": "text | media | typing | reaction | ack",
"room_id": "uuid",
"payload": { ... },
"timestamp": "ISO 8601"
}| Type | Persisted | Viewer Can Send | Description |
|---|---|---|---|
text |
Yes | No | Text message. Payload: {text} |
media |
Yes | No | File/image/audio. Payload: {object_key, mime_type, filename, size_bytes} |
typing |
No | No | Ephemeral typing indicator. Payload: {state: "start"|"stop"} |
reaction |
Yes | No | Toggle emoji reaction. Payload: {target_message_id, emoji} |
ack |
Yes | Yes | Read receipt. Payload: {target_message_id, status: "read"} |
system |
No | Server-only | Join/leave events. Payload: {event: "join"|"leave"} |
The system uses Redis pub/sub for cross-instance message fan-out. Two Redis clients are maintained: one for commands (redis) and one for subscriptions (redisSub).
Flow:
User A sends message via WebSocket
│
▼
Server validates membership + role
│
▼
Message persisted to PostgreSQL (messages table)
│
▼
redis.publish("room:{roomId}", JSON)
│
▼
redisSub receives on "room:{roomId}" channel
│
▼
broadcastToRoom() iterates all local WS connections
where meta.rooms.has(roomId), sends to each
Each WebSocket connection tracks which rooms it's subscribed to in a connectionMeta map. On connect, the server loads the user's room memberships and subscribes to all their room channels. If a room is created or joined after the WS connection is established, the subscription is added dynamically — either when the REST API creates the room/adds the member, or lazily on the first WS message to that room.
This design supports horizontal scaling: multiple server instances behind a load balancer all subscribe to the same Redis channels, so a message published by one instance reaches clients connected to all other instances.
Users sign up with email + password. Passwords are hashed with bcrypt (10 rounds). On login/signup, the server issues two JWTs:
- Access token (24h TTL): Used in
Authorization: Bearerheader for REST and as query param for WS - Refresh token (7d TTL): Contains
type: "refresh", used to get new token pairs
The frontend stores tokens in localStorage and auto-refreshes on 401 responses. The WS connection authenticates during the handshake via ?token= query param.
Every room member has a role: admin, member, or viewer.
| Permission | Admin | Member | Viewer |
|---|---|---|---|
| Read messages | Yes | Yes | Yes |
| Send messages | Yes | Yes | No |
| Upload media | Yes | Yes | No |
| React to messages | Yes | Yes | No |
| Send typing indicators | Yes | Yes | No |
| Send read receipts | Yes | Yes | Yes |
| Manage members | Yes | No | No |
| Change roles | Yes | No | No |
Enforcement happens at two levels:
- REST middleware (
requireRole): Checksroom_memberstable before allowing admin-only operations - WebSocket handler: Checks role on every incoming message; viewers get a
403error event
The frontend replaces the input area with a "no permission" banner for viewers.
Online status is tracked via Redis keys with TTL:
Key: user:{userId}:status
Value: "online"
TTL: 30 seconds
- On WS connect: Set key with 30s TTL
- Heartbeat (every 20s): Reset TTL to keep alive
- On disconnect: Delete key (if last connection for that user)
- On TTL expiry: User automatically goes offline
Multiple connections (tabs/devices) are supported — the user stays online as long as any connection exists. The GET /rooms/:id/presence endpoint pipelines Redis GET calls for all room members and returns online IDs.
Purely ephemeral — no database writes. When a user types:
- Client emits
typingwithstate: "start"on keystroke - Client debounces and emits
state: "stop"after 2s of inactivity - Server publishes directly to Redis
room:{roomId}channel - Other clients show "{name} is typing..." with a 3s auto-expire timeout
Binary data never transits the WebSocket. The flow uses presigned S3 URLs:
Client Server MinIO
│ │ │
├─POST /upload/presign───►│ │
│ {mimeType, filename, │ │
│ roomId} │ │
│ ├─validate role, mime──► │
│ ├─generate object key──► │
│ ├─presignedPutObject()──► │
│◄─{uploadUrl, objectKey}─┤ │
│ │ │
├─PUT uploadUrl──────────────────────────────────► │
│ (file body) │ │
│ │ │
├─WS: media message──────►│ │
│ {object_key, mime_type} │ │
│ ├─persist + broadcast──► │
│ │ │
│ (other clients) │ │
│◄─WS: media broadcast────┤ │
│ │ │
├─GET /upload/download/key►│ │
│ ├─presignedGetObject()──► │
│◄─{downloadUrl}──────────┤ │
│ │ │
├─GET downloadUrl────────────────────────────────► │
│◄─file data──────────────────────────────────────┤ │
Allowed types: JPEG, PNG, GIF, WebP, MP3, OGG, WebM, MP4 audio, PDF, Word docs. Presigned URLs expire after 5 minutes.
The frontend uses the browser's MediaRecorder API:
- User taps the mic button (appears when text input is empty)
- Browser requests microphone permission
- Recording starts with
audio/webm;codecs=opus(with fallback) - UI shows recording state: pulsing red dot, timer, cancel/send buttons
- On send: recording stops, blob is uploaded via the presigned URL flow
- A
mediaWS message is sent with the audio metadata - Recipients see an inline
<audio>player
When messages appear in a user's viewport, the frontend sends ack messages:
{"type": "ack", "room_id": "...", "payload": {"target_message_id": "...", "status": "read"}}The server upserts into message_acks and broadcasts to the room. The sender sees:
- Single checkmark (gray): Message sent, not yet read
- Double checkmark (blue): Read by at least one person
Unread counts per room are computed server-side by counting messages with no read ack from the requesting user.
The system supports two room types that share the same messaging infrastructure:
Group Rooms (2-500 members):
- Creator becomes admin, can invite/remove members, change roles
- Sidebar shows
#icon, member count, and settings gear - Members can be admin, member, or viewer
Direct Messages (exactly 2 members):
- Created via "+ Message" button — enter recipient's email
- Enforced uniqueness — if a DM already exists between two users, the existing room is opened instead of creating a duplicate
- Sidebar shows the other person's name and avatar initial
- Header shows the other person's name with online/offline presence status (polled every 15s)
- No settings gear or member management — it's just a 1:1 conversation
Both types share the same backend: WebSocket messaging, read receipts, typing indicators, reactions, and media uploads all work identically. The only differences are in the frontend UI and the creation validation logic.
Messages support cursor-based pagination (?before=timestamp&limit=N, max 100). Soft-deleted messages return as {deleted: true} tombstones.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgresql://postgres:postgres@localhost:5432/groupchat |
PostgreSQL connection |
REDIS_URL |
redis://localhost:6379 |
Redis connection |
PORT |
3000 |
Server port |
JWT_SECRET |
— | Secret for signing JWTs |
JWT_EXPIRES_IN |
24h |
Access token TTL |
JWT_REFRESH_EXPIRES_IN |
7d |
Refresh token TTL |
MINIO_ENDPOINT |
localhost |
MinIO host |
MINIO_PORT |
9000 |
MinIO S3 API port |
MINIO_ACCESS_KEY |
minioadmin |
MinIO access key |
MINIO_SECRET_KEY |
minioadmin |
MinIO secret key |
MINIO_BUCKET |
chat-uploads |
S3 bucket name |
MINIO_USE_SSL |
false |
TLS for MinIO |
| Service | Image | Port | Purpose |
|---|---|---|---|
| postgres | postgres:16-alpine |
5432 | Database |
| redis | redis:7-alpine |
6379 | Pub/sub + cache |
| minio | minio/minio:latest |
9000 (API), 9001 (console) | Object storage |
MinIO console: http://localhost:9001 (minioadmin/minioadmin)