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 — Data Retention & Redis Architecture
#Redis Database Layout
Redis runs with 16 databases. Each DB is reserved for a specific purpose to avoid key collisions.
| DB | Purpose | Key Pattern | TTL |
|---|---|---|---|
0 | Application cache | profile:{userId}, feed:{userId}:home | 5 min / 30 sec |
1 | Session store (optional, future) | session:{token} | 30 days |
2 | Rate limiting (ThrottlerGuard) | throttle:{ip}:{route} | Per window |
3 | BullMQ job queues | Managed by BullMQ | Per job config |
4 | Socket.io pub/sub adapter | Managed by @socket.io/redis-adapter | Transient |
5 | Leaderboard sorted sets | leaderboard:week, leaderboard:month, leaderboard:all | 1 hour |
#Cache Keys Reference
| Key | DB | TTL | Set By | Invalidated By |
|---|---|---|---|---|
profile:{userId} | 0 | 5 min | MembersService.findOne | MembersService.updateProfile |
matches:{userId} | 0 | 24 hours | EmbeddingProcessor | EmbeddingProcessor.generate |
feed:{userId}:home | 0 | 30 sec | FeedService.getHomeFeed | FeedService.createPost |
conversation:{id}:meta | 0 | 2 min | MessagingService | MessagingService.sendMessage |
leaderboard:all | 5 | 1 hour | Cron: refresh-leaderboard | Never (expires) |
leaderboard:week | 5 | 1 hour | Cron: refresh-leaderboard | Never (expires) |
presence:{userId} | 0 | 5 min (300s) | Gateway on connect + 60s heartbeat | Gateway on disconnect |
event:{id}:viewers | 0 | Live only | Gateway join_event | Event marked complete |
#Presence System
User connects WebSocket
→ SET presence:{userId} "online" EX 300
Client sends heartbeat ping every 60 seconds
→ EXPIRE presence:{userId} 300
User goes idle (no heartbeat after 5 min)
→ Key expires → user appears offline
User disconnects
→ DEL presence:{userId}
→ Broadcast presence_update: { userId, status: 'offline' }
Bulk presence check (e.g., load conversation participants)
→ MGET presence:uuid1 presence:uuid2 ...
→ null = offline, "online" = online, "away" = away
#Leaderboard (Redis Sorted Sets)
Key: leaderboard:all (all-time)
Key: leaderboard:week (current week, reset every Monday)
Key: leaderboard:month (current month, reset on 1st)
ZADD leaderboard:all {reputationScore} {userId}
ZREVRANGE leaderboard:all 0 19 WITHSCORES → Top 20 all-time
ZRANK leaderboard:all {userId} → User's rank
ReputationService updates these sets on every reputation_events INSERT.
The refresh-leaderboard cron job does a full recalculation hourly as a safety net.
#Redis Memory Configuration
maxmemory 256mb
maxmemory-policy allkeys-lru
LRU eviction means: when Redis reaches 256MB, it evicts the least recently used keys. This is safe because all critical data lives in PostgreSQL — Redis is a cache only.
#Data Retention Policies
##PostgreSQL
| Table | Retention | Policy |
|---|---|---|
users | Forever | Soft delete only (deleted_at). Hard delete on GDPR request |
member_profiles | Forever | Soft delete with user |
posts | Forever | Soft delete (deleted_at). Admin can hard-delete |
messages | 2 years | Soft delete on user request. cleanup:purge-deleted-messages hard-deletes after 30 days |
notifications | 90 days | cleanup job purges read notifications older than 90 days |
reputation_events | Forever | Append-only audit log — never delete |
moderation_logs | Forever | Legal compliance — never delete |
membership_applications | 3 years | After approval/rejection |
##Uploaded Files
| Location | Content | Retention |
|---|---|---|
uploads/avatars/ | Profile photos | Kept until user deletes or account closes |
uploads/posts/ | Post media | Kept until post is hard-deleted |
uploads/attachments/ | Message files | 1 year (cleaned by cron) |
uploads/recordings/ | Event replays | 1 year, then moved to cold storage |
uploads/resources/ | Library files | Kept until resource is removed |
#GDPR: Right to Erasure — Delete Account Flow
When a user requests account deletion (DELETE /api/v1/members/me/account):
1. Set users.status = 'deleted', users.deleted_at = NOW()
2. Anonymize PII:
- users.email = 'deleted_{uuid}@deleted.payday'
- users.phone = NULL
- users.password_hash = NULL
- users.refresh_tokens = []
3. Keep non-PII content (posts, reputation events) for community integrity
→ But unlink from real identity (author shown as "Deleted Member")
4. Delete: profile photo, cover photo, all uploaded files
5. Remove from: connections, conversation participants (soft), event attendees
6. Revoke: active Stripe subscriptions
7. Queue: GDPR deletion confirmation email to original address
8. Purge Redis: DEL profile:{userId}, matches:{userId}, presence:{userId}