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
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
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.
- 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
- 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
- 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 Name | Emitted By | Listened By | Payload |
|---|---|---|---|
message.created | MessagingService | GatewayModule, NotificationsModule | { message, conversationId, senderId, participantIds } |
post.created | FeedService | GatewayModule | { post, channelId, authorId } |
post.reacted | FeedService | GatewayModule | { postId, userId, reactionType, newCount, channelId } |
connection.accepted | MembersService | NotificationsModule, ReputationModule | { requesterId, acceptorId } |
opportunity.applied | OpportunitiesService | NotificationsModule, ReputationModule | { opportunityId, applicantId, posterId } |
opportunity.filled | OpportunitiesService | NotificationsModule, ReputationModule | { opportunityId, selectedApplicantId, posterId } |
event.rsvp | EventsService | NotificationsModule | { eventId, userId, eventTitle, startsAt } |
event.live | EventsService | GatewayModule, NotificationsModule | { eventId, streamUrl, attendeeIds } |
reputation.milestone | ReputationService | NotificationsModule | { userId, milestone, points, badgeAwarded? } |
member.approved | AdminService | NotificationsModule | { userId, email, displayName } |
subscription.changed | PaymentsService | MembersModule, NotificationsModule | { userId, oldTier, newTier, status } |
#BullMQ Queues & Jobs
##Queue: email
Redis DB 3. Processed by EmailProcessor.
| Job Name | Trigger | Template | Data |
|---|---|---|---|
welcome | Register | welcome.hbs | { to, displayName, verifyToken } |
verify-email | Resend verify | verify-email.hbs | { to, displayName, verifyUrl } |
password-reset | Forgot password | password-reset.hbs | { to, displayName, resetUrl, expiresIn } |
notification | NotificationsService | notification.hbs | { to, recipientName, title, body, actionUrl } |
event-confirmation | Event RSVP | event-confirmation.hbs | { to, displayName, eventTitle, startsAt, timezone, locationUrl } |
event-reminder | Scheduled 1h before | event-confirmation.hbs | Same as above + isReminder: true |
Job config: attempts: 3, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: 100
##Queue: notifications
Redis DB 3. Processed by NotificationProcessor.
| Job Name | Trigger | Action |
|---|---|---|
dispatch | notificationsService.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 Name | Trigger | Action |
|---|---|---|
generate | Profile created or updated | Build profile text → OpenAI API → pgvector UPDATE + invalidate Redis cache |
refresh-all | Weekly cron (every Monday 3am) | Queue generate for all active users, spread over 1 minute |
##Queue: media
Redis DB 3. Processed by MediaProcessor.
| Job Name | Trigger | Action |
|---|---|---|
process-image | File upload (avatar, cover, post image) | sharp: resize to max 1200px, compress, save to uploads/ |
process-video | Video post upload | Validate duration < 5min, generate thumbnail |
combine-hls | Event marked complete with RTMP recording | ffmpeg: HLS segments → MP4, save to uploads/recordings/ |
##Queue: cleanup
Redis DB 3. Processed by CleanupProcessor.
| Job Name | Schedule | Action |
|---|---|---|
expire-opportunities | Daily 2am | UPDATE opportunities SET status='expired' WHERE expires_at < NOW() AND status='open' |
archive-events | Daily 2am | UPDATE events SET status='completed' WHERE ends_at < NOW() AND status='live' |
purge-deleted-messages | Weekly | Hard-delete messages where deleted_at < NOW() - INTERVAL '30 days' |
refresh-leaderboard | Every hour | Recalculate 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