Architecture - gabrielg2020/10000-beers GitHub Wiki

Architecture

This guide explains the system architecture, design patterns, and code organisation of the 10,000 Beers bot.

Table of Contents


System Overview

The 10,000 Beers bot is a TypeScript application that integrates WhatsApp, PostgreSQL, and optionally Google Gemini AI to track beer submissions.

High-Level Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  WhatsApp User  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ Sends image
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         WhatsApp Web (Puppeteer)        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚ Message event
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚          Message Handler                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Command routing or beer flow   β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
         β”‚              β”‚              β”‚
         β–Ό              β–Ό              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Beer Service β”‚ β”‚  User    β”‚ β”‚   Image      β”‚
β”‚              β”‚ β”‚ Service  β”‚ β”‚   Service    β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚              β”‚              β”‚
       β”‚              β–Ό              β”‚
       β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
       β”‚       β”‚  Prisma ORM β”‚       β”‚
       β”‚       β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜       β”‚
       β”‚              β”‚              β”‚
       β”‚              β–Ό              β”‚
       β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
       β”‚       β”‚ PostgreSQL  β”‚       β”‚
       β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚
       β”‚                             β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  AI Service  β”‚
              β”‚  (Optional)  β”‚
              β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
                     β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚Google Gemini β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Components

Component Technology Purpose
WhatsApp Client whatsapp-web.js, Puppeteer WhatsApp Web interface automation
Message Handler TypeScript Routes messages to commands or beer submission
Command Handler TypeScript Parses and executes bot commands
Beer Service TypeScript Orchestrates beer submission workflow
User Service TypeScript User creation and lookup
Image Service TypeScript Image validation, storage, hashing
AI Service TypeScript Optional Gemini classification
Statistics Service TypeScript Leaderboard calculations
Database PostgreSQL 16, Prisma ORM Persistent data storage
Logger Pino Structured logging

Message Flow

Beer Submission Flow

When a user sends an image to the WhatsApp group, the following 8-step process occurs:

1. WhatsApp event received
   └─> messageHandler.handleMessage()

2. Check message origin
   └─> Ignore if not from configured group

3. Check for commands
   └─> Route to commandHandler if command

4. Check for media
   └─> Ignore if no image attached

5. Download and validate
   β”œβ”€> Get contact info
   β”œβ”€> Download media
   └─> Validate JPEG format

6. Build submission request
   └─> Create BeerSubmissionRequest object

7. Submit beer
   β”œβ”€> beerService.submitBeer()
   β”‚   β”œβ”€> userService.findOrCreateUser()
   β”‚   β”œβ”€> imageService.processImage()
   β”‚   β”‚   β”œβ”€> Validate mimetype and size
   β”‚   β”‚   β”œβ”€> Save to disk
   β”‚   β”‚   └─> Calculate SHA256 hash
   β”‚   β”œβ”€> beerService.checkDuplicate()
   β”‚   β”œβ”€> aiService.classifyBeer() [if enabled]
   β”‚   β”œβ”€> Create beer record in DB
   β”‚   └─> Get total beer count
   └─> Return success message

8. Reply to user
   └─> message.reply() with confirmation

Command Execution Flow

1. User sends "!command args"
   └─> messageHandler.handleMessage()

2. Command detected
   └─> commandHandler.isCommand()

3. Parse command
   β”œβ”€> Extract command name
   └─> Extract arguments

4. Lookup command
   └─> commandRegistry.getCommand()

5. Check admin permissions
   └─> If adminOnly, verify sender in ADMIN_IDS

6. Execute command
   └─> command.execute(message, args)

7. Reply with result
   └─> message.reply() with command output

Service Layer

The application uses a service-oriented architecture with focused, single-responsibility services.

Beer Service

File: src/services/beerService.ts

Responsibilities:

  • Orchestrate beer submission workflow
  • Check for duplicate submissions
  • Remove beers (admin/undo)

Key methods:

class BeerService {
  async submitBeer(request: BeerSubmissionRequest): Promise<BeerSubmissionResult>
  async checkDuplicate(userId: string, imageHash: string): Promise<DuplicateCheckResult>
  async removeLastBeer(whatsappId: string, timeWindowMinutes?: number): Promise<RemovalResult>
}

8-step submission flow:

  1. Find or create user
  2. Process image (validate, store, hash)
  3. Check for duplicates
  4. AI validation (if enabled)
  5. Create beer record
  6. Get total beer count
  7. Build success message
  8. Return result

User Service

File: src/services/userService.ts

Responsibilities:

  • Create and update users
  • Lookup users by WhatsApp ID
  • Count beers (per-user and total)

Key methods:

class UserService {
  async findOrCreateUser(whatsappId: string, displayName: string): Promise<UserInfo>
  async getUserBeerCount(userId: string): Promise<number>
  async getTotalBeerCount(): Promise<number>
}

User creation logic:

  • Lookup by whatsappId
  • If not found, create new user
  • If found, update displayName if changed
  • Return user info with isNewUser flag

Image Service

File: src/services/imageService.ts

Responsibilities:

  • Download images from WhatsApp
  • Validate format (JPEG only) and size
  • Store images to disk
  • Calculate SHA256 hashes for duplicate detection
  • Delete images

Key methods:

class ImageService {
  async processImage(media: MessageMedia, userId: string): Promise<ImageProcessingResult>
  async deleteImage(imagePath: string): Promise<void>
  calculateHash(buffer: Buffer): string
}

Image processing steps:

  1. Validate mimetype (must be image/jpeg)
  2. Validate file size (≀ MAX_IMAGE_SIZE_MB)
  3. Generate unique filename: beer_<timestamp>_<random>.jpg
  4. Write to IMAGE_STORAGE_PATH
  5. Calculate SHA256 hash
  6. Return path and hash

AI Service

File: src/services/aiService.ts

Responsibilities:

  • Classify images using Google Gemini
  • Determine if image contains a beer
  • Detect beer type (can, bottle, draught)

Key methods:

class AiService {
  async classifyBeer(imagePath: string): Promise<AiClassificationResult>
}

Classification logic:

  • If AI_ENABLED=false, always returns valid with no beer type
  • If enabled, sends image to Gemini with system prompt
  • Gemini returns: isValid, beerType, confidence
  • If confidence >= AI_CONFIDENCE_THRESHOLD, accept classification
  • If confidence < threshold, set beerType = null but accept submission

Statistics Service

File: src/services/statisticsService.ts

Responsibilities:

  • Calculate leaderboard rankings
  • Aggregate beer counts by user

Key methods:

class StatisticsService {
  async getLeaderboard(limit?: number): Promise<LeaderboardEntry[]>
}

Leaderboard calculation:

  • Counts beers per user
  • Sorts by beer count (descending)
  • Returns top N users with rank, name, count

Data Model

Database Schema

The application uses PostgreSQL with Prisma ORM. Schema is defined in src/database/schema.prisma.

User Model

model User {
  id          String   @id @default(uuid()) @map("id")
  whatsappId  String   @unique @map("whatsapp_id")
  displayName String   @map("display_name")
  nickname    String?  @map("nickname")
  isActive    Boolean  @default(true) @map("is_active")
  createdAt   DateTime @default(now()) @map("created_at")

  beers Beer[]

  @@map("users")
}

Fields:

  • id - UUID primary key
  • whatsappId - Unique WhatsApp ID (e.g., [email protected])
  • displayName - User's WhatsApp display name (auto-updated)
  • nickname - Optional custom nickname (future feature)
  • isActive - Soft delete flag
  • createdAt - Account creation timestamp

Indexes:

  • Unique index on whatsappId

Beer Model

model Beer {
  id                       String    @id @default(uuid()) @map("id")
  userId                   String    @map("user_id")
  submittedAt              DateTime  @map("submitted_at")
  imagePath                String    @map("image_path")
  imageHash                String    @map("image_hash")
  beerType                 BeerType? @map("beer_type")
  classificationConfidence Float?    @map("classification_confidence")
  isVerified               Boolean   @default(false) @map("is_verified")
  notes                    String?   @map("notes")

  user User @relation(fields: [userId], references: [id])

  @@index([userId])
  @@index([submittedAt])
  @@index([imageHash])
  @@map("beers")
}

Fields:

  • id - UUID primary key
  • userId - Foreign key to User
  • submittedAt - When the beer was submitted (from WhatsApp message timestamp)
  • imagePath - File system path to stored image
  • imageHash - SHA256 hash for duplicate detection
  • beerType - Enum: can, bottle, draught, or null
  • classificationConfidence - AI confidence score (0.0-1.0)
  • isVerified - Manual verification flag (future feature)
  • notes - Admin notes (future feature)

Indexes:

  • userId - Fast user beer lookups
  • submittedAt - Chronological queries
  • imageHash - Duplicate detection

BeerType Enum

enum BeerType {
  can
  bottle
  draught
}

Database Relationships

User (1) ──── (N) Beer
   β”‚              β”‚
   β”‚              β”œβ”€> imagePath β†’ File system
   β”‚              └─> imageHash β†’ Duplicate check
   β”‚
   └─> whatsappId β†’ WhatsApp

Code Structure

Directory Layout

src/
β”œβ”€β”€ index.ts                    # Application entry point
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ index.ts               # Configuration loader
β”‚   β”œβ”€β”€ types.ts               # Config interfaces
β”‚   └── puppeteer.ts           # Puppeteer setup
β”œβ”€β”€ handlers/
β”‚   β”œβ”€β”€ messageHandler.ts      # WhatsApp message routing
β”‚   └── commandHandler.ts      # Command execution
β”œβ”€β”€ commands/
β”‚   β”œβ”€β”€ commandRegistry.ts     # Command registration system
β”‚   β”œβ”€β”€ types.ts               # Command interfaces
β”‚   β”œβ”€β”€ index.ts               # Command initialization
β”‚   β”œβ”€β”€ undoCommand.ts         # !undo implementation
β”‚   β”œβ”€β”€ leaderboardCommand.ts  # !leaderboard implementation
β”‚   └── removeLastCommand.ts   # !removeLast implementation
β”œβ”€β”€ services/
β”‚   β”œβ”€β”€ beerService.ts         # Beer submission orchestration
β”‚   β”œβ”€β”€ userService.ts         # User management
β”‚   β”œβ”€β”€ imageService.ts        # Image processing
β”‚   β”œβ”€β”€ aiService.ts           # AI classification
β”‚   └── statisticsService.ts   # Leaderboard calculations
β”œβ”€β”€ database/
β”‚   β”œβ”€β”€ schema.prisma          # Prisma schema
β”‚   β”œβ”€β”€ client.ts              # Prisma singleton
β”‚   └── index.ts               # Database exports
β”œβ”€β”€ types/
β”‚   β”œβ”€β”€ submission.ts          # Beer submission types
β”‚   β”œβ”€β”€ image.ts               # Image operation types
β”‚   β”œβ”€β”€ ai.ts                  # AI classification types
β”‚   └── statistics.ts          # Statistics types
β”œβ”€β”€ utils/
β”‚   β”œβ”€β”€ logger.ts              # Pino logger
β”‚   β”œβ”€β”€ imageValidation.ts     # Image validation
β”‚   β”œβ”€β”€ fileSystem.ts          # File operations
β”‚   └── chromeCleanup.ts       # Chromium cleanup
β”œβ”€β”€ whatsapp/
β”‚   └── client.ts              # WhatsApp client factory
└── system_instruction.md      # Gemini AI prompt

Module Organisation

Handlers - Entry points for external events

  • messageHandler - WhatsApp message events
  • commandHandler - Command parsing and dispatch

Commands - Individual command implementations

  • commandRegistry - Central command registry
  • undoCommand, leaderboardCommand, etc. - Command logic

Services - Business logic layer

  • Focused, single-responsibility services
  • No direct WhatsApp or database coupling
  • Testable with mocked dependencies

Database - Data persistence

  • Prisma schema and client
  • Query logging extension

Types - TypeScript definitions

  • Domain types (Beer, User, etc.)
  • Request/response types
  • Error types

Utils - Shared utilities

  • Logger, file system, validation
  • No business logic

Design Patterns

Singleton Pattern

Services are exported as singleton instances:

// src/services/beerService.ts
export class BeerService {
  // ...
}

export const beerService = new BeerService();

Usage:

import { beerService } from './services/beerService';

const result = await beerService.submitBeer(request);

Benefits:

  • Single shared instance across application
  • Simple dependency management
  • Easy to mock in tests

Command Pattern

Commands implement a common interface:

// src/commands/types.ts
export interface Command {
  name: string;
  aliases: string[];
  description: string;
  adminOnly: boolean;
  execute(message: Message, args: string[]): Promise<void>;
}

Registration:

// src/commands/index.ts
commandRegistry.register(new UndoCommand());
commandRegistry.register(new LeaderboardCommand());

Benefits:

  • Easy to add new commands
  • Consistent command structure
  • Centralised command management

Repository Pattern (via Prisma)

Prisma client acts as a repository:

// src/database/client.ts
export const prisma = new PrismaClient()
  .$extends(queryLoggingExtension);

Usage:

const beer = await prisma.beer.create({
  data: { userId, imagePath, imageHash }
});

Benefits:

  • Type-safe database queries
  • Automatic SQL generation
  • Migration management

Factory Pattern

WhatsApp client creation:

// src/whatsapp/client.ts
export function createWhatsAppClient(): Client {
  return new Client({
    authStrategy: new LocalAuth({ clientId: '10000-beers' }),
    puppeteer: puppeteerConfig,
  });
}

Error Handling

Custom Error Hierarchy

All custom errors extend Error and include:

  • message - Technical error message (logged)
  • code - Machine-readable error code
  • userMessage - Human-readable message (sent to WhatsApp)
export class BeerSubmissionError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly userMessage?: string,
  ) {
    super(message);
    this.name = 'BeerSubmissionError';
  }
}

Error types:

  • BeerSubmissionError - Beer submission failures
  • ImageServiceError - Image processing failures
  • FileSystemError - File I/O failures
  • AiServiceError - AI classification failures
  • CommandError - Command execution failures
  • ConfigValidationError - Configuration validation

Error Flow

1. Error occurs in service
   └─> Throw custom error with code and userMessage

2. Handler catches error
   β”œβ”€> Log technical details
   └─> Send userMessage to WhatsApp

3. User receives friendly error
   └─> "Doesn't look like a beer to me mate πŸ€”"

Example:

// Service throws custom error
throw new BeerSubmissionError(
  'AI rejected beer submission: low confidence',
  'AI_VALIDATION_FAILED',
  "Doesn't look like a beer to me mate πŸ€”",
);

// Handler catches and replies
catch (error) {
  if (error instanceof BeerSubmissionError) {
    if (error.userMessage) {
      await message.reply(error.userMessage);
    }
    logger.warn({ code: error.code }, 'Beer submission rejected');
  }
}

Configuration System

Centralised Configuration

All configuration is loaded and validated at startup:

// src/config/index.ts
export const config = loadConfig();

Configuration structure:

interface AppConfig {
  database: DatabaseConfig;
  whatsapp: WhatsAppConfig;
  storage: StorageConfig;
  bot: BotConfig;
  application: ApplicationConfig;
  ai: AiConfig;
}

Validation

Each config section has a validator function:

function validateDatabaseConfig(config: DatabaseConfig): void {
  if (!config.url.startsWith('postgresql://') &&
      !config.url.startsWith('postgres://')) {
    throw new ConfigValidationError(
      'Invalid database URL',
      ['database.url']
    );
  }
}

Startup behavior:

  • All config validated before application starts
  • Invalid config throws ConfigValidationError with field details
  • Application exits immediately on validation failure

Usage in Services

Services access config via the singleton:

import { config } from '../config';

class BeerService {
  constructor() {
    this.replyOnSubmission = config.bot.replyOnSubmission;
  }
}

Key Architectural Decisions

Why Singleton Services?

Decision: Export services as singleton instances

Rationale:

  • Simple dependency injection
  • Shared state (e.g., config) across application
  • Easy to mock in tests
  • No need for complex DI framework

Why Prisma ORM?

Decision: Use Prisma instead of raw SQL or other ORMs

Rationale:

  • Type-safe queries
  • Automatic migrations
  • Excellent TypeScript integration
  • Schema-first development
  • Built-in connection pooling

Why whatsapp-web.js?

Decision: Use whatsapp-web.js instead of official WhatsApp API

Rationale:

  • No official API for group chats
  • Free (no API costs)
  • Good TypeScript support
  • Active community
  • Session persistence

Why Separate Image Storage?

Decision: Store images on file system, not in database

Rationale:

  • Better performance (no BLOB queries)
  • Easier backups (file system snapshots)
  • Simpler image serving (direct file access)
  • Lower database size
  • Hash stored in DB for duplicate detection

Why SHA256 Hashing?

Decision: Use SHA256 for duplicate detection

Rationale:

  • Collision-resistant (duplicate detection reliable)
  • Fast to compute
  • Fixed size (64 hex characters)
  • Standard library support
  • Indexed in database for fast lookups

Performance Considerations

Database Indexes

Three indexes on Beer table:

  • userId - Fast user beer lookups (leaderboard, counts)
  • submittedAt - Chronological queries (recent submissions)
  • imageHash - Duplicate detection

Connection Pooling

Prisma handles connection pooling automatically:

  • Default pool size: based on CPU cores
  • Configurable via DATABASE_URL parameters

Image Storage

Images stored on file system:

  • No database BLOB queries
  • Direct file access
  • Consider cleanup strategy for old images (future)

AI Latency

When AI is enabled:

  • Adds ~1-3 seconds to submission flow
  • Consider disabling for faster submissions
  • Use gemini-1.5-flash for lower latency

Security Considerations

Input Validation

All inputs validated:

  • WhatsApp IDs (format validation)
  • Image mimetypes (JPEG only)
  • Image sizes (≀ MAX_IMAGE_SIZE_MB)
  • Database URL format
  • Environment variable types

SQL Injection

Prisma prevents SQL injection:

  • Parameterized queries
  • Type-safe query builder
  • No raw SQL (unless explicitly used)

File System Safety

File operations use safe paths:

  • No user-controlled file paths
  • Images stored in dedicated directory
  • Filenames generated server-side

Session Security

WhatsApp session:

  • Stored in .wwebjs_auth/
  • Should not be committed to version control
  • Protected via file system permissions

Next Steps

⚠️ **GitHub.com Fallback** ⚠️