Skip to content
Return to Projects
Case Study

Payday Chat

A real-time, members-only network for online business founders.

The Problem

Founders needed a private, high-signal place to network, build teams, and scale revenue — not another noisy public feed. Payday Chat is invite-only and built around real-time collaboration.

My Role

Built solo with AI-assisted development — Flutter client, NestJS API, data model, real-time layer, and deployment.

Highlights

  • Real-time messaging with presence and live events over WebSockets
  • One NestJS API powering both a Flutter mobile app and a web client
  • PostgreSQL domain model with Redis for caching and pub/sub fan-out
  • NGINX gateway, Stripe payments, and OAuth authentication

Stack

FlutterDartNestJSPostgreSQLRedisNGINXStripe

Constraints

  • Invite-only product — auth and access control had to be correct from day one.
  • One backend, two clients (Flutter + web) — schema and contracts had to be shared, not duplicated.
  • Realtime is a feature, not a nice-to-have — presence and live events must survive reconnects.

System Architecture

Clients
Flutter Mobile App
Web App
Gateway
NGINX
API
NestJS REST
WebSocket Gateway
Data
PostgreSQL
Redis (cache + pub/sub)
External
Stripe
OAuth

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

Realtime transport

Chose

WebSockets with Redis pub/sub fan-out

Rejected

Long-polling or third-party realtime SaaS

Predictable latency, no per-message vendor cost, and Redis already in the stack for caching — one fewer moving part.

Mobile client framework

Chose

Flutter (single codebase for iOS + Android)

Rejected

Native Swift + Kotlin clients

Solo build — two native clients would have doubled the surface area and slowed iteration on the API.

API style

Chose

REST + dedicated WebSocket gateway

Rejected

GraphQL subscriptions

Simpler operational story, easier to cache at the NGINX layer, and the realtime channel stays an explicit, observable component.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
  2. 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
  3. 3Add structured event versioning from day one instead of retrofitting it once the schema started moving.

Technical Deep-Dive

Architecture, specifications, and implementation details.

Gateway Module — Socket.io WebSocket Spec

#Overview

The GatewayModule manages all real-time communication via Socket.io. It handles DM delivery, notifications, presence, and live event streaming. It shares the same port as the HTTP server (3000) — Nginx upgrades the connection.


#Module Files

src/gateway/
├── gateway.module.ts
├── app.gateway.ts         # Main @WebSocketGateway class
├── gateway.service.ts     # Helper to emit from other services
└── dto/
    ├── send-message.dto.ts
    └── join-room.dto.ts

#gateway.module.ts

@Module({
  imports: [
    AuthModule,           // To verify JWT on connection
    MessagingModule,
    NotificationsModule,
  ],
  providers: [AppGateway, GatewayService],
  exports: [GatewayService],  // Other services inject GatewayService to emit events
})
export class GatewayModule {}

#app.gateway.ts — Skeleton

@WebSocketGateway({
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(','),
    credentials: true,
  },
  namespace: '/',
  transports: ['websocket', 'polling'],  // polling as fallback
})
@Injectable()
export class AppGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer() server: Server;
  private readonly logger = new Logger(AppGateway.name);

  constructor(
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
    private readonly messagingService: MessagingService,
    private readonly notificationsService: NotificationsService,
    @Inject(REDIS_CLIENT) private readonly redis: Redis,
  ) {}

  afterInit(server: Server) {
    // Attach Redis adapter for multi-instance pub/sub
    server.adapter(createAdapter(pubClient, subClient));
    this.logger.log('WebSocket Gateway initialized');
  }

  async handleConnection(client: Socket) {
    try {
      // Extract and verify JWT from handshake
      const token = client.handshake.auth?.token
        || client.handshake.headers?.authorization?.replace('Bearer ', '');

      const payload = this.jwtService.verify(token, {
        secret: this.configService.get('jwt.accessSecret'),
      });

      client.data.userId = payload.sub;
      client.data.membershipTier = payload.tier;

      // Join personal room for direct notifications
      client.join(`user:${payload.sub}`);

      // Set presence: online
      await this.redis.setex(`presence:${payload.sub}`, 300, 'online');

      // Notify connections about presence
      this.server.emit('presence_update', { userId: payload.sub, status: 'online' });

      this.logger.log(`Client connected: ${payload.sub}`);
    } catch {
      client.disconnect(true);
    }
  }

  async handleDisconnect(client: Socket) {
    const userId = client.data.userId;
    if (userId) {
      await this.redis.del(`presence:${userId}`);
      this.server.emit('presence_update', { userId, status: 'offline' });
    }
  }
}

#Socket.io Rooms

Room NameWho JoinsEvents Received
user:{userId}User on connectnotification, presence_update
conversation:{id}On GET /conversations or subscribenew_message, message_read, typing_start, typing_stop
feed:{channelId}On entering a channelnew_post, post_reacted
event:{eventId}On RSVP or event viewevent_started, event_ended, attendee_count_update

#Client → Server Events

##send_message

// Client emits:
socket.emit('send_message', {
  conversationId: 'uuid',
  content: 'Hey, are you open to a deal?',
  replyToId: null,        // optional
});

// Server handler:
@SubscribeMessage('send_message')
async handleSendMessage(
  @ConnectedSocket() client: Socket,
  @MessageBody() data: SendMessageDto,
) {
  const message = await this.messagingService.createMessage(
    client.data.userId,
    data,
  );
  // Broadcast to all conversation participants
  this.server
    .to(`conversation:${data.conversationId}`)
    .emit('new_message', message);
}

##mark_read

socket.emit('mark_read', { conversationId: 'uuid', upToMessageId: 'uuid' });

##typing_start / typing_stop

socket.emit('typing_start', { conversationId: 'uuid' });
// Server broadcasts to conversation room (excluding sender)
// Auto-stops after 5 seconds if no typing_stop received

##join_conversation

socket.emit('join_conversation', { conversationId: 'uuid' });
// Server verifies user is participant, then: client.join(`conversation:{id}`)

##join_event

socket.emit('join_event', { eventId: 'uuid' });
// Server verifies RSVP, then: client.join(`event:{id}`)

##set_presence

socket.emit('set_presence', { status: 'away' });
// Updates Redis key + broadcasts to connections

#Server → Client Events

##new_message

// Emitted to: conversation:{conversationId}
{
  id: 'uuid',
  conversationId: 'uuid',
  sender: { id: 'uuid', displayName: 'Marcus Webb', avatarUrl: '...' },
  content: 'Hey, are you open to a deal?',
  fileUrl: null,
  replyTo: null,
  createdAt: '2025-03-17T14:23:00Z',
}

##message_read

// Emitted to: conversation:{conversationId}
{ conversationId: 'uuid', userId: 'uuid', readAt: '...' }

##typing_start / typing_stop

// Emitted to: conversation:{conversationId}
{ conversationId: 'uuid', userId: 'uuid', displayName: 'Marcus Webb' }

##notification

// Emitted to: user:{userId}
{
  id: 'uuid',
  type: 'new_message',
  title: 'Marcus Webb sent you a message',
  body: 'Hey, are you open to a deal?',
  actionUrl: '/messages/uuid',
  isRead: false,
  createdAt: '...',
}

##presence_update

// Emitted globally (or to connections only — optimize later)
{ userId: 'uuid', status: 'online' | 'away' | 'offline' }

##new_post

// Emitted to: feed:{channelId} or feed:home
{ post: PostObject, channelId: 'uuid' | null }

##post_reacted

// Emitted to: feed:{channelId} or feed:home
{ postId: 'uuid', reactionType: 'fire', newCount: 43 }

##event_started

// Emitted to: event:{eventId}
{ eventId: 'uuid', streamUrl: 'https://...', startedAt: '...' }

#GatewayService — Emit from Other Modules

Other services (NotificationsService, MessagingService, etc.) inject GatewayService to emit events without knowing about Socket.io internals.

@Injectable()
export class GatewayService {
  constructor(@Inject(forwardRef(() => AppGateway)) private gateway: AppGateway) {}

  emitToUser(userId: string, event: string, data: any) {
    this.gateway.server.to(`user:${userId}`).emit(event, data);
  }

  emitToConversation(conversationId: string, event: string, data: any) {
    this.gateway.server.to(`conversation:${conversationId}`).emit(event, data);
  }

  emitToEvent(eventId: string, event: string, data: any) {
    this.gateway.server.to(`event:${eventId}`).emit(event, data);
  }
}

Usage in NotificationsService:

// After saving notification to DB:
this.gatewayService.emitToUser(userId, 'notification', notificationData);

#Redis Adapter (Multi-Instance)

When running multiple API containers in production, Socket.io events must be broadcast across instances. The Redis adapter handles this via pub/sub.

import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));

Redis DB 4 is reserved for Socket.io pub/sub.


#Presence System

User connects         → SET presence:{userId} "online" EX 300
Heartbeat (60s)       → EXPIRE presence:{userId} 300
User goes away        → SET presence:{userId} "away"   EX 300
User disconnects      → DEL presence:{userId}

Check if online:      → GET presence:{userId}  (null = offline)
Bulk presence check:  → MGET presence:uuid1 presence:uuid2 ...

Clients send a heartbeat ping every 60 seconds to maintain presence. TTL of 300 seconds means users show offline 5 minutes after disconnect without heartbeat.

~ End of Document ~