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.

Payday — Domain Events & Module Reference

This document maps every domain event, BullMQ job, Socket.io event, and NestJS EventEmitter event in the system. Use it as the wiring diagram when coding inter-module communication.


#NestJS EventEmitter Events (In-Process)

These fire synchronously within the same NestJS process. Listeners in other modules react without HTTP calls.

Event NameEmitted ByListened ByPayload
message.createdMessagingServiceGatewayModule, NotificationsModule{ message, conversationId, senderId, participantIds }
post.createdFeedServiceGatewayModule{ post, channelId, authorId }
post.reactedFeedServiceGatewayModule{ postId, userId, reactionType, newCount, channelId }
connection.acceptedMembersServiceNotificationsModule, ReputationModule{ requesterId, acceptorId }
opportunity.appliedOpportunitiesServiceNotificationsModule, ReputationModule{ opportunityId, applicantId, posterId }
opportunity.filledOpportunitiesServiceNotificationsModule, ReputationModule{ opportunityId, selectedApplicantId, posterId }
event.rsvpEventsServiceNotificationsModule{ eventId, userId, eventTitle, startsAt }
event.liveEventsServiceGatewayModule, NotificationsModule{ eventId, streamUrl, attendeeIds }
reputation.milestoneReputationServiceNotificationsModule{ userId, milestone, points, badgeAwarded? }
member.approvedAdminServiceNotificationsModule{ userId, email, displayName }
subscription.changedPaymentsServiceMembersModule, NotificationsModule{ userId, oldTier, newTier, status }

#BullMQ Queues & Jobs

##Queue: email

Redis DB 3. Processed by EmailProcessor.

Job NameTriggerTemplateData
welcomeRegisterwelcome.hbs{ to, displayName, verifyToken }
verify-emailResend verifyverify-email.hbs{ to, displayName, verifyUrl }
password-resetForgot passwordpassword-reset.hbs{ to, displayName, resetUrl, expiresIn }
notificationNotificationsServicenotification.hbs{ to, recipientName, title, body, actionUrl }
event-confirmationEvent RSVPevent-confirmation.hbs{ to, displayName, eventTitle, startsAt, timezone, locationUrl }
event-reminderScheduled 1h beforeevent-confirmation.hbsSame as above + isReminder: true

Job config: attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: 100


##Queue: notifications

Redis DB 3. Processed by NotificationProcessor.

Job NameTriggerAction
dispatchnotificationsService.notify()1. Save to notifications table 2. Socket.io emit to user:{id} 3. Queue email:notification if pref enabled

##Queue: embeddings

Redis DB 3. Processed by EmbeddingProcessor.

Job NameTriggerAction
generateProfile created or updatedBuild profile text → OpenAI API → pgvector UPDATE + invalidate Redis cache
refresh-allWeekly cron (every Monday 3am)Queue generate for all active users, spread over 1 minute

##Queue: media

Redis DB 3. Processed by MediaProcessor.

Job NameTriggerAction
process-imageFile upload (avatar, cover, post image)sharp: resize to max 1200px, compress, save to uploads/
process-videoVideo post uploadValidate duration < 5min, generate thumbnail
combine-hlsEvent marked complete with RTMP recordingffmpeg: HLS segments → MP4, save to uploads/recordings/

##Queue: cleanup

Redis DB 3. Processed by CleanupProcessor.

Job NameScheduleAction
expire-opportunitiesDaily 2amUPDATE opportunities SET status='expired' WHERE expires_at < NOW() AND status='open'
archive-eventsDaily 2amUPDATE events SET status='completed' WHERE ends_at < NOW() AND status='live'
purge-deleted-messagesWeeklyHard-delete messages where deleted_at < NOW() - INTERVAL '30 days'
refresh-leaderboardEvery hourRecalculate leaderboard sorted sets in Redis

#Module Dependency Map

GatewayModule
  imports: AuthModule (JWT verify), MessagingModule, NotificationsModule
  exports: GatewayService (other modules inject to emit WS events)

NotificationsModule
  imports: BullMQ 'email' + 'notifications' queues
  exports: NotificationsService (injected by every other module)

MessagingModule
  imports: NotificationsModule, GatewayModule
  exports: MessagingService

FeedModule
  imports: MembersModule (author data), NotificationsModule

AuthModule
  imports: MembersModule (UserEntity, MemberProfileEntity), JwtModule, PassportModule
  exports: AuthService, JwtModule

MembersModule
  exports: MembersService (injected by Feed, Opportunities, Events, Admin, Auth)

PaymentsModule
  imports: MembersModule (update tier), NotificationsModule

ReputationModule
  imports: MembersModule
  listens to: EventEmitter events from all modules

AdminModule
  imports: MembersModule, NotificationsModule

#File Structure: src/

src/
├── main.ts
├── app.module.ts
├── app.controller.ts
├── auth/
│   ├── auth.module.ts
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   ├── strategies/
│   │   ├── jwt.strategy.ts
│   │   ├── local.strategy.ts
│   │   ├── google.strategy.ts
│   │   └── apple.strategy.ts
│   └── dto/
│       ├── register.dto.ts
│       ├── login.dto.ts
│       ├── refresh-token.dto.ts
│       ├── forgot-password.dto.ts
│       └── reset-password.dto.ts
├── members/
│   ├── members.module.ts
│   ├── members.controller.ts
│   ├── members.service.ts
│   ├── entities/
│   │   ├── user.entity.ts
│   │   ├── member-profile.entity.ts
│   │   ├── member-connection.entity.ts
│   │   └── member-badge.entity.ts
│   └── dto/
│       ├── update-profile.dto.ts
│       ├── query-members.dto.ts
│       └── connect-member.dto.ts
├── feed/
│   ├── feed.module.ts
│   ├── feed.controller.ts
│   ├── feed.service.ts
│   ├── entities/
│   │   ├── post.entity.ts
│   │   ├── post-reaction.entity.ts
│   │   ├── saved-post.entity.ts
│   │   └── channel.entity.ts
│   └── dto/
│       ├── create-post.dto.ts
│       ├── update-post.dto.ts
│       └── query-feed.dto.ts
├── messaging/
│   ├── messaging.module.ts
│   ├── messaging.controller.ts
│   ├── messaging.service.ts
│   ├── entities/
│   │   ├── conversation.entity.ts
│   │   ├── conversation-participant.entity.ts
│   │   └── message.entity.ts
│   └── dto/
│       ├── create-conversation.dto.ts
│       └── send-message.dto.ts
├── opportunities/
│   ├── opportunities.module.ts
│   ├── opportunities.controller.ts
│   ├── opportunities.service.ts
│   ├── entities/
│   │   ├── opportunity.entity.ts
│   │   └── opportunity-application.entity.ts
│   └── dto/
│       ├── create-opportunity.dto.ts
│       ├── apply-opportunity.dto.ts
│       └── query-opportunities.dto.ts
├── events/
│   ├── events.module.ts
│   ├── events.controller.ts
│   ├── events.service.ts
│   ├── entities/
│   │   ├── event.entity.ts
│   │   └── event-attendee.entity.ts
│   └── dto/
│       ├── create-event.dto.ts
│       └── query-events.dto.ts
├── resources/
│   ├── resources.module.ts
│   ├── resources.controller.ts
│   ├── resources.service.ts
│   ├── entities/
│   │   ├── resource.entity.ts
│   │   └── resource-rating.entity.ts
│   └── dto/
│       ├── create-resource.dto.ts
│       └── rate-resource.dto.ts
├── notifications/
│   ├── notifications.module.ts
│   ├── notifications.controller.ts
│   ├── notifications.service.ts
│   ├── processors/
│   │   ├── email.processor.ts
│   │   └── notification.processor.ts
│   ├── entities/
│   │   ├── notification.entity.ts
│   │   └── notification-preference.entity.ts
│   ├── dto/
│   │   └── update-preferences.dto.ts
│   └── templates/
│       ├── welcome.hbs
│       ├── verify-email.hbs
│       ├── password-reset.hbs
│       ├── notification.hbs
│       └── event-confirmation.hbs
├── reputation/
│   ├── reputation.module.ts
│   ├── reputation.service.ts
│   └── entities/
│       └── reputation-event.entity.ts
├── payments/
│   ├── payments.module.ts
│   ├── payments.controller.ts
│   ├── payments.webhook.controller.ts
│   ├── payments.service.ts
│   └── entities/
│       └── subscription.entity.ts
├── admin/
│   ├── admin.module.ts
│   ├── admin.controller.ts
│   ├── admin.service.ts
│   └── entities/
│       ├── moderation-log.entity.ts
│       └── membership-application.entity.ts
├── gateway/
│   ├── gateway.module.ts
│   ├── app.gateway.ts
│   ├── gateway.service.ts
│   └── dto/
│       ├── send-message.dto.ts
│       └── join-room.dto.ts
├── health/
│   └── health.module.ts
├── common/
│   ├── decorators/
│   │   ├── public.decorator.ts
│   │   ├── roles.decorator.ts
│   │   └── tiers.decorator.ts
│   ├── filters/
│   │   └── http-exception.filter.ts
│   ├── guards/
│   │   ├── jwt-auth.guard.ts
│   │   ├── roles.guard.ts
│   │   └── tiers.guard.ts
│   ├── interceptors/
│   │   ├── transform.interceptor.ts
│   │   └── logging.interceptor.ts
│   └── pipes/
│       └── pagination.dto.ts
├── config/
│   ├── configuration.ts
│   └── validation.ts
├── database/
│   └── database.module.ts
└── redis/
    └── redis.module.ts
~ End of Document ~