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.

Notifications Module — Spec

#What It Handles

The notifications module is the central dispatcher for all user-facing alerts. Every other module calls NotificationsService — it decides how and where to deliver.

Three delivery channels:

  1. In-app — saved to notifications table, delivered via Socket.io to user:{userId} room
  2. Email — queued to email BullMQ queue → Nodemailer → Mailhog (dev) / SMTP (prod)
  3. Push — reserved for mobile (Expo Push Notifications — future implementation)

#Module Files

src/notifications/
├── notifications.module.ts
├── notifications.service.ts
├── notifications.controller.ts
├── processors/
│   ├── email.processor.ts
│   └── notification.processor.ts
├── entities/
│   └── notification.entity.ts
├── dto/
│   └── update-preferences.dto.ts
└── templates/
    ├── welcome.hbs
    ├── verify-email.hbs
    ├── password-reset.hbs
    ├── notification.hbs
    └── event-confirmation.hbs

#NotificationsService API

@Injectable()
export class NotificationsService {

  // Called by any module when something happens
  async notify(params: {
    recipientId: string;
    type: NotificationType;
    title: string;
    body: string;
    data?: Record<string, any>;
    actionUrl?: string;
    senderId?: string;
    channels?: ('in_app' | 'email' | 'push')[];  // defaults to user preferences
  }): Promise<void>

  // Bulk notify (e.g., broadcast to all members)
  async notifyMany(userIds: string[], params: Omit<typeof above, 'recipientId'>): Promise<void>
}

##Usage from other modules

// In MessagingService, after saving a message:
await this.notificationsService.notify({
  recipientId: otherParticipantId,
  senderId: senderId,
  type: 'new_message',
  title: `${senderName} sent you a message`,
  body: message.content.substring(0, 100),
  actionUrl: `/messages/${conversationId}`,
  data: { conversationId, messageId: message.id },
});

// In MembersService, when connection accepted:
await this.notificationsService.notify({
  recipientId: requesterId,
  senderId: acceptorId,
  type: 'connection_accepted',
  title: `${acceptorName} accepted your connection request`,
  actionUrl: `/members/${acceptorSlug}`,
});

#Notification Flow

1. Module calls notificationsService.notify(params)
2. Check user's notification_preferences for this type
3. Queue BullMQ job: notifications:dispatch
4. NotificationProcessor runs:
   a. Save notification to notifications table
   b. Emit via Socket.io: server.to(`user:{id}`).emit('notification', payload)
   c. If email enabled in prefs: queue email:notification job
   d. If push enabled: queue push:send job (future)
5. EmailProcessor runs:
   a. Load notification email template (.hbs)
   b. Send via Nodemailer

#Email Templates

All email templates use Handlebars (.hbs) for variable interpolation.

##welcome.hbs

Sent on: successful registration Variables: {{ displayName }}, {{ verifyUrl }}, {{ appUrl }}

##verify-email.hbs

Sent on: email verify request Variables: {{ displayName }}, {{ verifyUrl }}

##password-reset.hbs

Sent on: forgot password request Variables: {{ displayName }}, {{ resetUrl }}, {{ expiresIn }}

##notification.hbs

Sent on: any notification with email delivery enabled Variables: {{ recipientName }}, {{ title }}, {{ body }}, {{ actionUrl }}, {{ appUrl }}

##event-confirmation.hbs

Sent on: event RSVP Variables: {{ displayName }}, {{ eventTitle }}, {{ startsAt }}, {{ timezone }}, {{ locationUrl }}


#Nodemailer Configuration

// In notifications.module.ts
NodemailerModule.forRootAsync({
  useFactory: (config: ConfigService) => ({
    transport: {
      host: config.get('mail.host'),
      port: config.get('mail.port'),
      auth: config.get('mail.user') ? {
        user: config.get('mail.user'),
        pass: config.get('mail.password'),
      } : undefined,
      // Mailhog doesn't use auth — only set auth in production
    },
    defaults: {
      from: `"${config.get('mail.fromName')}" <${config.get('mail.from')}>`,
    },
    template: {
      dir: join(__dirname, 'templates'),
      adapter: new HandlebarsAdapter(),
      options: { strict: true },
    },
  }),
  inject: [ConfigService],
})

#Notification Types

enum NotificationType {
  new_message          = 'new_message',
  connection_request   = 'connection_request',
  connection_accepted  = 'connection_accepted',
  post_reaction        = 'post_reaction',
  post_comment         = 'post_comment',
  new_opportunity      = 'new_opportunity',
  opportunity_application = 'opportunity_application',
  application_update   = 'application_update',
  event_reminder       = 'event_reminder',
  system_announcement  = 'system_announcement',
  membership_update    = 'membership_update',
  reputation_milestone = 'reputation_milestone',
}

#Preferences Default Values

New users get these defaults, stored in notification_preferences.preferences:

{
  "new_message":            { "in_app": true,  "email": true,  "push": true  },
  "connection_request":     { "in_app": true,  "email": true,  "push": true  },
  "connection_accepted":    { "in_app": true,  "email": false, "push": true  },
  "post_reaction":          { "in_app": true,  "email": false, "push": false },
  "post_comment":           { "in_app": true,  "email": false, "push": true  },
  "new_opportunity":        { "in_app": true,  "email": true,  "push": false },
  "opportunity_application":{ "in_app": true,  "email": true,  "push": true  },
  "application_update":     { "in_app": true,  "email": true,  "push": true  },
  "event_reminder":         { "in_app": true,  "email": true,  "push": true  },
  "system_announcement":    { "in_app": true,  "email": true,  "push": false },
  "membership_update":      { "in_app": true,  "email": true,  "push": true  },
  "reputation_milestone":   { "in_app": true,  "email": false, "push": false }
}
~ End of Document ~