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.
Auth Module — NestJS Spec
#Module Files
src/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
#auth.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, MemberProfileEntity]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
BullModule.registerQueue({ name: 'email' }),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, LocalStrategy, GoogleStrategy, AppleStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
#jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('jwt.accessSecret'),
});
}
async validate(payload: JwtPayload) {
// payload is already verified by Passport
// Return this as request.user in every protected route
return {
id: payload.sub,
email: payload.email,
membershipTier: payload.tier,
isAdmin: payload.isAdmin,
isModerator: payload.isModerator,
};
}
}
#google.strategy.ts
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(config: ConfigService) {
super({
clientID: config.get('GOOGLE_CLIENT_ID'),
clientSecret: config.get('GOOGLE_CLIENT_SECRET'),
// Must match exactly what's registered in Google Cloud Console
callbackURL: `${config.get('APP_URL')}/api/v1/auth/google/callback`,
scope: ['email', 'profile'],
});
}
async validate(accessToken: string, refreshToken: string, profile: Profile) {
return {
email: profile.emails[0].value,
displayName: profile.displayName,
avatarUrl: profile.photos[0]?.value,
providerId: profile.id,
provider: 'google',
};
}
}
Google Cloud Console setup:
- Create OAuth 2.0 Client ID (Web application type)
- Authorized redirect URI:
http://localhost/api/v1/auth/google/callback(dev) - Add
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETto.env
#apple.strategy.ts
Apple Sign-In is more complex — it uses a private key (PEM) to generate a client secret JWT.
@Injectable()
export class AppleStrategy extends PassportStrategy(Strategy, 'apple') {
constructor(config: ConfigService) {
super({
clientID: config.get('APPLE_CLIENT_ID'), // e.g., "com.yourapp.service"
teamID: config.get('APPLE_TEAM_ID'), // 10-char team ID from Apple Developer
keyID: config.get('APPLE_KEY_ID'), // Key ID from Apple Developer
privateKeyString: config.get('APPLE_PRIVATE_KEY'), // Contents of .p8 file
callbackURL: `${config.get('APP_URL')}/api/v1/auth/apple/callback`,
scope: ['name', 'email'],
passReqToCallback: false,
});
}
async validate(
accessToken: string,
refreshToken: string,
idToken: object,
profile: AppleProfile,
) {
// Apple only sends name on FIRST sign-in — store it immediately
return {
email: profile.email,
displayName: profile.name ? `${profile.name.firstName} ${profile.name.lastName}` : null,
providerId: profile.id,
provider: 'apple',
};
}
}
Apple Developer setup:
- Enable Sign In with Apple for your App ID
- Create a Service ID (e.g.,
com.yourapp.service) — this is theAPPLE_CLIENT_ID - Configure the Service ID with your domain and redirect URL
- Create a Sign In with Apple private key — download the
.p8file - Set
APPLE_PRIVATE_KEYto the full contents of the.p8file
#JWT Payload Shape
interface JwtPayload {
sub: string; // user.id (UUID)
email: string;
tier: 'starter' | 'pro' | 'elite';
isAdmin: boolean;
isModerator: boolean;
iat: number; // issued at (auto by JWT)
exp: number; // expiry (auto by JWT)
}
#Token Lifetimes
| Token | TTL | Storage |
|---|---|---|
| Access Token | 15 minutes | Memory (client-side JS, not localStorage) |
| Refresh Token | 30 days | HttpOnly cookie (prod) or secure storage (mobile) |
#Auth Flow Diagrams
##Email Login
Client → POST /auth/login { email, password }
→ LocalStrategy.validate() → bcrypt.compare()
→ AuthService.login()
→ generateTokens() → { accessToken (15m), refreshToken (30d) }
→ Store refreshToken hash in users.refresh_tokens[]
→ Response: { user, accessToken, refreshToken }
##Google OAuth
Client → GET /auth/google
→ Nginx → NestJS → Redirect to accounts.google.com
→ User consents
→ Google → GET /auth/google/callback?code=...
→ GoogleStrategy.validate() → { email, displayName, providerId }
→ AuthService.handleOAuthLogin('google', profile)
→ Find user by provider_id OR email
→ If not found: create user + profile
→ generateTokens()
→ Redirect to frontend with tokens
##Refresh Token Rotation
Client → POST /auth/refresh { refreshToken }
→ Verify JWT signature (refreshSecret)
→ Load user from DB
→ Compare token against stored bcrypt hashes
→ If valid: generate new token pair
→ Remove old hash, store new hash
→ Response: { accessToken, refreshToken }