Skip to content

Code Standards — Backend (NestJS)

Project: PTX Channel Manager (ptx-cm)
Last Updated: 2026-06-05
See also: code-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
});

Entity Code Resolution & URL Routes

See code-standards.md § 4 — Entity URLs & Internal Codes for full requirements.

Key points for backend:

  • Route params (:id, :bookingId, etc.) accept either internal code OR UUID.
  • Use EntityCodeService.resolve(entity, idOrCode, scope?) to convert route param to UUID (0 queries for UUID passthrough; 1 indexed query for code).
  • Department routes require scope: { companyId } since codes are only unique per company.
  • Request body/query fields keep @IsUUID decorators and always carry real UUIDs from the fetched entity — never the route param.

Module location: apps/api/src/common/entity-code/ (@Global)

typescript
// ✓ CORRECT: resolve route param, use real UUID in body operations
@Get(':id')
async findOne(
  @Param('id') idOrCode: string,
  @CountryScope() countryScope?: string,
) {
  const uuid = await this.entityCodeService.resolve('booking', idOrCode);
  return this.prisma.booking.findUnique({ where: { id: uuid } });
}

// ✓ CORRECT: generate codes at creation
@Post()
async create(@Body() dto: CreateBookingDto) {
  const internalCode = await this.entityCodeService.generateFor('booking', 'BK');
  return this.prisma.booking.create({ data: { ...dto, internalCode } });
}

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() { }

Advanced Filters (DSL & Registry)

Module location: apps/api/src/common/filtering/ (@Global)

The advanced filter system enables dynamic, server-side filtering via a generic DSL. Filters are parsed from the ?af= query param (URL-encoded JSON) and applied with hard limits.

Core concepts:

  • FilterCondition interface: { field: string, op: FilterOp, value?: string | number | boolean | string[] }
  • FilterOp types: contains, notContains, is, isNot, isEmpty, isNotEmpty, eq, neq, gt, gte, lt, lte, isBefore, isAfter, isAnyOf, isNoneOf
  • Hard limits: MAX 20 conditions, MAX 50 array values per condition (enforced in parseAfParam, reject 400 if exceeded)
  • AND semantics only: no OR groups; all conditions combined with AND
  • 1-level relations only: whitelist in registry prevents deep nesting

Per-module field registry:
Each module that supports filters has a registry file defining whitelisted fields + their Prisma path:

typescript
// Example: supplier-apartments-filter-registry.ts
export const SUPPLIER_APARTMENTS_FILTERS = {
  name: { prismaPath: 'name', type: 'string' },
  status: { prismaPath: 'status', type: 'enum' },
  supplier: { prismaPath: 'supplier.name', type: 'string' }, // 1-level relation only
} as const;

All registries mapped in module-filter-registries.ts (central registry map, avoids circular imports).

Server-side validation: Filters are re-validated on apply via applyAdvancedFilters() — prevents schema drift if registry changes. Public metadata endpoint (GET /{module}/filter-fields) returns field names + types (hides internal prismaPath).

Cross-reference: See system-architecture.md § 2f for integration flow.


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
}

OTA Inbound XML Processing

Module location: apps/api/src/modules/ota-inbound/

OTA push webhooks (e.g., Trip.com OpenTravel) receive XML reservations and require constant-time, deterministic processing. The inbound pipeline follows this sequence:

  1. Auth — Extract POS signature, validate HMAC-SHA256 against known OTA secret (constant-time comparison)
  2. Redact PAN — Strip full card numbers; keep last 4 digits only in logs
  3. Persist Raw — Store complete XML in OtaInboundMessage table (idempotency key = echoToken)
  4. Validate — Check DOCTYPE, structure; reject malformed or unsupported message types
  5. Parse — Convert XML → domain model (Booking, Customer, LineItem)
  6. Dispatch Handler — Route to per-messageType handler (new/modify/cancel/unknown)
  7. HTTP 200 Always — Return OTA-spec XML response (never JSON) with status code 200 regardless of handler outcome (Trip.com expects immediate ACK)

Why "200 always": OTA webhooks treat 4xx/5xx as delivery failures and retry indefinitely. A single bad booking should not block all future webhooks. Async handlers process disputed cases; HTTP response confirms receipt only.

PAN redaction rule: Any request body containing card data must:

  • Redact in logs: last4 = card_number.slice(-4)
  • Audit entry shows [REDACTED] for full PAN
  • Durable message log stores original XML (PCI-DSS compliance requires retained proof)

Cross-reference: docs/ota-trip-inbound-reservation.md for simulator runbook and account manager setup steps.


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