Development Guide - gabrielg2020/10000-beers GitHub Wiki
This guide covers coding standards, patterns, and best practices for contributing to the 10,000 Beers bot.
- Getting Started with Development
- Coding Standards
- TypeScript Conventions
- Import Patterns
- Error Handling
- Logging
- Database Development
- Adding New Features
- Code Review Guidelines
- Node.js v20.x or higher
- PostgreSQL v16.x or higher
- Git
- A code editor (recommended: VS Code, Neovim)
-
Clone the repository
git clone <repository-url> cd 10000-beers
-
Install dependencies
npm install
-
Set up database
createdb beers npm run prisma:migrate
-
Configure environment
cp .env.example .env # Edit .env with your settings -
Start development server
npm run dev
# 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-featureThe 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);
}Files:
- Use
camelCasefor TypeScript files:beerService.ts,messageHandler.ts - Use
PascalCasefor type files when they export a single class/interface - Use
index.tsfor barrel exports
Variables and Functions:
- Use
camelCase:userId,getBeerCount,processImage - Use descriptive names:
totalBeerCountnottotal
Classes:
- Use
PascalCase:BeerService,MessageHandler - Suffix service classes with
Service:BeerService,UserService
Constants:
- Use
UPPER_SNAKE_CASEfor true constants:MAX_IMAGE_SIZE,DEFAULT_TIMEOUT - Use
camelCasefor 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 PascalCaseThe 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
anytype (useunknownif type is truly unknown) - Handle
nullandundefinedexplicitly - 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;
}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 };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);
}Imports should be organised in this order:
- External dependencies
- Internal absolute imports (types)
- Internal absolute imports (values)
- 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';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
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
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",
);Use descriptive, UPPER_SNAKE_CASE error codes:
Good codes:
DUPLICATE_SUBMISSIONAI_VALIDATION_FAILEDUSER_NOT_FOUNDIMAGE_TOO_LARGE
Bad codes:
-
ERROR_1(not descriptive) -
duplicateSubmission(not UPPER_SNAKE_CASE) -
err(too vague)
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',
);
}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}`);Always log:
- Service initialisation
- Successful operations (at
infolevel) - Errors (at
errorlevel) - Warnings (at
warnlevel) - 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;
}
}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');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
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})
`;Add indexes for:
- Foreign keys (automatically added by Prisma)
- Frequently queried columns (
userId,submittedAt,imageHash) - Columns used in
WHEREclauses
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])
}-
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);
}
}-
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
}-
Write tests in
tests/unit/commands/myCommand.test.ts
describe('MyCommand', () => {
it('should execute successfully', async () => {
// Test implementation
});
});-
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();-
Add types in
src/types/
// src/types/myService.ts
export interface MyServiceRequest {
param: string;
}
export interface MyServiceResult {
success: boolean;
data: string;
}-
Write tests in
tests/unit/services/myService.test.ts -
Use service in handlers or other services
import { myService } from '../services/myService';
const result = await myService.doSomething(param);-
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")
}- Create migration
npm run prisma:migrate- Update User model if adding a relation
model User {
// ... existing fields
myModels MyModel[]
}Checklist:
- All tests pass (
npm test) - Code is formatted (
biome.jsonauto-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
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"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
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?
Write descriptive variable names
const totalBeerCount = await userService.getTotalBeerCount(); // Good
const count = await userService.getTotalBeerCount(); // BadUse 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;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# 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 serviceSolution: Regenerate Prisma client
npm run prisma:generateSolution: Clear Jest cache and reinstall
rm -rf node_modules
npm install
npm test -- --clearCache
npm testSolution: Check for syntax errors and restart
# Stop dev server (Ctrl+C)
npm run devSolution: Reset database (WARNING: deletes all data)
npm run prisma:migrate reset- Commands - Command system documentation
Remember: When in doubt, follow existing patterns in the codebase. Consistency is more important than perfection.