# tsoa Controller Standards

This document defines the coding standards and patterns for implementing tsoa controllers in the Flyer Crawler API.

## Overview

Controllers are the API layer that handles HTTP requests and responses. They use [tsoa](https://tsoa-community.github.io/docs/) decorators to define routes and generate OpenAPI specifications automatically.

**Key Principles:**

- Controllers handle HTTP concerns (parsing requests, formatting responses)
- Business logic belongs in the service layer
- All controllers extend `BaseController` for consistent response formatting
- Response formats follow ADR-028 (API Response Standards)

## Quick Start

```typescript
import { Route, Get, Post, Tags, Body, Path, Query, Security, Request } from 'tsoa';
import {
  BaseController,
  SuccessResponse,
  PaginatedResponse,
  RequestContext,
} from './base.controller';
import { userService } from '../services/userService';
import type { User, CreateUserRequest } from '../types';

@Route('users')
@Tags('Users')
export class UsersController extends BaseController {
  /**
   * Get a user by ID.
   * @param id The user's unique identifier
   */
  @Get('{id}')
  @Security('jwt')
  public async getUser(
    @Path() id: string,
    @Request() ctx: RequestContext,
  ): Promise<SuccessResponse<User>> {
    ctx.logger.info({ userId: id }, 'Fetching user');
    const user = await userService.getUserById(id, ctx.logger);
    return this.success(user);
  }

  /**
   * List all users with pagination.
   */
  @Get()
  @Security('jwt', ['admin'])
  public async listUsers(
    @Query() page?: number,
    @Query() limit?: number,
    @Request() ctx?: RequestContext,
  ): Promise<PaginatedResponse<User>> {
    const { page: p, limit: l } = this.normalizePagination(page, limit);
    const { users, total } = await userService.listUsers({ page: p, limit: l }, ctx?.logger);
    return this.paginated(users, { page: p, limit: l, total });
  }

  /**
   * Create a new user.
   */
  @Post()
  @Security('jwt', ['admin'])
  public async createUser(
    @Body() body: CreateUserRequest,
    @Request() ctx: RequestContext,
  ): Promise<SuccessResponse<User>> {
    const user = await userService.createUser(body, ctx.logger);
    return this.created(user);
  }
}
```

## File Structure

```
src/controllers/
├── base.controller.ts    # Base class with response helpers
├── types.ts              # Shared types for controllers
├── README.md             # This file
├── health.controller.ts  # Health check endpoints
├── auth.controller.ts    # Authentication endpoints
├── users.controller.ts   # User management endpoints
└── ...
```

## Response Format

All responses follow ADR-028:

### Success Response

```typescript
interface SuccessResponse<T> {
  success: true;
  data: T;
  meta?: {
    requestId?: string;
    timestamp?: string;
    pagination?: PaginationMeta;
  };
}
```

### Error Response

```typescript
interface ErrorResponse {
  success: false;
  error: {
    code: string; // e.g., 'NOT_FOUND', 'VALIDATION_ERROR'
    message: string; // Human-readable message
    details?: unknown; // Additional error details
  };
  meta?: {
    requestId?: string;
    timestamp?: string;
  };
}
```

### Paginated Response

```typescript
interface PaginatedResponse<T> {
  success: true;
  data: T[];
  meta: {
    pagination: {
      page: number;
      limit: number;
      total: number;
      totalPages: number;
      hasNextPage: boolean;
      hasPrevPage: boolean;
    };
  };
}
```

## BaseController Methods

### Response Helpers

| Method                               | Description                 | HTTP Status |
| ------------------------------------ | --------------------------- | ----------- |
| `success(data, meta?)`               | Standard success response   | 200         |
| `created(data, meta?)`               | Resource created            | 201         |
| `noContent()`                        | Success with no body        | 204         |
| `paginated(data, pagination, meta?)` | Paginated list response     | 200         |
| `message(msg)`                       | Success with just a message | 200         |

### Pagination Helpers

| Method                               | Description                                           |
| ------------------------------------ | ----------------------------------------------------- |
| `normalizePagination(page?, limit?)` | Apply defaults and bounds (page=1, limit=20, max=100) |
| `calculatePagination(input)`         | Calculate totalPages, hasNextPage, etc.               |

### Error Handling

Controllers should throw typed errors rather than constructing error responses manually:

```typescript
import { NotFoundError, ForbiddenError, ValidationError } from './base.controller';

// Throw when resource not found
throw new NotFoundError('User not found');

// Throw when access denied
throw new ForbiddenError('Cannot access this resource');

// Throw for validation errors (from Zod)
throw new ValidationError(zodError.issues);
```

The global error handler converts these to proper HTTP responses.

## tsoa Decorators

### Route Decorators

| Decorator                                              | Description                               |
| ------------------------------------------------------ | ----------------------------------------- |
| `@Route('path')`                                       | Base path for all endpoints in controller |
| `@Tags('TagName')`                                     | OpenAPI tag for grouping                  |
| `@Get()`, `@Post()`, `@Put()`, `@Patch()`, `@Delete()` | HTTP methods                              |
| `@Security('jwt')`                                     | Require authentication                    |
| `@Security('jwt', ['admin'])`                          | Require specific roles                    |

### Parameter Decorators

| Decorator    | Description            | Example                               |
| ------------ | ---------------------- | ------------------------------------- |
| `@Path()`    | URL path parameter     | `@Path() id: string`                  |
| `@Query()`   | Query string parameter | `@Query() search?: string`            |
| `@Body()`    | Request body           | `@Body() data: CreateUserRequest`     |
| `@Header()`  | Request header         | `@Header('X-Custom') custom?: string` |
| `@Request()` | Full request context   | `@Request() ctx: RequestContext`      |

### Response Decorators

| Decorator                                    | Description                      |
| -------------------------------------------- | -------------------------------- |
| `@Response<ErrorResponse>(404, 'Not Found')` | Document possible error response |
| `@SuccessResponse(201, 'Created')`           | Document success response        |

## RequestContext

The `RequestContext` interface provides access to request-scoped resources:

```typescript
interface RequestContext {
  logger: Logger; // Request-scoped Pino logger (ADR-004)
  requestId: string; // Unique request ID for correlation
  user?: AuthenticatedUser; // Authenticated user (from JWT)
  dbClient?: PoolClient; // Database client for transactions
}

interface AuthenticatedUser {
  userId: string;
  email: string;
  roles?: string[];
}
```

## Naming Conventions

### Controller Class Names

- PascalCase with `Controller` suffix
- Example: `UsersController`, `HealthController`, `AuthController`

### Method Names

- camelCase, describing the action
- Use verbs: `getUser`, `listUsers`, `createUser`, `updateUser`, `deleteUser`

### File Names

- kebab-case with `.controller.ts` suffix
- Example: `users.controller.ts`, `health.controller.ts`

## Service Layer Integration

Controllers delegate to the service layer for business logic:

```typescript
@Route('flyers')
@Tags('Flyers')
export class FlyersController extends BaseController {
  @Get('{id}')
  public async getFlyer(
    @Path() id: number,
    @Request() ctx: RequestContext,
  ): Promise<SuccessResponse<Flyer>> {
    // Service handles business logic and database access
    const flyer = await flyerService.getFlyerById(id, ctx.logger);
    // Controller only formats the response
    return this.success(flyer);
  }
}
```

**Rules:**

1. Never access repositories directly from controllers
2. Always pass the logger to service methods
3. Let services throw domain errors (NotFoundError, etc.)
4. Controllers catch and transform errors only when needed

## Validation

tsoa performs automatic validation based on TypeScript types. For complex validation, define request types:

```typescript
// In types.ts or a dedicated types file
interface CreateUserRequest {
  /**
   * User's email address
   * @format email
   */
  email: string;

  /**
   * User's password
   * @minLength 8
   */
  password: string;

  /**
   * Full name (optional)
   */
  fullName?: string;
}

// In controller
@Post()
public async createUser(@Body() body: CreateUserRequest): Promise<SuccessResponse<User>> {
  // body is already validated by tsoa
  return this.created(await userService.createUser(body));
}
```

For additional runtime validation (e.g., database constraints), use Zod in the service layer.

## Error Handling Examples

### Not Found

```typescript
@Get('{id}')
public async getUser(@Path() id: string): Promise<SuccessResponse<User>> {
  const user = await userService.findUserById(id);
  if (!user) {
    throw new NotFoundError(`User with ID ${id} not found`);
  }
  return this.success(user);
}
```

### Authorization

```typescript
@Delete('{id}')
@Security('jwt')
public async deleteUser(
  @Path() id: string,
  @Request() ctx: RequestContext,
): Promise<void> {
  // Only allow users to delete their own account (or admins)
  if (ctx.user?.userId !== id && !ctx.user?.roles?.includes('admin')) {
    throw new ForbiddenError('Cannot delete another user account');
  }
  await userService.deleteUser(id, ctx.logger);
  return this.noContent();
}
```

### Conflict (Duplicate)

```typescript
@Post()
public async createUser(@Body() body: CreateUserRequest): Promise<SuccessResponse<User>> {
  try {
    const user = await userService.createUser(body);
    return this.created(user);
  } catch (error) {
    if (error instanceof UniqueConstraintError) {
      this.setStatus(409);
      throw error; // Let error handler format the response
    }
    throw error;
  }
}
```

## Testing Controllers

Controllers should be tested via integration tests that verify the full HTTP request/response cycle:

```typescript
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../app';

describe('UsersController', () => {
  describe('GET /api/v1/users/:id', () => {
    it('should return user when found', async () => {
      const response = await request(app)
        .get('/api/v1/users/123')
        .set('Authorization', `Bearer ${validToken}`)
        .expect(200);

      expect(response.body).toEqual({
        success: true,
        data: expect.objectContaining({
          user_id: '123',
        }),
      });
    });

    it('should return 404 when user not found', async () => {
      const response = await request(app)
        .get('/api/v1/users/nonexistent')
        .set('Authorization', `Bearer ${validToken}`)
        .expect(404);

      expect(response.body).toEqual({
        success: false,
        error: {
          code: 'NOT_FOUND',
          message: expect.any(String),
        },
      });
    });
  });
});
```

## Migration from Express Routes

When migrating existing Express routes to tsoa controllers:

1. Create the controller class extending `BaseController`
2. Add route decorators matching existing paths
3. Move request handling logic (keep business logic in services)
4. Replace `sendSuccess`/`sendError` with `this.success()`/throwing errors
5. Update tests to use the new paths
6. Remove the old Express route file

## Related Documentation

- [ADR-028: API Response Standards](../../docs/adr/ADR-028-api-response-standards.md)
- [ADR-004: Request-Scoped Logging](../../docs/adr/0004-request-scoped-logging.md)
- [CODE-PATTERNS.md](../../docs/development/CODE-PATTERNS.md)
- [tsoa Documentation](https://tsoa-community.github.io/docs/)
