DEVELOPER_GUIDE.md 12 KB

Developer Guide - New API Patterns

Quick Reference

Importing New Guards & Interceptors

// Rate limiting
import { RateLimitGuard } from '@box/common/guards/rate-limit.guard';

// MFA enforcement
import { MfaGuard } from '@box/common/guards/mfa.guard';

// Response types
import {
  ApiResponse,
  PaginatedApiResponse,
} from '@box/common/interfaces/api-response.interface';

Response Typing Best Practices

Basic Response

// ✅ Good: Type-safe response
async getUser(id: number): Promise<ApiResponse<UserDto>> {
  const user = await this.userService.findById(id)

  // Response interceptor automatically wraps in ApiResponse
  return user
}

// Response:
// {
//   success: true,
//   code: "OK",
//   message: "success",
//   data: { id: 1, username: "admin", ... },
//   timestamp: "2025-11-20T12:34:56.789Z"
// }

Paginated Response

// ✅ Good: Paginated response type
async listUsers(
  query: SearchUserDto
): Promise<{ list: UserDto[]; total: number }> {
  const { list, total } = await this.userService.list(query)

  return { list, total }
}

// Response (auto-wrapped):
// {
//   success: true,
//   code: "OK",
//   message: "success",
//   data: {
//     list: [...],
//     total: 100
//   },
//   timestamp: "2025-11-20T12:34:56.789Z"
// }

Void Response

// ✅ Good: Void operations
async deleteUser(id: number): Promise<void> {
  await this.userService.delete(id)
  // No return needed
}

// Response:
// {
//   success: true,
//   code: "OK",
//   message: "success",
//   data: null,
//   timestamp: "2025-11-20T12:34:56.789Z"
// }

Error Handling Patterns

Standard HTTP Exceptions

import {
  BadRequestException,
  NotFoundException,
  UnauthorizedException,
  ForbiddenException,
  ConflictException
} from '@nestjs/common'

// ✅ Good: Use standard exceptions
async updateUser(id: number, dto: UpdateUserDto) {
  const user = await this.userService.findById(id)

  if (!user) {
    throw new NotFoundException('User not found')
    // → HTTP 404, code: "NOT_FOUND"
  }

  if (user.username === 'admin' && dto.username !== 'admin') {
    throw new ForbiddenException('Cannot rename admin user')
    // → HTTP 403, code: "FORBIDDEN"
  }

  return this.userService.update(id, dto)
}

Custom Error Codes

// ✅ Good: Custom error codes for specific cases
async enable2FA(user: User, dto: Enable2FADto) {
  if (user.twoFA) {
    throw new BadRequestException({
      statusCode: 400,
      message: '2FA already enabled',
      code: 'TWO_FA_ALREADY_ENABLED'
    })
  }

  // Business logic...
}

// Response:
// HTTP 400 Bad Request
// {
//   success: false,
//   code: "TWO_FA_ALREADY_ENABLED",
//   message: "2FA already enabled",
//   data: null,
//   timestamp: "2025-11-20T12:34:56.789Z"
// }

Validation Errors

// ✅ Good: class-validator errors auto-formatted
class CreateUserDto {
  @IsNotEmpty()
  @Length(3, 20)
  username: string;

  @IsEmail()
  email: string;

  @IsStrongPassword()
  password: string;
}

// Invalid request:
// → HTTP 400, message: "username must be longer than or equal to 3 characters, email must be an email"

Rate Limiting Patterns

Apply to Sensitive Endpoints

import { RateLimitGuard } from '@box/common/guards/rate-limit.guard';

@Controller('auth')
export class AuthController {
  // ✅ Good: Rate limit login attempts
  @UseGuards(RateLimitGuard, LocalAuthGuard)
  @Post('login')
  async login(@AuthUser() user: User) {
    return this.authService.login(user);
  }

  // ✅ Good: Rate limit password reset
  @UseGuards(RateLimitGuard)
  @Post('reset-password')
  async resetPassword(@Body() dto: ResetPasswordDto) {
    return this.authService.resetPassword(dto);
  }

  // ❌ Bad: Don't rate limit read operations
  @Get('permission') // No rate limit
  async getPermission(@AuthUser() user: User) {
    return this.authService.getPermission(user);
  }
}

Custom Rate Limits (Future Enhancement)

// For future: configurable rate limits per endpoint
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, window: 300 }) // 5 requests per 5 minutes
@Post('send-verification-email')
async sendVerificationEmail(@AuthUser() user: User) {
  return this.emailService.sendVerification(user)
}

MFA Guard Patterns

Protect Sensitive Operations

import { MfaGuard } from '@box/common/guards/mfa.guard';

@Controller('users')
export class UserController {
  // ✅ Good: Require MFA for destructive operations
  @UseGuards(JwtAuthGuard, MfaGuard)
  @Delete(':id')
  async deleteUser(@Param('id') id: number) {
    return this.userService.delete(id);
  }

  // ✅ Good: Require MFA for privilege escalation
  @UseGuards(JwtAuthGuard, MfaGuard)
  @Post(':id/grant-admin')
  async grantAdmin(@Param('id') id: number) {
    return this.userService.grantAdmin(id);
  }

  // ❌ Bad: Don't use MfaGuard for read operations
  @UseGuards(JwtAuthGuard) // No MfaGuard
  @Get(':id')
  async getUser(@Param('id') id: number) {
    return this.userService.get(id);
  }
}

Guard Execution Order

// ✅ Good: Correct guard order
@UseGuards(
  RateLimitGuard,    // 1. Check rate limit first
  LocalAuthGuard,    // 2. Then authenticate
  MfaGuard          // 3. Finally check MFA
)
@Post('sensitive-action')
async sensitiveAction() {
  // All guards passed
}

// ❌ Bad: Wrong order
@UseGuards(
  MfaGuard,         // ❌ Will fail - user not authenticated yet
  LocalAuthGuard
)

Correlation ID Usage

Accessing Correlation ID

import type { FastifyRequest } from 'fastify'

@Post('process')
async processData(@Req() req: FastifyRequest) {
  const correlationId = (req as any).correlationId

  this.logger.log(`[${correlationId}] Processing started`)

  try {
    const result = await this.service.process()
    this.logger.log(`[${correlationId}] Processing completed`)
    return result
  } catch (error) {
    this.logger.error(`[${correlationId}] Processing failed`, error.stack)
    throw error
  }
}

Passing to External Services

async callExternalApi(@Req() req: FastifyRequest) {
  const correlationId = (req as any).correlationId

  // ✅ Good: Pass correlation ID to downstream services
  const response = await this.httpService.post(
    'https://api.example.com/endpoint',
    { data: '...' },
    {
      headers: {
        'x-correlation-id': correlationId
      }
    }
  )

  return response.data
}

Logging Best Practices

Structured Logging with Context

import { Logger } from '@nestjs/common';

export class UserService {
  private readonly logger = new Logger(UserService.name);

  async createUser(dto: CreateUserDto, correlationId?: string) {
    const context = correlationId ? `[${correlationId}]` : '';

    this.logger.log(`${context} Creating user: ${dto.username}`);

    try {
      const user = await this.userRepo.create(dto);
      this.logger.log(`${context} User created: ${user.id}`);
      return user;
    } catch (error) {
      this.logger.error(
        `${context} Failed to create user: ${dto.username}`,
        error.stack,
      );
      throw error;
    }
  }
}

Error Logging Levels

// ✅ Good: Appropriate log levels
try {
  const user = await this.userService.findById(id);

  if (!user) {
    this.logger.warn(`User not found: ${id}`); // User error
    throw new NotFoundException();
  }

  return user;
} catch (error) {
  if (error instanceof NotFoundException) {
    // Don't log - already logged as warning
  } else {
    this.logger.error('Unexpected error', error.stack); // System error
  }
  throw error;
}

Configuration Access

Type-Safe Config

import { ConfigService } from '@nestjs/config';
import { EnvironmentVariables } from '../config/env.validation';

export class SomeService {
  constructor(private configService: ConfigService<EnvironmentVariables>) {}

  async someMethod() {
    // ✅ Good: Type-safe config access
    const jwtSecret = this.configService.get('JWT_SECRET', { infer: true });
    const jwtExpiry = this.configService.get('JWT_EXPIRES_IN_SECONDS', {
      infer: true,
    });

    // No need for || fallbacks - validation ensures they exist
    this.jwtService.sign(payload, {
      secret: jwtSecret,
      expiresIn: jwtExpiry,
    });
  }
}

Testing Patterns

Unit Tests with New Response Format

describe('UserController', () => {
  it('should return user in ApiResponse format', async () => {
    const result = await controller.getUser(1);

    // ✅ Good: Test unwrapped data (interceptor adds wrapper)
    expect(result).toEqual({
      id: 1,
      username: 'test',
      // ...
    });
  });

  it('should throw proper HTTP exception', async () => {
    // ✅ Good: Test exception type and status
    await expect(controller.getUser(999)).rejects.toThrow(NotFoundException);
  });
});

Integration Tests

describe('Auth E2E', () => {
  it('should enforce rate limiting', async () => {
    // Send 10 requests (should succeed)
    for (let i = 0; i < 10; i++) {
      const response = await request(app.getHttpServer())
        .post('/auth/login')
        .send({ username: 'test', password: 'wrong' });

      expect([401, 400]).toContain(response.status);
    }

    // 11th request should be rate limited
    const response = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ username: 'test', password: 'wrong' });

    expect(response.status).toBe(429);
    expect(response.body).toMatchObject({
      success: false,
      code: 'RATE_LIMITED',
    });
  });

  it('should include correlation ID in response', async () => {
    const correlationId = 'test-correlation-123';

    const response = await request(app.getHttpServer())
      .get('/auth/permission')
      .set('x-request-id', correlationId)
      .set('Authorization', `Bearer ${token}`);

    expect(response.headers['x-request-id']).toBe(correlationId);
    expect(response.headers['x-correlation-id']).toBe(correlationId);
  });
});

Common Pitfalls to Avoid

❌ Don't Return Raw ApiResponse

// ❌ Bad: Manually creating ApiResponse
async getUser(id: number): Promise<ApiResponse<User>> {
  const user = await this.userService.findById(id)

  return {
    success: true,
    code: 'OK',
    message: 'User found',
    data: user,
    timestamp: new Date().toISOString()
  }
}

// ✅ Good: Let interceptor wrap response
async getUser(id: number): Promise<User> {
  return this.userService.findById(id)
  // Interceptor auto-wraps in ApiResponse
}

❌ Don't Swallow Errors

// ❌ Bad: Swallowing errors
async updateUser(id: number, dto: UpdateUserDto) {
  try {
    return await this.userService.update(id, dto)
  } catch (error) {
    return null // ❌ Error hidden!
  }
}

// ✅ Good: Let errors propagate
async updateUser(id: number, dto: UpdateUserDto) {
  return this.userService.update(id, dto)
  // Exception filter handles errors
}

❌ Don't Mix Guard Types

// ❌ Bad: Conflicting guards
@UseGuards(PublicGuard, JwtAuthGuard) // Contradiction!
@Get('public-endpoint')

// ✅ Good: Use @Public() decorator
@Public()
@Get('public-endpoint')

Migration Checklist for Developers

When creating new endpoints or updating existing ones:

  • Use proper HTTP status codes (throw exceptions, don't return error objects)
  • Return data directly (let ResponseInterceptor wrap it)
  • Add @UseGuards(RateLimitGuard) to auth/sensitive endpoints
  • Add @UseGuards(MfaGuard) to destructive operations
  • Use TypeScript generics for type-safe responses
  • Include correlation ID in logs for debugging
  • Write tests for both success and error cases
  • Document custom error codes in API docs

Quick Command Reference

# Generate Prisma clients
pnpm prisma:generate

# Build application
pnpm build:mgnt

# Run development server
pnpm dev:mgnt

# Run production server
pnpm start:mgnt

# Check for errors
pnpm build:mgnt && echo "✅ No errors"

Need Help?

  • Response format questions: See BEFORE_AFTER.md
  • Deployment steps: See DEPLOYMENT_CHECKLIST.md
  • Architecture overview: See REFACTOR_SUMMARY.md
  • Code examples: This document