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.

Naming Conventions

Consistent naming makes the codebase predictable and Copilot suggestions accurate. Follow these rules everywhere without exception.


#Files & Directories

TypeConventionExample
All fileskebab-case.type.tsmember-profile.entity.ts
Module folderkebab-case/src/members/
Entityfeature-name.entity.tsmember-profile.entity.ts
Servicefeature-name.service.tsmembers.service.ts
Controllerfeature-name.controller.tsmembers.controller.ts
Modulefeature-name.module.tsmembers.module.ts
DTOaction-feature.dto.tscreate-post.dto.ts
Guardguard-name.guard.tsjwt-auth.guard.ts
Strategystrategy-name.strategy.tsjwt.strategy.ts
Decoratordecorator-name.decorator.tscurrent-user.decorator.ts
Filterfilter-name.filter.tshttp-exception.filter.ts
Interceptorname.interceptor.tstransform.interceptor.ts
Processorqueue-name.processor.tsemail.processor.ts
Interfaceinterface-name.interface.tsjwt-payload.interface.ts
Testsame as source + .spec.tsmembers.service.spec.ts

#TypeScript Classes

TypeConventionExample
Class (all types)PascalCaseMembersService
InterfacePascalCase (no I prefix)JwtPayload
EnumPascalCaseMembershipTier
Type aliasPascalCaseAuthProvider
DecoratorPascalCase@CurrentUser()

#Variables & Methods

TypeConventionExample
VariablescamelCaseconst userId = ...
MethodscamelCaseasync findOneOrFail()
Constants (module-level)SCREAMING_SNAKEexport const REDIS_CLIENT = 'REDIS_CLIENT'
Private class propertiescamelCase (no _ prefix)private readonly repo

#Database

TypeConventionExample
Table namessnake_case (plural)member_profiles, conversation_participants
Column namessnake_casedisplay_name, created_at
Primary keyidalways UUID, always id
Foreign keys{table_singular}_iduser_id, conversation_id
Boolean columnsis_{adjective}is_verified, is_admin, is_featured
Timestamp columns{verb}_{preposition}_atcreated_at, updated_at, deleted_at, last_login_at
Enum columns{noun} (no _type unless ambiguous)status, tier — but post_type, event_type where needed
Junction tables{table1}_{table2} alphabeticallyconversation_participants, event_attendees
Indexesidx_{table}_{columns}idx_posts_author, idx_profiles_skills

#Redis Keys

Pattern: {resource}:{identifier}:{field?}

KeyExampleTTL
User profile cacheprofile:{userId}5 min
Match suggestionsmatches:{userId}24 hours
Presence statuspresence:{userId}5 min (300s) with heartbeat
Rate limit counterthrottle:{userId}:{route}Per window
Leaderboardleaderboard:week, leaderboard:month, leaderboard:all1 hour
Conversation cacheconversation:{convId}:messages2 min
Feed cachefeed:{userId}:home30 sec

#Socket.io Room Names

Pattern: {resource}:{identifier}

RoomMembersPurpose
user:{userId}Single userPersonal notifications
conversation:{convId}Conversation participantsMessaging
feed:{channelId}Channel subscribersLive feed updates
feed:homeAll connected membersGlobal feed updates
event:{eventId}RSVPed attendeesLive event + chat

#BullMQ Queue & Job Names

QueueJobs
emailwelcome, verify-email, password-reset, notification, event-confirmation, event-reminder
notificationsdispatch
embeddingsgenerate, refresh-all
mediaprocess-image, process-video, combine-hls
cleanupexpire-opportunities, archive-events, purge-deleted-messages

#API Routes

Pattern: /{resource} (plural, kebab-case for multi-word)

ResourceRoute Prefix
Auth/auth
Members/members
Feed/feed
Conversations/conversations
Opportunities/opportunities
Events/events
Resources/resources
Notifications/notifications
Payments/payments
Admin/admin
Health/health

Sub-resources follow REST conventions:

GET  /opportunities/:id/applications       List applications
POST /opportunities/:id/applications       Apply
PATCH /opportunities/:id/applications/:appId  Update status

#TypeORM Column Mapping

Always explicitly map TypeScript camelCase to database snake_case:

// ✅ Always explicit
@Column({ name: 'display_name', length: 100 })
displayName: string;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

// ❌ Never rely on automatic naming (it breaks cross-platform)
@Column()
displayName: string;

#Environment Variables

All environment variables: SCREAMING_SNAKE_CASE

Grouped by prefix:

  • DB_* — database
  • REDIS_* — Redis
  • JWT_* — JWT tokens
  • MAIL_* — email
  • STRIPE_* — payments
  • AWS_* — S3 storage
  • GOOGLE_* — Google OAuth
  • APPLE_* — Apple Sign-In
  • THROTTLE_* — rate limiting
  • BULL_* — job queue
  • SWAGGER_* — API docs
~ End of Document ~