Development Guide - gabrielg2020/10000-beers GitHub Wiki

Development Guide

This guide covers coding standards, patterns, and best practices for contributing to the 10,000 Beers bot.

Table of Contents


Getting Started with Development

Prerequisites

  • Node.js v20.x or higher
  • PostgreSQL v16.x or higher
  • Git
  • A code editor (recommended: VS Code, Neovim)

Development Setup

  1. Clone the repository

    git clone <repository-url>
    cd 10000-beers
  2. Install dependencies

    npm install
  3. Set up database

    createdb beers
    npm run prisma:migrate
  4. Configure environment

    cp .env.example .env
    # Edit .env with your settings
  5. Start development server

    npm run dev

Development Workflow

# Create feature branch
git checkout -b feature/my-feature

# Make changes and test
npm run dev

# Run tests
npm test

# Format and lint
# (Biome runs automatically on save)

# Commit changes
git add .
git commit -m "feat: add my feature"

# Push and create PR
git push origin feature/my-feature

Coding Standards

Formatting

The project uses Biome for linting and formatting. Configuration is in biome.json.

Key formatting rules:

  • Indentation: Tabs (not spaces)
  • Quotes: Single quotes for strings
  • Line length: 120 characters (not enforced strictly)
  • Semicolons: Required
  • Trailing commas: ES5 style

Example:

// Good
function submitBeer(request: BeerSubmissionRequest): Promise<BeerSubmissionResult> {
	const { whatsappId, displayName, media } = request;
	return beerService.submitBeer(request);
}

// Bad - uses spaces instead of tabs, double quotes
function submitBeer(request: BeerSubmissionRequest): Promise<BeerSubmissionResult> {
  const { whatsappId, displayName, media } = request;
  return beerService.submitBeer(request);
}

Naming Conventions

Files:

  • Use camelCase for TypeScript files: beerService.ts, messageHandler.ts
  • Use PascalCase for type files when they export a single class/interface
  • Use index.ts for barrel exports

Variables and Functions:

  • Use camelCase: userId, getBeerCount, processImage
  • Use descriptive names: totalBeerCount not total

Classes:

  • Use PascalCase: BeerService, MessageHandler
  • Suffix service classes with Service: BeerService, UserService

Constants:

  • Use UPPER_SNAKE_CASE for true constants: MAX_IMAGE_SIZE, DEFAULT_TIMEOUT
  • Use camelCase for exported singletons: config, logger, prisma

Types and Interfaces:

  • Use PascalCase: BeerSubmissionRequest, UserInfo
  • Suffix error types with Error: BeerSubmissionError
  • Suffix result types with Result: BeerSubmissionResult

Example:

// Good
const totalBeerCount = 42;
const MAX_RETRIES = 3;
class BeerService {}
interface BeerSubmissionRequest {}

// Bad
const TotalBeerCount = 42;  // Should be camelCase
const maxRetries = 3;       // Should be UPPER_SNAKE_CASE
class Beer_Service {}       // Should be PascalCase
interface beerRequest {}    // Should be PascalCase

TypeScript Conventions

Strict Mode

The project uses strict TypeScript configuration:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true
  }
}

Always:

  • Use explicit type annotations on function parameters and return types
  • Avoid any type (use unknown if type is truly unknown)
  • Handle null and undefined explicitly
  • Use type guards for narrowing

Example:

// Good - explicit types, no 'any'
async function getBeerCount(userId: string): Promise<number> {
	const count = await prisma.beer.count({
		where: { userId },
	});
	return count;
}

// Bad - implicit return type, 'any' parameter
async function getBeerCount(userId) {
	const count = await prisma.beer.count({
		where: { userId },
	});
	return count;
}

Type vs Interface

Use interface for:

  • Object shapes that may be extended
  • Public API definitions
  • Configuration objects

Use type for:

  • Union types
  • Intersection types
  • Mapped types
  • Type aliases

Example:

// Good - interface for extendable object shape
interface BeerSubmissionRequest {
	whatsappId: string;
	displayName: string;
	media: BeerImageData;
}

// Good - type for union
type BeerType = 'can' | 'bottle' | 'draught';

// Good - type for complex type
type Result<T> = { success: true; data: T } | { success: false; error: string };

Optional vs Undefined

Use optional (?) for:

  • Properties that may not exist
  • Function parameters with defaults

Use | undefined for:

  • Properties that can be explicitly set to undefined
  • Return values that may fail

Example:

// Good - optional property
interface User {
	id: string;
	nickname?: string;  // May not exist
}

// Good - explicit undefined for nullable return
function findUser(id: string): User | undefined {
	return users.find(u => u.id === id);
}

Import Patterns

Import Order

Imports should be organised in this order:

  1. External dependencies
  2. Internal absolute imports (types)
  3. Internal absolute imports (values)
  4. Relative imports

Example:

// 1. External dependencies
import { Message } from 'whatsapp-web.js';
import { PrismaClient } from '@prisma/client';

// 2. Internal types
import type { BeerSubmissionRequest } from '../types/submission';
import type { UserInfo } from '../types/user';

// 3. Internal values
import { logger } from '../utils/logger';
import { config } from '../config';
import { beerService } from '../services/beerService';

// 4. Relative imports
import { validateImage } from './validation';

Type Imports

Always use import type for type-only imports:

// Good - explicit type import
import type { BeerSubmissionRequest } from '../types/submission';
import { beerService } from '../services/beerService';

// Bad - mixed type and value import
import { BeerSubmissionRequest, beerService } from '../services/beerService';

Benefits:

  • Clear separation of types and values
  • Smaller compiled output
  • Faster compilation

Named Exports Only

Always use named exports, never default exports:

// Good - named export
export class BeerService {}
export const beerService = new BeerService();

// Bad - default export
export default class BeerService {}

Benefits:

  • Easier to refactor
  • Better IDE support
  • Consistent import syntax

Error Handling

Custom Error Classes

All custom errors extend Error and follow this pattern:

export class BeerSubmissionError extends Error {
	constructor(
		message: string,
		public readonly code: string,
		public readonly userMessage?: string,
	) {
		super(message);
		this.name = 'BeerSubmissionError';
	}
}

Required fields:

  • message - Technical error message (for logs)
  • code - Machine-readable error code (UPPER_SNAKE_CASE)
  • userMessage - Optional user-facing message (for WhatsApp replies)

Example usage:

throw new BeerSubmissionError(
	'Image hash already exists in database',
	'DUPLICATE_SUBMISSION',
	"You've already submitted this beer",
);

Error Codes

Use descriptive, UPPER_SNAKE_CASE error codes:

Good codes:

  • DUPLICATE_SUBMISSION
  • AI_VALIDATION_FAILED
  • USER_NOT_FOUND
  • IMAGE_TOO_LARGE

Bad codes:

  • ERROR_1 (not descriptive)
  • duplicateSubmission (not UPPER_SNAKE_CASE)
  • err (too vague)

Try-Catch Patterns

Pattern 1: Rethrow custom errors

try {
	const result = await someOperation();
	return result;
} catch (error) {
	// Rethrow known errors
	if (error instanceof BeerSubmissionError) {
		throw error;
	}

	// Wrap unexpected errors
	logger.error({ error }, 'Unexpected error');
	throw new BeerSubmissionError(
		'Failed to submit beer',
		'SUBMISSION_FAILED',
		'Failed to save your beer, please try again',
	);
}

Pattern 2: Handle specific errors

try {
	await fs.unlink(filePath);
} catch (error) {
	// Ignore "file not found" errors
	if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
		logger.debug({ filePath }, 'File already deleted');
		return;
	}

	throw new FileSystemError(
		`Failed to delete file: ${filePath}`,
		'FILE_DELETE_FAILED',
	);
}

Logging

Structured Logging with Pino

The project uses Pino for structured logging. All logs are JSON objects.

Log levels:

  • debug - Detailed debugging information
  • info - Informational messages (default in production)
  • warn - Warnings (something unexpected but handled)
  • error - Errors (something failed)

Logging pattern:

// Good - structured logging with context
logger.info(
	{ userId, beerId, totalCount, messageId },
	'Beer submitted successfully',
);

// Bad - string interpolation, no context
logger.info(`Beer submitted successfully for user ${userId}`);

What to Log

Always log:

  • Service initialisation
  • Successful operations (at info level)
  • Errors (at error level)
  • Warnings (at warn level)
  • Business-critical events (beer submission, user creation)

Log at debug level:

  • Detailed flow information
  • Variable values during debugging
  • Step-by-step process tracking

Never log:

  • Passwords or API keys
  • Full image data
  • Sensitive user information

Example:

// Good - comprehensive logging
async function submitBeer(request: BeerSubmissionRequest): Promise<BeerSubmissionResult> {
	const { whatsappId, displayName } = request;

	logger.debug({ whatsappId }, 'Starting beer submission');

	try {
		const user = await userService.findOrCreateUser(whatsappId, displayName);
		logger.debug({ userId: user.id, isNewUser: user.isNewUser }, 'User resolved');

		const result = await processSubmission(user);
		logger.info({ userId: user.id, beerId: result.beerId }, 'Beer submitted successfully');

		return result;
	} catch (error) {
		logger.error({ error, whatsappId }, 'Beer submission failed');
		throw error;
	}
}

Logging Context

Always include relevant context:

// Good - includes all relevant IDs and metadata
logger.info(
	{ userId, beerId, totalCount, messageId, beerType },
	'Beer submitted successfully',
);

// Bad - missing context
logger.info('Beer submitted');

Database Development

Schema Changes

Always use Prisma migrations:

# 1. Edit src/database/schema.prisma
vim src/database/schema.prisma

# 2. Create migration
npm run prisma:migrate

# 3. Name your migration descriptively
# e.g., "add_nickname_to_users", "create_beers_table"

Never:

  • Modify the database directly via SQL
  • Edit migration files after creation
  • Delete migrations that have been applied

Query Patterns

Use Prisma's type-safe query builder:

// Good - type-safe, clean
const beer = await prisma.beer.create({
	data: {
		userId,
		imagePath,
		imageHash,
		submittedAt: new Date(),
	},
});

// Bad - raw SQL (avoid unless absolutely necessary)
const beer = await prisma.$queryRaw`
	INSERT INTO beers (user_id, image_path, image_hash)
	VALUES (${userId}, ${imagePath}, ${imageHash})
`;

Indexes

Add indexes for:

  • Foreign keys (automatically added by Prisma)
  • Frequently queried columns (userId, submittedAt, imageHash)
  • Columns used in WHERE clauses

Example:

model Beer {
  id          String   @id @default(uuid())
  userId      String   @map("user_id")
  imageHash   String   @map("image_hash")
  submittedAt DateTime @map("submitted_at")

  @@index([userId])
  @@index([imageHash])
  @@index([submittedAt])
}

Adding New Features

Adding a New Command

  1. Create command file in src/commands/
// src/commands/myCommand.ts
import type { Message } from 'whatsapp-web.js';
import type { Command } from './types';
import { logger } from '../utils/logger';

export class MyCommand implements Command {
	name = 'mycommand';
	aliases = ['mc', 'mycmd'];
	description = 'Description of my command';
	adminOnly = false;

	async execute(message: Message, args: string[]): Promise<void> {
		logger.debug({ args }, 'Executing mycommand');

		// Command logic here
		const result = await doSomething(args);

		await message.reply(result);
	}
}
  1. Register command in src/commands/index.ts
import { MyCommand } from './myCommand';

export function registerCommands(): void {
	commandRegistry.register(new UndoCommand());
	commandRegistry.register(new LeaderboardCommand());
	commandRegistry.register(new MyCommand());  // Add here
}
  1. Write tests in tests/unit/commands/myCommand.test.ts
describe('MyCommand', () => {
	it('should execute successfully', async () => {
		// Test implementation
	});
});

Adding a New Service

  1. Create service file in src/services/
// src/services/myService.ts
import { logger } from '../utils/logger';
import { prisma } from '../database';

export class MyService {
	async doSomething(param: string): Promise<Result> {
		logger.debug({ param }, 'Doing something');

		// Service logic here

		return result;
	}
}

export const myService = new MyService();
  1. Add types in src/types/
// src/types/myService.ts
export interface MyServiceRequest {
	param: string;
}

export interface MyServiceResult {
	success: boolean;
	data: string;
}
  1. Write tests in tests/unit/services/myService.test.ts

  2. Use service in handlers or other services

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

const result = await myService.doSomething(param);

Adding Database Models

  1. Update Prisma schema in src/database/schema.prisma
model MyModel {
  id        String   @id @default(uuid())
  userId    String   @map("user_id")
  data      String
  createdAt DateTime @default(now()) @map("created_at")

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

  @@index([userId])
  @@map("my_models")
}
  1. Create migration
npm run prisma:migrate
  1. Update User model if adding a relation
model User {
  // ... existing fields
  myModels MyModel[]
}

Code Review Guidelines

Before Submitting a PR

Checklist:

  • All tests pass (npm test)
  • Code is formatted (biome.json auto-formats on save)
  • No TypeScript errors (npm run build)
  • Relevant tests added for new code
  • Commit messages follow convention (see below)
  • CLAUDE.md updated if project structure changed

Commit Message Convention

Use Conventional Commits format:

<type>(<scope>): <description>

[optional body]

Types:

  • feat - New feature
  • fix - Bug fix
  • docs - Documentation changes
  • refactor - Code refactoring
  • test - Test changes
  • chore - Maintenance tasks

Examples:

# Good commits
git commit -m "feat(commands): add stats command"
git commit -m "fix(beer-service): handle duplicate submissions correctly"
git commit -m "docs: update configuration guide"
git commit -m "refactor(image-service): extract validation to separate function"
git commit -m "test(user-service): add tests for display name updates"

# Bad commits
git commit -m "fixed bug"
git commit -m "WIP"
git commit -m "asdf"

Pull Request Guidelines

PR title: Follow commit message convention

PR description should include:

  • Summary - What does this PR do?
  • Test plan - How to test the changes
  • Screenshots - If applicable (for UI changes)

Example:

## Summary
Adds a new `!stats` command that shows individual user statistics including:
- Total beers submitted
- Average beers per week
- Favourite beer type

## Test plan
- Run `npm test` - all tests pass
- Run bot locally and send `!stats` command
- Verify response includes correct statistics
- Verify admin-only restriction works

## Screenshots
![Stats command output](screenshot.png)

Code Review Focus Areas

When reviewing:

  • Does the code follow project standards?
  • Are there tests covering the new code?
  • Is error handling appropriate?
  • Are logs structured and informative?
  • Is the code maintainable and readable?
  • Are there any security concerns?
  • Is the database schema change necessary and correct?

Best Practices

Good Practices

Write descriptive variable names

const totalBeerCount = await userService.getTotalBeerCount();  // Good
const count = await userService.getTotalBeerCount();           // Bad

Use early returns

// Good
if (!message.hasMedia) {
	return;
}
// Continue with media handling

// Bad
if (message.hasMedia) {
	// All media handling nested here
}

Extract complex logic to functions

// Good
const isValidSubmission = await validateSubmission(request);
if (!isValidSubmission) {
	throw new Error('Invalid submission');
}

// Bad
if (!request.whatsappId || request.whatsappId.length < 5 ||
    !request.media || request.media.mimetype !== 'image/jpeg') {
	throw new Error('Invalid submission');
}

Use const over let

// Good
const userId = user.id;

// Bad (unless reassignment is needed)
let userId = user.id;

Bad Practices

Don't use any type

// Bad
function process(data: any) {}

// Good
function process(data: unknown) {
	if (typeof data === 'string') {
		// Type narrowed to string
	}
}

Don't leave commented-out code

// Bad
// const oldFunction = () => {};
// This was the old way
const newFunction = () => {};

// Good
const newFunction = () => {};

Don't add unnecessary comments

// Bad - obvious comment
// Get the user ID
const userId = user.id;

// Good - no comment needed
const userId = user.id;

Don't nest too deeply

// Bad
if (a) {
	if (b) {
		if (c) {
			if (d) {
				// Do something
			}
		}
	}
}

// Good - early returns
if (!a) return;
if (!b) return;
if (!c) return;
if (!d) return;
// Do something

Useful Commands

# Development
npm run dev                    # Start dev server with hot reload
npm run build                  # Build TypeScript to JavaScript
npm start                      # Run production build

# Testing
npm test                       # Run all tests
npm run test:watch            # Run tests in watch mode
npm run test:coverage         # Generate coverage report

# Database
npm run prisma:generate       # Generate Prisma client
npm run prisma:migrate        # Create and apply migration
npm run prisma:migrate:prod   # Apply migrations in production
npm run prisma:studio         # Open Prisma Studio (database GUI)

# Docker
docker compose up -d          # Start all services
docker compose down           # Stop all services
docker compose logs -f bot    # View bot logs
docker compose restart bot    # Restart bot service

Troubleshooting Development Issues

TypeScript Errors After Schema Change

Solution: Regenerate Prisma client

npm run prisma:generate

Tests Failing After Dependency Update

Solution: Clear Jest cache and reinstall

rm -rf node_modules
npm install
npm test -- --clearCache
npm test

Hot Reload Not Working

Solution: Check for syntax errors and restart

# Stop dev server (Ctrl+C)
npm run dev

Database Out of Sync

Solution: Reset database (WARNING: deletes all data)

npm run prisma:migrate reset

Next Steps

  • Commands - Command system documentation

Remember: When in doubt, follow existing patterns in the codebase. Consistency is more important than perfection.

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