Skip to content

Code Standards — Backend (NestJS)

ProjectPTX Channel Manager (ptx-cm)
Last Updated2026-02-20
See alsocode-standards.md (principles & naming)

1. NestJS Module Structure

Standard Module Layout

src/modules/[module-name]/
├── [module-name].controller.ts    # HTTP handlers, decorators
├── [module-name].service.ts       # Business logic, DB queries
├── [module-name].module.ts        # Module configuration
├── [module-name].service.spec.ts  # Unit tests
├── dto/
│   ├── create-[entity].dto.ts    # Request DTOs
│   ├── list-[entity].dto.ts      # Query parameter DTOs
│   └── update-[entity].dto.ts    # Partial update DTOs
└── entities/ (optional)
    └── [entity].entity.ts         # Response types if complex

DTO Validation Pattern

typescript
// ✓ Good: Use class-validator decorators
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsString()
  name: string;
}

// ✗ Bad: No validation, manual checks in handler
export class CreateUserDto {
  email: string;
  password: string;
  name: string;
}

Controller Pattern

typescript
// ✓ Good: Minimal logic, delegate to service
@Controller('properties')
export class PropertiesController {
  constructor(private propertiesService: PropertiesService) {}

  @Get()
  async findAll(
    @Query() dto: ListPropertiesDto,
    @CountryScope() countryScope?: string,
  ) {
    return this.propertiesService.findAll(dto, countryScope);
  }

  @Post()
  @Roles('manager')
  async create(@Body() dto: CreatePropertyDto) {
    return this.propertiesService.create(dto);
  }
}

// ✗ Bad: Business logic in controller
@Controller('properties')
export class PropertiesController {
  constructor(private prisma: PrismaService) {}

  @Get()
  async findAll(@Query() dto: ListPropertiesDto) {
    // ❌ DB query in controller
    const properties = await this.prisma.property.findMany({
      where: { isActive: true, country: dto.country },
    });
    if (!properties) throw new NotFoundException();
    return properties;
  }
}

Service Pattern

typescript
// ✓ Good: Single responsibility, delegated calls
@Injectable()
export class PropertiesService {
  constructor(private prisma: PrismaService) {}

  async findAll(dto: ListPropertiesDto, countryScope?: string) {
    const where = this.buildWhereClause(dto, countryScope);

    const [data, total] = await Promise.all([
      this.prisma.property.findMany({
        where,
        skip: (dto.page - 1) * dto.limit,
        take: dto.limit,
      }),
      this.prisma.property.count({ where }),
    ]);

    return {
      data,
      pagination: {
        page: dto.page,
        limit: dto.limit,
        total,
        totalPages: Math.ceil(total / dto.limit),
      },
    };
  }

  private buildWhereClause(dto: ListPropertiesDto, countryScope?: string) {
    const where: Prisma.PropertyWhereInput = { isActive: true };
    if (countryScope) where.country = countryScope;
    if (dto.status) where.isActive = dto.status === 'active';
    if (dto.search) where.name = { contains: dto.search, mode: 'insensitive' };
    return where;
  }
}

2. Security Patterns

Authentication: Always Require JWT

typescript
// ✓ Good: JwtAuthGuard applied globally, @Public() for exceptions
@Controller('auth')
export class AuthController {
  @Public()
  @Post('login')
  login(@Body() dto: LoginDto) {
    // Public endpoint, no JWT required
  }

  @Post('logout')
  logout(@Req() req: Request) {
    // JwtAuthGuard enforced by default
  }
}

// ✗ Bad: Missing guard leaves endpoint unprotected
@Controller('sensitive')
export class SensitiveController {
  @Get('data')
  getData() {
    // ❌ No guard = unauthenticated access allowed
  }
}

Access Control: Use Helpers & Decorators

typescript
// ✓ Good: Centralized access check via assertPropertyAccess helper
async deleteProperty(
  @Param('propertyId') propertyId: string,
  @CountryScope() countryScope?: string,
) {
  await assertPropertyAccess(this.prisma, propertyId, countryScope);
  return this.prisma.property.update({
    where: { id: propertyId },
    data: { isActive: false },
  });
}

// ✗ Bad: Manual checks scattered across endpoints
async deleteProperty(@Param('propertyId') propertyId: string, @Req() req: Request) {
  const user = req.user as any;
  const property = await this.prisma.property.findUnique({ where: { id: propertyId } });
  if (user.country && property.country !== user.country) {
    throw new ForbiddenException(); // ❌ Duplicated in 10 other endpoints
  }
}

Role-Based Access

typescript
// ✓ Good: @RequirePermission decorator
@Post('settings')
@RequirePermission(ModuleKey.SETTINGS, Permission.EDIT)
async updateSettings(@Body() dto: UpdateSettingsDto) {
  return this.settingsService.update(dto);
}

// ✗ Bad: Manual role checks in handler body
@Post('settings')
async updateSettings(@Body() dto: UpdateSettingsDto, @Req() req: Request) {
  if ((req.user as any).role !== 'manager') {
    throw new ForbiddenException(); // ❌ Easy to forget, hard to audit
  }
}

Input Validation: Whitelist Sort Fields

typescript
// ✓ Good: Explicit whitelist prevents injection
const ALLOWED_SORT_FIELDS = ['name', 'country', 'createdAt'];

function validateSortBy(sortBy?: string): string {
  if (!sortBy || !ALLOWED_SORT_FIELDS.includes(sortBy)) return 'name';
  return sortBy;
}

const sortBy = validateSortBy(dto.sortBy);
const properties = await this.prisma.property.findMany({
  orderBy: { [sortBy]: 'asc' },
});

// ✗ Bad: Direct use of user input
const properties = await this.prisma.property.findMany({
  orderBy: { [dto.sortBy]: 'asc' }, // ❌ Unvalidated input
});

Rate Limiting

typescript
// ✓ Good: Explicit throttle per sensitive endpoint
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(@Body() dto: LoginDto) { }

@Post('refresh')
@Throttle({ default: { limit: 10, ttl: 60000 } })
async refresh() { }

3. Error Handling

Standard Exception Pattern

typescript
// ✓ Good: Use NestJS built-in exceptions
async findProperty(id: string) {
  const property = await this.prisma.property.findUnique({ where: { id } });
  if (!property) throw new NotFoundException(`Property ${id} not found`);
  return property;
}

async deleteProperty(id: string, countryScope?: string) {
  const property = await this.findProperty(id); // Throws NotFoundException if missing
  if (countryScope && property.country !== countryScope) {
    throw new ForbiddenException('Access denied: property not in your country scope');
  }
  return this.prisma.property.update({ where: { id }, data: { isActive: false } });
}

// ✗ Bad: Manual error responses, inconsistent format
async findProperty(id: string) {
  const property = await this.prisma.property.findUnique({ where: { id } });
  if (!property) return { error: 'Not found', statusCode: 404 }; // ❌ Not an exception
}

Validation Error Pattern

typescript
// ✓ Good: DTOs + class-validator → ValidationPipe returns 400
export class CreatePropertyDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsIn(['VN', 'ID', 'MY'])
  country: string;
}

// Response from GlobalExceptionFilter:
// {
//   "error": "Validation failed",
//   "code": "VALIDATION_ERROR",
//   "details": [{ "field": "name", "message": "name must be at least 3 characters" }]
// }

// ✗ Bad: Manual validation in service
async create(dto: any) {
  if (!dto.name || dto.name.length < 3) {
    return { error: 'Name required' }; // ❌ Not thrown, inconsistent format
  }
}

4. Database & Prisma Patterns

Efficient Pagination

typescript
// ✓ Good: Parallel count + data query
async findAll(skip: number, take: number) {
  const [data, total] = await Promise.all([
    this.prisma.property.findMany({ skip, take }),
    this.prisma.property.count(),
  ]);
  return { data, pagination: { total, totalPages: Math.ceil(total / take) } };
}

// ✗ Bad: Load all then slice
async findAll(skip: number, take: number) {
  const all = await this.prisma.property.findMany(); // ❌ Loads all rows
  return all.slice(skip, skip + take);
}

Transaction Pattern

typescript
// ✓ Good: $transaction for atomic multi-step operations
async importProperties(properties: ImportDto[]) {
  return this.prisma.$transaction(async (tx) => {
    const results = [];
    for (const prop of properties) {
      const property = await tx.property.create({ data: { name: prop.name, country: prop.country } });
      const roomTypes = await tx.roomType.createMany({
        data: prop.rooms.map(r => ({ ...r, propertyId: property.id })),
      });
      results.push({ property, roomTypes });
    }
    return results;
  });
}

// ✗ Bad: Sequential operations without transaction
async importProperties(properties: ImportDto[]) {
  // ❌ If roomType creation fails, property still created (partial state)
  for (const prop of properties) {
    const property = await this.prisma.property.create({ data: prop });
    await this.prisma.roomType.createMany({ data: prop.rooms.map(r => ({ ...r, propertyId: property.id })) });
  }
}

Upsert Pattern

typescript
// ✓ Good: Idempotent upsert for OTA booking dedup
async upsertBooking(booking: BookingData) {
  return this.prisma.booking.upsert({
    where: { ota_booking_id: `${booking.otaType}#${booking.otaBookingId}` },
    create: booking,
    update: { status: booking.status, updatedAt: new Date() },
  });
}

// ✗ Bad: Separate find + create with race condition
async upsertBooking(booking: BookingData) {
  const existing = await this.prisma.booking.findUnique({ where: { otaBookingId: booking.otaBookingId } });
  if (existing) {
    return this.prisma.booking.update({ where: { id: existing.id }, data: booking });
  }
  return this.prisma.booking.create({ data: booking }); // ❌ Race condition
}

Specific Selects (Avoid N+1)

typescript
// ✓ Good: Select only needed fields, count in single query
const properties = await this.prisma.property.findMany({
  select: {
    id: true,
    name: true,
    country: true,
    _count: { select: { roomTypes: true, bookings: true } },
  },
});

// ✗ Bad: Load full entities + N additional queries
const properties = await this.prisma.property.findMany();
for (const prop of properties) {
  const roomCount = await this.prisma.roomType.count({ where: { propertyId: prop.id } }); // N queries
}

5. Testing Standards

Framework: Jest + @nestjs/testing. Follow Arrange / Act / Assert pattern.

typescript
// ✓ Good: Clear structure, specific assertions
it('should create a property with valid DTO', async () => {
  // Arrange
  const dto: CreatePropertyDto = { name: 'Hotel', country: 'VN', timezone: 'Asia/Ho_Chi_Minh', currency: 'VND' };
  jest.spyOn(prisma.property, 'create').mockResolvedValue({ id: 'uuid', ...dto });

  // Act
  const result = await service.create(dto);

  // Assert
  expect(result.id).toBeDefined();
  expect(prisma.property.create).toHaveBeenCalledWith({ data: dto });
});

// ✗ Bad: Vague test, no behavioral verification
it('works', async () => {
  const result = await service.create({ name: 'Test' });
  expect(result).toBeDefined();
});

Coverage Targets

Code TypeTarget
Services80%
Controllers60%
Helpers90%
Guards80%
Pipes/Filters70%
bash
pnpm test:cov

PTX Channel Manager — Internal Documentation