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.

AI Match Engine — Specification

#What It Does

The match engine powers two features:

  1. "Who to Meet This Week" — weekly curated list of 5–10 suggested connections per member
  2. Real-time member suggestions — shown in the discovery sidebar while browsing

It uses vector similarity search (pgvector) to find members with complementary profiles.


#How It Works

1. Member profile text → OpenAI text-embedding-3-small → vector(1536)
2. Store vector in member_profiles.embedding
3. Match query: cosine similarity search via pgvector operator <=>
4. Filter: active, not connected, not blocked, different user
5. Cache results in Redis for 24h
6. Weekly BullMQ job refreshes all embeddings

#Embedding Generation

##What Text Gets Embedded

function buildProfileText(profile: MemberProfileEntity): string {
  return [
    profile.displayName,
    profile.tagline,
    profile.bio,
    profile.businessNiche,
    profile.skills?.join(', '),
    profile.industries?.join(', '),
    profile.revenueStage,
    profile.availabilityStatus,
  ]
    .filter(Boolean)
    .join('. ');
}
// Example output:
// "Marcus Webb. E-com operator | $500K/mo Shopify brand.
//  Been building Shopify brands since 2019. E-commerce.
//  Paid Ads, Shopify, Team Building, Brand Strategy.
//  1m_plus. open_to_deals."

##OpenAI API Call

async generateEmbedding(text: string): Promise<number[]> {
  const response = await this.openai.embeddings.create({
    model: 'text-embedding-3-small',  // 1536 dimensions, cheap ($0.02/1M tokens)
    input: text,
  });
  return response.data[0].embedding;
}

Cost estimate: ~500 tokens per profile → 10,000 members = ~5M tokens = $0.10 per full refresh


#Storing the Embedding

// TypeORM entity column
@Column({ type: 'vector', length: 1536, nullable: true })
embedding: number[];

// TypeORM cannot handle vector type natively — use raw query for updates:
await this.dataSource.query(
  `UPDATE member_profiles SET embedding = $1::vector, embedding_updated_at = NOW() WHERE user_id = $2`,
  [`[${embedding.join(',')}]`, userId],
);

#Match Query

async getMatchesForUser(
  userId: string,
  limit = 10,
): Promise<MemberProfileEntity[]> {
  // Check Redis cache first
  const cacheKey = `matches:${userId}`;
  const cached = await this.redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Get the requesting user's embedding
  const [userProfile] = await this.dataSource.query(
    `SELECT embedding FROM member_profiles WHERE user_id = $1`,
    [userId],
  );
  if (!userProfile?.embedding) return [];

  // Find similar profiles using cosine distance
  // Exclude: self, already connected, blocked users, inactive users
  const matches = await this.dataSource.query(
    `
    SELECT
      mp.*,
      u.membership_tier,
      u.status,
      (mp.embedding <=> $1::vector) AS distance
    FROM member_profiles mp
    JOIN users u ON u.id = mp.user_id
    WHERE
      mp.user_id != $2
      AND u.status = 'active'
      AND mp.embedding IS NOT NULL
      AND mp.user_id NOT IN (
        SELECT CASE
          WHEN requester_id = $2 THEN addressee_id
          ELSE requester_id
        END
        FROM member_connections
        WHERE (requester_id = $2 OR addressee_id = $2)
          AND status IN ('accepted', 'blocked')
      )
    ORDER BY mp.embedding <=> $1::vector
    LIMIT $3
    `,
    [userProfile.embedding, userId, limit],
  );

  // Cache for 24 hours
  await this.redis.setex(cacheKey, 86400, JSON.stringify(matches));

  return matches;
}

#BullMQ Job: Embedding Refresh

##Trigger: New/Updated Profile

// In MembersService.updateProfile():
await this.embeddingsQueue.add('generate', { userId }, {
  delay: 5000,         // Wait 5s in case of rapid updates
  jobId: `embed:${userId}`,  // Deduplicates — if job exists, skip
});

##Trigger: Weekly Full Refresh (Scheduled)

// In ReputationModule or a dedicated SchedulerService:
@Cron(CronExpression.EVERY_WEEK)
async refreshAllEmbeddings() {
  const activeUsers = await this.userRepo.find({
    where: { status: 'active' },
    select: ['id'],
  });

  for (const user of activeUsers) {
    await this.embeddingsQueue.add('generate', { userId: user.id }, {
      delay: Math.random() * 60000,  // Spread load over 1 minute
    });
  }
}

##EmbeddingProcessor

@Processor('embeddings')
export class EmbeddingProcessor {
  @Process('generate')
  async handleGenerate(job: Job<{ userId: string }>) {
    const { userId } = job.data;

    // Load profile
    const profile = await this.profileRepo.findOne({ where: { userId } });
    if (!profile) return;

    // Build text and generate embedding
    const text = buildProfileText(profile);
    const embedding = await this.openaiService.generateEmbedding(text);

    // Save to DB
    await this.dataSource.query(
      `UPDATE member_profiles SET embedding = $1::vector, embedding_updated_at = NOW() WHERE user_id = $2`,
      [`[${embedding.join(',')}]`, userId],
    );

    // Invalidate match cache for this user
    await this.redis.del(`matches:${userId}`);

    this.logger.log(`Embedding updated for user ${userId}`);
  }
}

#pgvector Index

-- Created in 01-init.sql
-- ivfflat is faster than exact search for large datasets
-- lists=100 is good for up to ~1M rows
CREATE INDEX idx_profiles_embedding
ON member_profiles
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- For exact search (slower but more accurate, use at <10K rows):
-- USING hnsw (embedding vector_cosine_ops)

When to switch from ivfflat to hnsw: at 10K+ active members.


#Environment Variables Required

OPENAI_API_KEY=sk-...    # Required for AI features
                          # Leave blank to disable embeddings (safe in dev)

If OPENAI_API_KEY is not set, the EmbeddingProcessor should log a warning and skip gracefully. The /members/suggested endpoint falls back to a simple query (random active members with similar skills tags).


#Fallback (No OpenAI Key)

async getMatchesForUser(userId: string, limit = 10) {
  if (!this.configService.get('openai.apiKey')) {
    // Skill-overlap fallback
    const userProfile = await this.profileRepo.findOne({ where: { userId } });
    return this.profileRepo
      .createQueryBuilder('p')
      .where('p.userId != :userId', { userId })
      .andWhere('p.skills && :skills', { skills: userProfile.skills })
      .orderBy('RANDOM()')
      .limit(limit)
      .getMany();
  }
  // ... full AI match logic
}
~ End of Document ~