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 complexDTO Validation Pattern
// ✓ 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
// ✓ 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
// ✓ 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
// ✓ 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
// ✓ 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
// ✓ 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
// ✓ 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
@IsUUIDdecorators and always carry real UUIDs from the fetched entity — never the route param.
Module location: apps/api/src/common/entity-code/ (@Global)
// ✓ 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
// ✓ 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:
// 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
// ✓ 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
// ✓ 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
// ✓ 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
// ✓ 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
// ✓ 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)
// ✓ 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:
- Auth — Extract POS signature, validate HMAC-SHA256 against known OTA secret (constant-time comparison)
- Redact PAN — Strip full card numbers; keep last 4 digits only in logs
- Persist Raw — Store complete XML in
OtaInboundMessagetable (idempotency key = echoToken) - Validate — Check DOCTYPE, structure; reject malformed or unsupported message types
- Parse — Convert XML → domain model (Booking, Customer, LineItem)
- Dispatch Handler — Route to per-messageType handler (new/modify/cancel/unknown)
- 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.
// ✓ 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 Type | Target |
|---|---|
| Services | 80% |
| Controllers | 60% |
| Helpers | 90% |
| Guards | 80% |
| Pipes/Filters | 70% |
pnpm test:cov