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.
NestJS Patterns — Reference for Copilot
This file contains canonical patterns used throughout the Payday codebase. Copilot should replicate these patterns exactly when generating new code.
#Standard Module Structure
Every NestJS module in this project follows this exact layout:
src/feature/
├── feature.module.ts
├── feature.controller.ts
├── feature.service.ts
├── entities/
│ └── feature.entity.ts
└── dto/
├── create-feature.dto.ts
├── update-feature.dto.ts
└── query-feature.dto.ts
#Entity Pattern
import {
Entity, Column, PrimaryGeneratedColumn,
CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
ManyToOne, JoinColumn, Index,
} from 'typeorm';
@Entity('feature_name') // Always snake_case
@Index(['userId', 'status']) // Composite indexes here
export class FeatureEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' }) // Always explicit name for snake_case
userId: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity; // ? = lazy load by default
@Column({ length: 255 })
title: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ type: 'jsonb', default: '{}' })
metadata: Record<string, any>;
@Column({ type: 'text', array: true, default: '{}' })
tags: string[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt?: Date;
}
#DTO Patterns
##Create DTO
import {
IsString, IsNotEmpty, IsOptional, IsUUID, IsEnum,
IsBoolean, IsArray, IsInt, MaxLength, MinLength, IsUrl,
ArrayMaxSize, ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateFeatureDto {
@ApiProperty({ example: 'My Feature Title', maxLength: 255 })
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@ApiPropertyOptional({ example: 'Optional description' })
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ isArray: true, type: String })
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(10)
tags?: string[];
@ApiPropertyOptional({ enum: ['draft', 'published'] })
@IsOptional()
@IsEnum(['draft', 'published'])
status?: 'draft' | 'published';
}
##Update DTO (using PartialType)
import { PartialType } from '@nestjs/swagger';
export class UpdateFeatureDto extends PartialType(CreateFeatureDto) {}
##Query DTO (with pagination)
import { PaginationDto } from '../../common/pipes/pagination.dto';
export class QueryFeatureDto extends PaginationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ['draft', 'published'] })
@IsOptional()
@IsEnum(['draft', 'published'])
status?: string;
}
#Controller Patterns
##Standard CRUD Controller
import {
Controller, Get, Post, Patch, Delete, Body, Param,
Query, UseGuards, HttpCode, HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { ParseUUIDPipe } from '@nestjs/common';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUserId } from '../common/decorators/roles.decorator';
@ApiTags('features')
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard)
@Controller('features')
export class FeatureController {
constructor(private readonly featureService: FeatureService) {}
@Post()
@ApiOperation({ summary: 'Create new feature' })
@ApiResponse({ status: 201 })
create(@CurrentUserId() userId: string, @Body() dto: CreateFeatureDto) {
return this.featureService.create(userId, dto);
}
@Get()
@ApiOperation({ summary: 'List features' })
findAll(@CurrentUserId() userId: string, @Query() query: QueryFeatureDto) {
return this.featureService.findAll(userId, query);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.featureService.findOne(id);
}
@Patch(':id')
update(
@CurrentUserId() userId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateFeatureDto,
) {
return this.featureService.update(userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@CurrentUserId() userId: string, @Param('id', ParseUUIDPipe) id: string) {
return this.featureService.remove(userId, id);
}
}
#Service Patterns
##Ownership Check Pattern
// Always verify the requester owns the resource before mutating
private async assertOwnership(entity: FeatureEntity, userId: string) {
if (entity.userId !== userId) {
throw new ForbiddenException('You do not have permission to modify this resource');
}
}
##Safe Find Pattern
// Always throw NotFoundException when entity not found
async findOneOrFail(id: string): Promise<FeatureEntity> {
const entity = await this.repo.findOne({ where: { id } });
if (!entity) {
throw new NotFoundException(`Feature with id ${id} not found`);
}
return entity;
}
##Paginated Query Pattern
async findAll(userId: string, query: QueryFeatureDto) {
const { page = 1, limit = 20, search, status } = query;
const qb = this.repo.createQueryBuilder('f')
.where('f.userId = :userId', { userId })
.andWhere('f.deletedAt IS NULL')
.orderBy('f.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit);
if (search) {
qb.andWhere(
'to_tsvector(\'english\', f.title || \' \' || COALESCE(f.description, \'\')) @@ plainto_tsquery(:search)',
{ search },
);
}
if (status) {
qb.andWhere('f.status = :status', { status });
}
const [data, total] = await qb.getManyAndCount();
return paginate(data, total, page, limit);
}
#Exception Types — When to Use Each
| Exception | HTTP Status | When to Use |
|---|---|---|
NotFoundException | 404 | Entity not found by ID |
BadRequestException | 400 | Invalid input that passes DTO validation but fails business rules |
UnauthorizedException | 401 | No/invalid JWT token |
ForbiddenException | 403 | Valid token but wrong role/tier/ownership |
ConflictException | 409 | Duplicate (email already exists, already applied, etc.) |
UnprocessableEntityException | 422 | Request valid but cannot be processed (e.g., event already started) |
InternalServerErrorException | 500 | Unexpected errors — catch and re-throw with context |
#ConfigService Pattern
// ✅ Always use ConfigService — never process.env directly in services
constructor(private readonly config: ConfigService) {}
// Access nested config (from configuration.ts)
const secret = this.config.get<string>('jwt.accessSecret');
const dbHost = this.config.get<string>('database.host');
// With default fallback
const ttl = this.config.get<number>('redis.cacheTtl', 300);
#BullMQ Job Pattern
// Enqueue a job
@InjectQueue('email') private emailQueue: Queue
await this.emailQueue.add(
'welcome', // Job name — matches @Process('welcome')
{ to: user.email, displayName: profile.displayName },
{
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 50,
}
);
// Process a job
@Processor('email')
export class EmailProcessor {
@Process('welcome')
async handleWelcome(job: Job<WelcomeEmailData>) {
try {
await this.mailService.sendWelcome(job.data);
} catch (err) {
this.logger.error(`Failed to send welcome email: ${err.message}`);
throw err; // Re-throw so BullMQ retries
}
}
}
#Redis Cache Pattern
// Inject cache manager
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
// Get with fallback
async getProfile(userId: string) {
const cacheKey = `profile:${userId}`;
const cached = await this.cacheManager.get<MemberProfileEntity>(cacheKey);
if (cached) return cached;
const profile = await this.profileRepo.findOne({ where: { userId } });
if (profile) {
await this.cacheManager.set(cacheKey, profile, 300000); // 5 min
}
return profile;
}
// Invalidate on update
async updateProfile(userId: string, dto: any) {
const updated = await this.profileRepo.save({ userId, ...dto });
await this.cacheManager.del(`profile:${userId}`);
return updated;
}