260223:1415 20260223 nextJS & nestJS Best pratices
All checks were successful
Build and Deploy / deploy (push) Successful in 4m44s

This commit is contained in:
admin
2026-02-23 14:15:06 +07:00
parent c90a664f53
commit ef16817f38
164 changed files with 24815 additions and 311 deletions

View File

@@ -0,0 +1,24 @@
name: Branch Protection
on:
pull_request:
branches: [main]
jobs:
check-branch:
runs-on: ubuntu-latest
steps:
- name: Block docs branch merge to main
if: github.head_ref == 'docs'
run: |
echo "::error::Merging 'docs' branch into 'main' is not allowed."
echo ""
echo "The 'docs' branch contains the documentation website which should"
echo "remain separate from the main skill files to keep installations lightweight."
echo ""
echo "If you need to sync changes, cherry-pick specific commits instead."
exit 1
- name: Branch check passed
if: github.head_ref != 'docs'
run: echo "Branch check passed - not merging from docs branch"

View File

@@ -0,0 +1,61 @@
name: Deploy to GitHub Pages
on:
push:
branches: [docs]
paths:
- 'website/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: npm ci
working-directory: website
- name: Build
run: npm run build
working-directory: website
- name: Copy index.html to 404.html for SPA routing
run: cp website/dist/index.html website/dist/404.html
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: website/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -0,0 +1,29 @@
# Dependencies
node_modules/
# Website (lives on docs branch)
website/
# Build outputs
dist/
*.js
*.d.ts
*.js.map
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
# NestJS Best Practices
📖 [For Humans <3](https://kadajett.github.io/agent-nestjs-skills/)
A structured repository for creating and maintaining NestJS Best Practices optimized for agents and LLMs.
## Installation
Install this skill using [skills](https://github.com/vercel-labs/skills):
```bash
# GitHub shorthand
npx skills add Kadajett/agent-nestjs-skills
# Install globally (available across all projects)
npx skills add Kadajett/agent-nestjs-skills --global
# Install for specific agents
npx skills add Kadajett/agent-nestjs-skills -a claude-code -a cursor
```
### Supported Agents
- Claude Code
- OpenCode
- Codex
- Cursor
- Antigravity
- Roo Code
## Structure
- `rules/` - Individual rule files (one per rule)
- `_sections.md` - Section metadata (titles, impacts, descriptions)
- `_template.md` - Template for creating new rules
- `area-description.md` - Individual rule files
- `scripts/` - Build scripts and utilities
- `metadata.json` - Document metadata (version, organization, abstract)
- __`AGENTS.md`__ - Compiled output (generated)
## Getting Started
1. Install dependencies:
```bash
cd scripts && npm install
```
2. Build AGENTS.md from rules:
```bash
npm run build
# or
./scripts/build.sh
```
## Creating a New Rule
1. Copy `rules/_template.md` to `rules/area-description.md`
2. Choose the appropriate area prefix:
- `arch-` for Architecture (Section 1)
- `di-` for Dependency Injection (Section 2)
- `error-` for Error Handling (Section 3)
- `security-` for Security (Section 4)
- `perf-` for Performance (Section 5)
- `test-` for Testing (Section 6)
- `db-` for Database & ORM (Section 7)
- `api-` for API Design (Section 8)
- `micro-` for Microservices (Section 9)
- `devops-` for DevOps & Deployment (Section 10)
3. Fill in the frontmatter and content
4. Ensure you have clear examples with explanations
5. Run the build script to regenerate AGENTS.md
## Rule File Structure
Each rule file should follow this structure:
```markdown
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description
tags: tag1, tag2, tag3
---
## Rule Title Here
Brief explanation of the rule and why it matters.
**Incorrect (description of what's wrong):**
```typescript
// Bad code example
```
**Correct (description of what's right):**
```typescript
// Good code example
```
Optional explanatory text after examples.
Reference: [NestJS Documentation](https://docs.nestjs.com)
## File Naming Convention
- Files starting with `_` are special (excluded from build)
- Rule files: `area-description.md` (e.g., `arch-avoid-circular-deps.md`)
- Section is automatically inferred from filename prefix
- Rules are sorted alphabetically by title within each section
- IDs (e.g., 1.1, 1.2) are auto-generated during build
## Impact Levels
| Level | Description |
|-------|-------------|
| CRITICAL | Violations cause runtime errors, security vulnerabilities, or architectural breakdown |
| HIGH | Significant impact on reliability, security, or maintainability |
| MEDIUM-HIGH | Notable impact on quality and developer experience |
| MEDIUM | Moderate impact on code quality and best practices |
| LOW-MEDIUM | Minor improvements for consistency and maintainability |
## Scripts
- `npm run build` (in scripts/) - Compile rules into AGENTS.md
## Contributing
When adding or modifying rules:
1. Use the correct filename prefix for your section
2. Follow the `_template.md` structure
3. Include clear bad/good examples with explanations
4. Add appropriate tags
5. Run the build script to regenerate AGENTS.md
6. Rules are automatically sorted by title - no need to manage numbers!
## Documentation Website
The documentation website source code lives on the [`docs` branch](https://github.com/Kadajett/agent-nestjs-skills/tree/docs/website). This separation keeps the skill installation lightweight while maintaining the full documentation site.
To contribute to the website:
```bash
git checkout docs
cd website
npm install
npm run dev
```
## Acknowledgments
- Inspired by the [Vercel React Best Practices](https://github.com/vercel-labs/agent-skills) skill structure
- Compatible with [skills](https://github.com/vercel-labs/skills) for easy installation across coding agents
## Compatible Agents
These NestJS skills work with:
- [Claude Code](https://claude.ai/code) - Anthropic's official CLI
- [AdaL](https://sylph.ai/adal) - Self-evolving AI coding agent with MCP support

View File

@@ -0,0 +1,130 @@
---
name: nestjs-best-practices
description: NestJS best practices and architecture patterns for building production-ready applications. This skill should be used when writing, reviewing, or refactoring NestJS code to ensure proper patterns for modules, dependency injection, security, and performance.
license: MIT
metadata:
author: Kadajett
version: "1.1.0"
---
# NestJS Best Practices
Comprehensive best practices guide for NestJS applications. Contains 40 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new NestJS modules, controllers, or services
- Implementing authentication and authorization
- Reviewing code for architecture and security issues
- Refactoring existing NestJS codebases
- Optimizing performance or database queries
- Building microservices architectures
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Architecture | CRITICAL | `arch-` |
| 2 | Dependency Injection | CRITICAL | `di-` |
| 3 | Error Handling | HIGH | `error-` |
| 4 | Security | HIGH | `security-` |
| 5 | Performance | HIGH | `perf-` |
| 6 | Testing | MEDIUM-HIGH | `test-` |
| 7 | Database & ORM | MEDIUM-HIGH | `db-` |
| 8 | API Design | MEDIUM | `api-` |
| 9 | Microservices | MEDIUM | `micro-` |
| 10 | DevOps & Deployment | LOW-MEDIUM | `devops-` |
## Quick Reference
### 1. Architecture (CRITICAL)
- `arch-avoid-circular-deps` - Avoid circular module dependencies
- `arch-feature-modules` - Organize by feature, not technical layer
- `arch-module-sharing` - Proper module exports/imports, avoid duplicate providers
- `arch-single-responsibility` - Focused services over "god services"
- `arch-use-repository-pattern` - Abstract database logic for testability
- `arch-use-events` - Event-driven architecture for decoupling
### 2. Dependency Injection (CRITICAL)
- `di-avoid-service-locator` - Avoid service locator anti-pattern
- `di-interface-segregation` - Interface Segregation Principle (ISP)
- `di-liskov-substitution` - Liskov Substitution Principle (LSP)
- `di-prefer-constructor-injection` - Constructor over property injection
- `di-scope-awareness` - Understand singleton/request/transient scopes
- `di-use-interfaces-tokens` - Use injection tokens for interfaces
### 3. Error Handling (HIGH)
- `error-use-exception-filters` - Centralized exception handling
- `error-throw-http-exceptions` - Use NestJS HTTP exceptions
- `error-handle-async-errors` - Handle async errors properly
### 4. Security (HIGH)
- `security-auth-jwt` - Secure JWT authentication
- `security-validate-all-input` - Validate with class-validator
- `security-use-guards` - Authentication and authorization guards
- `security-sanitize-output` - Prevent XSS attacks
- `security-rate-limiting` - Implement rate limiting
### 5. Performance (HIGH)
- `perf-async-hooks` - Proper async lifecycle hooks
- `perf-use-caching` - Implement caching strategies
- `perf-optimize-database` - Optimize database queries
- `perf-lazy-loading` - Lazy load modules for faster startup
### 6. Testing (MEDIUM-HIGH)
- `test-use-testing-module` - Use NestJS testing utilities
- `test-e2e-supertest` - E2E testing with Supertest
- `test-mock-external-services` - Mock external dependencies
### 7. Database & ORM (MEDIUM-HIGH)
- `db-use-transactions` - Transaction management
- `db-avoid-n-plus-one` - Avoid N+1 query problems
- `db-use-migrations` - Use migrations for schema changes
### 8. API Design (MEDIUM)
- `api-use-dto-serialization` - DTO and response serialization
- `api-use-interceptors` - Cross-cutting concerns
- `api-versioning` - API versioning strategies
- `api-use-pipes` - Input transformation with pipes
### 9. Microservices (MEDIUM)
- `micro-use-patterns` - Message and event patterns
- `micro-use-health-checks` - Health checks for orchestration
- `micro-use-queues` - Background job processing
### 10. DevOps & Deployment (LOW-MEDIUM)
- `devops-use-config-module` - Environment configuration
- `devops-use-logging` - Structured logging
- `devops-graceful-shutdown` - Zero-downtime deployments
## How to Use
Read individual rule files for detailed explanations and code examples:
```
rules/arch-avoid-circular-deps.md
rules/security-validate-all-input.md
rules/_sections.md
```
Each rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
## Full Compiled Document
For the complete guide with all rules expanded: `AGENTS.md`

View File

@@ -0,0 +1,182 @@
---
title: Use DTOs and Serialization for API Responses
impact: MEDIUM
impactDescription: Response DTOs prevent accidental data exposure and ensure consistency
tags: api, dto, serialization, class-transformer
---
## Use DTOs and Serialization for API Responses
Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract.
**Incorrect (returning entities directly or manual spreading):**
```typescript
// Return entities directly
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findById(id);
// Returns: { id, email, passwordHash, ssn, internalNotes, ... }
// Exposes sensitive data!
}
}
// Manual object spreading (error-prone)
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.usersService.findById(id);
return {
id: user.id,
email: user.email,
name: user.name,
// Easy to forget to exclude sensitive fields
// Hard to maintain across endpoints
};
}
```
**Correct (use class-transformer with @Exclude and response DTOs):**
```typescript
// Enable class-transformer globally
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
await app.listen(3000);
}
// Entity with serialization control
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
email: string;
@Column()
name: string;
@Column()
@Exclude() // Never include in responses
passwordHash: string;
@Column({ nullable: true })
@Exclude()
ssn: string;
@Column({ default: false })
@Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests
isAdmin: boolean;
@CreateDateColumn()
createdAt: Date;
@Column()
@Exclude()
internalNotes: string;
}
// Now returning entity is safe
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findById(id);
// Returns: { id, email, name, createdAt }
// Sensitive fields excluded automatically
}
}
// For different response shapes, use explicit DTOs
export class UserResponseDto {
@Expose()
id: string;
@Expose()
email: string;
@Expose()
name: string;
@Expose()
@Transform(({ obj }) => obj.posts?.length || 0)
postCount: number;
constructor(partial: Partial<User>) {
Object.assign(this, partial);
}
}
export class UserDetailResponseDto extends UserResponseDto {
@Expose()
createdAt: Date;
@Expose()
@Type(() => PostResponseDto)
posts: PostResponseDto[];
}
// Controller with explicit DTOs
@Controller('users')
export class UsersController {
@Get()
@SerializeOptions({ type: UserResponseDto })
async findAll(): Promise<UserResponseDto[]> {
const users = await this.usersService.findAll();
return users.map(u => plainToInstance(UserResponseDto, u));
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> {
const user = await this.usersService.findByIdWithPosts(id);
return plainToInstance(UserDetailResponseDto, user, {
excludeExtraneousValues: true,
});
}
}
// Groups for conditional serialization
export class UserDto {
@Expose()
id: string;
@Expose()
name: string;
@Expose({ groups: ['admin'] })
email: string;
@Expose({ groups: ['admin'] })
createdAt: Date;
@Expose({ groups: ['admin', 'owner'] })
settings: UserSettings;
}
@Controller('users')
export class UsersController {
@Get()
@SerializeOptions({ groups: ['public'] })
async findAllPublic(): Promise<UserDto[]> {
// Returns: { id, name }
}
@Get('admin')
@UseGuards(AdminGuard)
@SerializeOptions({ groups: ['admin'] })
async findAllAdmin(): Promise<UserDto[]> {
// Returns: { id, name, email, createdAt }
}
@Get('me')
@SerializeOptions({ groups: ['owner'] })
async getProfile(@CurrentUser() user: User): Promise<UserDto> {
// Returns: { id, name, settings }
}
}
```
Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization)

View File

@@ -0,0 +1,202 @@
---
title: Use Interceptors for Cross-Cutting Concerns
impact: MEDIUM-HIGH
impactDescription: Interceptors provide clean separation for cross-cutting logic
tags: api, interceptors, logging, caching
---
## Use Interceptors for Cross-Cutting Concerns
Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams.
**Incorrect (logging and transformation in every method):**
```typescript
// Logging in every controller method
@Controller('users')
export class UsersController {
@Get()
async findAll(): Promise<User[]> {
const start = Date.now();
this.logger.log('findAll called');
const users = await this.usersService.findAll();
this.logger.log(`findAll completed in ${Date.now() - start}ms`);
return users;
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
const start = Date.now();
this.logger.log(`findOne called with id: ${id}`);
const user = await this.usersService.findOne(id);
this.logger.log(`findOne completed in ${Date.now() - start}ms`);
return user;
}
// Repeated in every method!
}
// Manual response wrapping
@Get()
async findAll(): Promise<{ data: User[]; meta: Meta }> {
const users = await this.usersService.findAll();
return {
data: users,
meta: { timestamp: new Date(), count: users.length },
};
}
```
**Correct (use interceptors for cross-cutting concerns):**
```typescript
// Logging interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url, body } = request;
const now = Date.now();
return next.handle().pipe(
tap({
next: (data) => {
const response = context.switchToHttp().getResponse();
this.logger.log(
`${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`,
);
},
error: (error) => {
this.logger.error(
`${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`,
error.stack,
);
},
}),
);
}
}
// Response transformation interceptor
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
data,
meta: {
timestamp: new Date().toISOString(),
path: context.switchToHttp().getRequest().url,
},
})),
);
}
}
// Timeout interceptor
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(5000),
catchError((err) => {
if (err instanceof TimeoutError) {
throw new RequestTimeoutException('Request timed out');
}
throw err;
}),
);
}
}
// Apply globally or per-controller
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
],
})
export class AppModule {}
// Or per-controller
@Controller('users')
@UseInterceptors(LoggingInterceptor)
export class UsersController {
@Get()
async findAll(): Promise<User[]> {
// Clean business logic only
return this.usersService.findAll();
}
}
// Custom cache interceptor with TTL
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
private cacheManager: Cache,
private reflector: Reflector,
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
// Only cache GET requests
if (request.method !== 'GET') {
return next.handle();
}
const cacheKey = this.generateKey(request);
const ttl = this.reflector.get<number>('cacheTTL', context.getHandler()) || 300;
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return of(cached);
}
return next.handle().pipe(
tap((response) => {
this.cacheManager.set(cacheKey, response, ttl);
}),
);
}
private generateKey(request: Request): string {
return `cache:${request.url}:${JSON.stringify(request.query)}`;
}
}
// Usage with custom TTL
@Get()
@SetMetadata('cacheTTL', 600)
@UseInterceptors(HttpCacheInterceptor)
async findAll(): Promise<User[]> {
return this.usersService.findAll();
}
// Error mapping interceptor
@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
catchError((error) => {
if (error instanceof EntityNotFoundError) {
throw new NotFoundException(error.message);
}
if (error instanceof QueryFailedError) {
if (error.message.includes('duplicate')) {
throw new ConflictException('Resource already exists');
}
}
throw error;
}),
);
}
}
```
Reference: [NestJS Interceptors](https://docs.nestjs.com/interceptors)

View File

@@ -0,0 +1,205 @@
---
title: Use Pipes for Input Transformation
impact: MEDIUM
impactDescription: Pipes ensure clean, validated data reaches your handlers
tags: api, pipes, validation, transformation
---
## Use Pipes for Input Transformation
Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers.
**Incorrect (manual type parsing in handlers):**
```typescript
// Manual type parsing in handlers
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
// Manual validation in every handler
const uuid = id.trim();
if (!isUUID(uuid)) {
throw new BadRequestException('Invalid UUID');
}
return this.usersService.findOne(uuid);
}
@Get()
async findAll(
@Query('page') page: string,
@Query('limit') limit: string,
): Promise<User[]> {
// Manual parsing and defaults
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 10;
return this.usersService.findAll(pageNum, limitNum);
}
}
// Type coercion without validation
@Get()
async search(@Query('price') price: string): Promise<Product[]> {
const priceNum = +price; // NaN if invalid, no error
return this.productsService.findByPrice(priceNum);
}
```
**Correct (use built-in and custom pipes):**
```typescript
// Use built-in pipes for common transformations
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
// id is guaranteed to be a valid UUID
return this.usersService.findOne(id);
}
@Get()
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
): Promise<User[]> {
// Automatic defaults and type conversion
return this.usersService.findAll(page, limit);
}
@Get('by-status/:status')
async findByStatus(
@Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus,
): Promise<User[]> {
return this.usersService.findByStatus(status);
}
}
// Custom pipe for business logic
@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
transform(value: string): Date {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new BadRequestException('Invalid date format');
}
return date;
}
}
@Get('reports')
async getReports(
@Query('from', ParseDatePipe) from: Date,
@Query('to', ParseDatePipe) to: Date,
): Promise<Report[]> {
return this.reportsService.findBetween(from, to);
}
// Custom transformation pipes
@Injectable()
export class NormalizeEmailPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value) return value;
return value.trim().toLowerCase();
}
}
// Parse comma-separated values
@Injectable()
export class ParseArrayPipe implements PipeTransform<string, string[]> {
transform(value: string): string[] {
if (!value) return [];
return value.split(',').map((v) => v.trim()).filter(Boolean);
}
}
@Get('products')
async findProducts(
@Query('ids', ParseArrayPipe) ids: string[],
@Query('email', NormalizeEmailPipe) email: string,
): Promise<Product[]> {
// ids is already an array, email is normalized
return this.productsService.findByIds(ids);
}
// Sanitize HTML input
@Injectable()
export class SanitizeHtmlPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value) return value;
return sanitizeHtml(value, { allowedTags: [] });
}
}
// Global validation pipe with transformation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip non-DTO properties
transform: true, // Auto-transform to DTO types
transformOptions: {
enableImplicitConversion: true, // Convert query strings to numbers
},
forbidNonWhitelisted: true, // Throw on extra properties
}),
);
// DTO with transformation decorators
export class FindProductsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 10;
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsString()
search?: string;
@IsOptional()
@Transform(({ value }) => value?.split(','))
@IsArray()
@IsString({ each: true })
categories?: string[];
}
@Get()
async findAll(@Query() dto: FindProductsDto): Promise<Product[]> {
// dto is already transformed and validated
return this.productsService.findAll(dto);
}
// Pipe error customization
@Injectable()
export class CustomParseIntPipe extends ParseIntPipe {
constructor() {
super({
exceptionFactory: (error) =>
new BadRequestException(`${error} must be a valid integer`),
});
}
}
// Or use options on built-in pipes
@Get(':id')
async findOne(
@Param(
'id',
new ParseIntPipe({
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
exceptionFactory: () => new NotAcceptableException('ID must be numeric'),
}),
)
id: number,
): Promise<Item> {
return this.itemsService.findOne(id);
}
```
Reference: [NestJS Pipes](https://docs.nestjs.com/pipes)

View File

@@ -0,0 +1,191 @@
---
title: Use API Versioning for Breaking Changes
impact: MEDIUM
impactDescription: Versioning allows you to evolve APIs without breaking existing clients
tags: api, versioning, breaking-changes, compatibility
---
## Use API Versioning for Breaking Changes
Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints.
**Incorrect (breaking changes without versioning):**
```typescript
// Breaking changes without versioning
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
// Original response: { id, name, email }
// Later changed to: { id, firstName, lastName, emailAddress }
// Old clients break!
return this.usersService.findOne(id);
}
}
// Manual versioning in routes
@Controller('v1/users')
export class UsersV1Controller {}
@Controller('v2/users')
export class UsersV2Controller {}
// Inconsistent, error-prone, hard to maintain
```
**Correct (use NestJS built-in versioning):**
```typescript
// Enable versioning in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// URI versioning: /v1/users, /v2/users
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
// Or header versioning: X-API-Version: 1
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
defaultVersion: '1',
});
// Or media type: Accept: application/json;v=1
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=',
defaultVersion: '1',
});
await app.listen(3000);
}
// Version-specific controllers
@Controller('users')
@Version('1')
export class UsersV1Controller {
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserV1Response> {
const user = await this.usersService.findOne(id);
// V1 response format
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
@Controller('users')
@Version('2')
export class UsersV2Controller {
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserV2Response> {
const user = await this.usersService.findOne(id);
// V2 response format with breaking changes
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
emailAddress: user.email,
createdAt: user.createdAt,
};
}
}
// Per-route versioning - different versions for different routes
@Controller('users')
export class UsersController {
@Get()
@Version('1')
findAllV1(): Promise<UserV1Response[]> {
return this.usersService.findAllV1();
}
@Get()
@Version('2')
findAllV2(): Promise<UserV2Response[]> {
return this.usersService.findAllV2();
}
@Get(':id')
@Version(['1', '2']) // Same handler for multiple versions
findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findOne(id);
}
@Post()
@Version(VERSION_NEUTRAL) // Available in all versions
create(@Body() dto: CreateUserDto): Promise<User> {
return this.usersService.create(dto);
}
}
// Shared service with version-specific logic
@Injectable()
export class UsersService {
async findOne(id: string, version: string): Promise<any> {
const user = await this.repo.findOne({ where: { id } });
if (version === '1') {
return this.toV1Response(user);
}
return this.toV2Response(user);
}
private toV1Response(user: User): UserV1Response {
return {
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
};
}
private toV2Response(user: User): UserV2Response {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
emailAddress: user.email,
createdAt: user.createdAt,
};
}
}
// Controller extracts version
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(
@Param('id') id: string,
@Headers('X-API-Version') version: string = '1',
): Promise<any> {
return this.usersService.findOne(id, version);
}
}
// Deprecation strategy - mark old versions as deprecated
@Controller('users')
@Version('1')
@UseInterceptors(DeprecationInterceptor)
export class UsersV1Controller {
// All V1 routes will include deprecation warning
}
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const response = context.switchToHttp().getResponse();
response.setHeader('Deprecation', 'true');
response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT');
response.setHeader('Link', '</v2/users>; rel="successor-version"');
return next.handle();
}
}
```
Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning)

View File

@@ -0,0 +1,80 @@
---
title: Avoid Circular Dependencies
impact: CRITICAL
impactDescription: "#1 cause of runtime crashes"
tags: architecture, modules, dependencies
---
## Avoid Circular Dependencies
Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications.
**Incorrect (circular module imports):**
```typescript
// users.module.ts
@Module({
imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// orders.module.ts
@Module({
imports: [UsersModule], // Circular dependency!
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
```
**Correct (extract shared logic or use events):**
```typescript
// Option 1: Extract shared logic to a third module
// shared.module.ts
@Module({
providers: [SharedService],
exports: [SharedService],
})
export class SharedModule {}
// users.module.ts
@Module({
imports: [SharedModule],
providers: [UsersService],
})
export class UsersModule {}
// orders.module.ts
@Module({
imports: [SharedModule],
providers: [OrdersService],
})
export class OrdersModule {}
// Option 2: Use events for decoupled communication
// users.service.ts
@Injectable()
export class UsersService {
constructor(private eventEmitter: EventEmitter2) {}
async createUser(data: CreateUserDto) {
const user = await this.userRepo.save(data);
this.eventEmitter.emit('user.created', user);
return user;
}
}
// orders.service.ts
@Injectable()
export class OrdersService {
@OnEvent('user.created')
handleUserCreated(user: User) {
// React to user creation without direct dependency
}
}
```
Reference: [NestJS Circular Dependency](https://docs.nestjs.com/fundamentals/circular-dependency)

View File

@@ -0,0 +1,82 @@
---
title: Organize by Feature Modules
impact: CRITICAL
impactDescription: "3-5x faster onboarding and development"
tags: architecture, modules, organization
---
## Organize by Feature Modules
Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development.
**Incorrect (technical layer organization):**
```typescript
// Technical layer organization (anti-pattern)
src/
controllers/
users.controller.ts
orders.controller.ts
products.controller.ts
services/
users.service.ts
orders.service.ts
products.service.ts
entities/
user.entity.ts
order.entity.ts
product.entity.ts
app.module.ts // Imports everything directly
```
**Correct (feature module organization):**
```typescript
// Feature module organization
src/
users/
dto/
create-user.dto.ts
update-user.dto.ts
entities/
user.entity.ts
users.controller.ts
users.service.ts
users.repository.ts
users.module.ts
orders/
dto/
entities/
orders.controller.ts
orders.service.ts
orders.module.ts
shared/
guards/
interceptors/
filters/
shared.module.ts
app.module.ts
// users.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService], // Only export what others need
})
export class UsersModule {}
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot(),
UsersModule,
OrdersModule,
SharedModule,
],
})
export class AppModule {}
```
Reference: [NestJS Modules](https://docs.nestjs.com/modules)

View File

@@ -0,0 +1,141 @@
---
title: Use Proper Module Sharing Patterns
impact: CRITICAL
impactDescription: Prevents duplicate instances, memory leaks, and state inconsistency
tags: architecture, modules, sharing, exports
---
## Use Proper Module Sharing Patterns
NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed.
**Incorrect (service provided in multiple modules):**
```typescript
// StorageService provided directly in multiple modules - WRONG
// storage.service.ts
@Injectable()
export class StorageService {
private cache = new Map(); // Each instance has separate state!
store(key: string, value: any) {
this.cache.set(key, value);
}
}
// app.module.ts
@Module({
providers: [StorageService], // Instance #1
controllers: [AppController],
})
export class AppModule {}
// videos.module.ts
@Module({
providers: [StorageService], // Instance #2 - different from AppModule!
controllers: [VideosController],
})
export class VideosModule {}
// Problems:
// 1. Two separate StorageService instances exist
// 2. cache.set() in VideosModule doesn't affect AppModule's cache
// 3. Memory wasted on duplicate instances
// 4. Debugging nightmares when state doesn't sync
```
**Correct (dedicated module with exports):**
```typescript
// storage/storage.module.ts
@Module({
providers: [StorageService],
exports: [StorageService], // Make available to importers
})
export class StorageModule {}
// videos/videos.module.ts
@Module({
imports: [StorageModule], // Import the module, not the service
controllers: [VideosController],
providers: [VideosService],
})
export class VideosModule {}
// channels/channels.module.ts
@Module({
imports: [StorageModule], // Same instance shared
controllers: [ChannelsController],
providers: [ChannelsService],
})
export class ChannelsModule {}
// app.module.ts
@Module({
imports: [
StorageModule, // Only if AppModule itself needs StorageService
VideosModule,
ChannelsModule,
],
})
export class AppModule {}
// Now all modules share the SAME StorageService instance
```
**When to use @Global() (sparingly):**
```typescript
// ONLY for truly cross-cutting concerns
@Global()
@Module({
providers: [ConfigService, LoggerService],
exports: [ConfigService, LoggerService],
})
export class CoreModule {}
// Import once in AppModule
@Module({
imports: [CoreModule], // Registered globally, available everywhere
})
export class AppModule {}
// Other modules don't need to import CoreModule
@Module({
controllers: [UsersController],
providers: [UsersService], // Can inject ConfigService without importing
})
export class UsersModule {}
// WARNING: Don't make everything global!
// - Hides dependencies (can't see what a module needs from imports)
// - Makes testing harder
// - Reserve for: config, logging, database connections
```
**Module re-exporting pattern:**
```typescript
// common.module.ts - shared utilities
@Module({
providers: [DateService, ValidationService],
exports: [DateService, ValidationService],
})
export class CommonModule {}
// core.module.ts - re-exports common for convenience
@Module({
imports: [CommonModule, DatabaseModule],
exports: [CommonModule, DatabaseModule], // Re-export for consumers
})
export class CoreModule {}
// feature.module.ts - imports CoreModule, gets both
@Module({
imports: [CoreModule], // Gets CommonModule + DatabaseModule
controllers: [FeatureController],
})
export class FeatureModule {}
```
Reference: [NestJS Modules](https://docs.nestjs.com/modules#shared-modules)

View File

@@ -0,0 +1,106 @@
---
title: Single Responsibility for Services
impact: CRITICAL
impactDescription: "40%+ improvement in testability"
tags: architecture, services, single-responsibility
---
## Single Responsibility for Services
Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+.
**Incorrect (god service anti-pattern):**
```typescript
// God service anti-pattern
@Injectable()
export class UserAndOrderService {
constructor(
private userRepo: UserRepository,
private orderRepo: OrderRepository,
private mailer: MailService,
private payment: PaymentService,
) {}
async createUser(dto: CreateUserDto) {
const user = await this.userRepo.save(dto);
await this.mailer.sendWelcome(user);
return user;
}
async createOrder(userId: string, dto: CreateOrderDto) {
const order = await this.orderRepo.save({ userId, ...dto });
await this.payment.charge(order);
await this.mailer.sendOrderConfirmation(order);
return order;
}
async calculateOrderStats(userId: string) {
// Stats logic mixed in
}
async validatePayment(orderId: string) {
// Payment logic mixed in
}
}
```
**Correct (focused services with single responsibility):**
```typescript
// Focused services with single responsibility
@Injectable()
export class UsersService {
constructor(private userRepo: UserRepository) {}
async create(dto: CreateUserDto): Promise<User> {
return this.userRepo.save(dto);
}
async findById(id: string): Promise<User> {
return this.userRepo.findOneOrFail({ where: { id } });
}
}
@Injectable()
export class OrdersService {
constructor(private orderRepo: OrderRepository) {}
async create(userId: string, dto: CreateOrderDto): Promise<Order> {
return this.orderRepo.save({ userId, ...dto });
}
async findByUser(userId: string): Promise<Order[]> {
return this.orderRepo.find({ where: { userId } });
}
}
@Injectable()
export class OrderStatsService {
constructor(private orderRepo: OrderRepository) {}
async calculateForUser(userId: string): Promise<OrderStats> {
// Focused stats calculation
}
}
// Orchestration in controller or dedicated orchestrator
@Controller('orders')
export class OrdersController {
constructor(
private orders: OrdersService,
private payment: PaymentService,
private notifications: NotificationService,
) {}
@Post()
async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) {
const order = await this.orders.create(user.id, dto);
await this.payment.charge(order);
await this.notifications.sendOrderConfirmation(order);
return order;
}
}
```
Reference: [NestJS Providers](https://docs.nestjs.com/providers)

View File

@@ -0,0 +1,108 @@
---
title: Use Event-Driven Architecture for Decoupling
impact: MEDIUM-HIGH
impactDescription: Enables async processing and modularity
tags: architecture, events, decoupling
---
## Use Event-Driven Architecture for Decoupling
Use `@nestjs/event-emitter` for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing.
**Incorrect (direct service coupling):**
```typescript
// Direct service coupling
@Injectable()
export class OrdersService {
constructor(
private inventoryService: InventoryService,
private emailService: EmailService,
private analyticsService: AnalyticsService,
private notificationService: NotificationService,
private loyaltyService: LoyaltyService,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repo.save(dto);
// Tight coupling - OrdersService knows about all consumers
await this.inventoryService.reserve(order.items);
await this.emailService.sendConfirmation(order);
await this.analyticsService.track('order_created', order);
await this.notificationService.push(order.userId, 'Order placed');
await this.loyaltyService.addPoints(order.userId, order.total);
// Adding new behavior requires modifying this service
return order;
}
}
```
**Correct (event-driven decoupling):**
```typescript
// Use EventEmitter for decoupling
import { EventEmitter2 } from '@nestjs/event-emitter';
// Define event
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: OrderItem[],
public readonly total: number,
) {}
}
// Service emits events
@Injectable()
export class OrdersService {
constructor(
private eventEmitter: EventEmitter2,
private repo: Repository<Order>,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repo.save(dto);
// Emit event - no knowledge of consumers
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent(order.id, order.userId, order.items, order.total),
);
return order;
}
}
// Listeners in separate modules
@Injectable()
export class InventoryListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.inventoryService.reserve(event.items);
}
}
@Injectable()
export class EmailListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.emailService.sendConfirmation(event.orderId);
}
}
@Injectable()
export class AnalyticsListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.analyticsService.track('order_created', {
orderId: event.orderId,
total: event.total,
});
}
}
```
Reference: [NestJS Events](https://docs.nestjs.com/techniques/events)

View File

@@ -0,0 +1,97 @@
---
title: Use Repository Pattern for Data Access
impact: HIGH
impactDescription: Decouples business logic from database
tags: architecture, repository, data-access
---
## Use Repository Pattern for Data Access
Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code.
**Incorrect (complex queries in services):**
```typescript
// Complex queries in services
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User) private repo: Repository<User>,
) {}
async findActiveWithOrders(minOrders: number): Promise<User[]> {
// Complex query logic mixed with business logic
return this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.where('user.isActive = :active', { active: true })
.andWhere('user.deletedAt IS NULL')
.groupBy('user.id')
.having('COUNT(order.id) >= :min', { min: minOrders })
.orderBy('user.createdAt', 'DESC')
.getMany();
}
// Service becomes bloated with query logic
}
```
**Correct (custom repository with encapsulated queries):**
```typescript
// Custom repository with encapsulated queries
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User) private repo: Repository<User>,
) {}
async findById(id: string): Promise<User | null> {
return this.repo.findOne({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.repo.findOne({ where: { email } });
}
async findActiveWithMinOrders(minOrders: number): Promise<User[]> {
return this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.where('user.isActive = :active', { active: true })
.andWhere('user.deletedAt IS NULL')
.groupBy('user.id')
.having('COUNT(order.id) >= :min', { min: minOrders })
.orderBy('user.createdAt', 'DESC')
.getMany();
}
async save(user: User): Promise<User> {
return this.repo.save(user);
}
}
// Clean service with business logic only
@Injectable()
export class UsersService {
constructor(private usersRepo: UsersRepository) {}
async getActiveUsersWithOrders(): Promise<User[]> {
return this.usersRepo.findActiveWithMinOrders(1);
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.usersRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email already registered');
}
const user = new User();
user.email = dto.email;
user.name = dto.name;
return this.usersRepo.save(user);
}
}
```
Reference: [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html)

View File

@@ -0,0 +1,139 @@
---
title: Avoid N+1 Query Problems
impact: HIGH
impactDescription: N+1 queries are one of the most common performance killers
tags: database, n-plus-one, queries, performance
---
## Avoid N+1 Query Problems
N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with `relations`, query builder joins, or DataLoader to batch queries efficiently.
**Incorrect (lazy loading in loops causes N+1):**
```typescript
// Lazy loading in loops causes N+1
@Injectable()
export class OrdersService {
async getOrdersWithItems(userId: string): Promise<Order[]> {
const orders = await this.orderRepo.find({ where: { userId } });
// 1 query for orders
for (const order of orders) {
// N additional queries - one per order!
order.items = await this.itemRepo.find({ where: { orderId: order.id } });
}
return orders;
}
}
// Accessing lazy relations without loading
@Controller('users')
export class UsersController {
@Get()
async findAll(): Promise<User[]> {
const users = await this.userRepo.find();
// If User.posts is lazy-loaded, serializing triggers N queries
return users; // Each user.posts access = 1 query
}
}
```
**Correct (use relations for eager loading):**
```typescript
// Use relations option for eager loading
@Injectable()
export class OrdersService {
async getOrdersWithItems(userId: string): Promise<Order[]> {
// Single query with JOIN
return this.orderRepo.find({
where: { userId },
relations: ['items', 'items.product'],
});
}
}
// Use QueryBuilder for complex joins
@Injectable()
export class UsersService {
async getUsersWithPostCounts(): Promise<UserWithPostCount[]> {
return this.userRepo
.createQueryBuilder('user')
.leftJoin('user.posts', 'post')
.select('user.id', 'id')
.addSelect('user.name', 'name')
.addSelect('COUNT(post.id)', 'postCount')
.groupBy('user.id')
.getRawMany();
}
async getActiveUsersWithPosts(): Promise<User[]> {
return this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('post.comments', 'comment')
.where('user.isActive = :active', { active: true })
.andWhere('post.status = :status', { status: 'published' })
.getMany();
}
}
// Use find options for specific fields
async getOrderSummaries(userId: string): Promise<OrderSummary[]> {
return this.orderRepo.find({
where: { userId },
relations: ['items'],
select: {
id: true,
total: true,
status: true,
items: {
id: true,
quantity: true,
price: true,
},
},
});
}
// Use DataLoader for GraphQL to batch and cache queries
import DataLoader from 'dataloader';
@Injectable({ scope: Scope.REQUEST })
export class PostsLoader {
constructor(private postsService: PostsService) {}
readonly batchPosts = new DataLoader<string, Post[]>(async (userIds) => {
// Single query for all users' posts
const posts = await this.postsService.findByUserIds([...userIds]);
// Group by userId
const postsMap = new Map<string, Post[]>();
for (const post of posts) {
const userPosts = postsMap.get(post.userId) || [];
userPosts.push(post);
postsMap.set(post.userId, userPosts);
}
// Return in same order as input
return userIds.map((id) => postsMap.get(id) || []);
});
}
// In resolver
@ResolveField()
async posts(@Parent() user: User): Promise<Post[]> {
// DataLoader batches multiple calls into single query
return this.postsLoader.batchPosts.load(user.id);
}
// Enable query logging in development to detect N+1
TypeOrmModule.forRoot({
logging: ['query', 'error'],
logger: 'advanced-console',
});
```
Reference: [TypeORM Relations](https://typeorm.io/relations)

View File

@@ -0,0 +1,129 @@
---
title: Use Database Migrations
impact: HIGH
impactDescription: Enables safe, repeatable database schema changes
tags: database, migrations, typeorm, schema
---
## Use Database Migrations
Never use `synchronize: true` in production. Use migrations for all schema changes. Migrations provide version control for your database, enable safe rollbacks, and ensure consistency across all environments.
**Incorrect (using synchronize or manual SQL):**
```typescript
// Use synchronize in production
TypeOrmModule.forRoot({
type: 'postgres',
synchronize: true, // DANGEROUS in production!
// Can drop columns, tables, or data
});
// Manual SQL in production
@Injectable()
export class DatabaseService {
async addColumn(): Promise<void> {
await this.dataSource.query('ALTER TABLE users ADD COLUMN age INT');
// No version control, no rollback, inconsistent across envs
}
}
// Modify entities without migration
@Entity()
export class User {
@Column()
email: string;
@Column() // Added without migration
newField: string; // Will crash in production if synchronize is false
}
```
**Correct (use migrations for all schema changes):**
```typescript
// Configure TypeORM for migrations
// data-source.ts
export const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
synchronize: false, // Always false in production
migrationsRun: true, // Run migrations on startup
});
// app.module.ts
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST'),
synchronize: config.get('NODE_ENV') === 'development', // Only in dev
migrations: ['dist/migrations/*.js'],
migrationsRun: true,
}),
});
// migrations/1705312800000-AddUserAge.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAge1705312800000 implements MigrationInterface {
name = 'AddUserAge1705312800000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add column with default to handle existing rows
await queryRunner.query(`
ALTER TABLE "users" ADD "age" integer DEFAULT 0
`);
// Add index for frequently queried columns
await queryRunner.query(`
CREATE INDEX "IDX_users_age" ON "users" ("age")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Always implement down for rollback
await queryRunner.query(`DROP INDEX "IDX_users_age"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "age"`);
}
}
// Safe column rename (two-step)
export class RenameNameToFullName1705312900000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Step 1: Add new column
await queryRunner.query(`
ALTER TABLE "users" ADD "full_name" varchar(255)
`);
// Step 2: Copy data
await queryRunner.query(`
UPDATE "users" SET "full_name" = "name"
`);
// Step 3: Add NOT NULL constraint
await queryRunner.query(`
ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL
`);
// Step 4: Drop old column (after verifying app works)
await queryRunner.query(`
ALTER TABLE "users" DROP COLUMN "name"
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "name" varchar(255)`);
await queryRunner.query(`UPDATE "users" SET "name" = "full_name"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`);
}
}
```
Reference: [TypeORM Migrations](https://typeorm.io/migrations)

View File

@@ -0,0 +1,140 @@
---
title: Use Transactions for Multi-Step Operations
impact: HIGH
impactDescription: Ensures data consistency in multi-step operations
tags: database, transactions, typeorm, consistency
---
## Use Transactions for Multi-Step Operations
When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios.
**Incorrect (multiple saves without transaction):**
```typescript
// Multiple saves without transaction
@Injectable()
export class OrdersService {
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
// If any step fails, data is inconsistent
const order = await this.orderRepo.save({ userId, status: 'pending' });
for (const item of items) {
await this.orderItemRepo.save({ orderId: order.id, ...item });
await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity);
}
await this.paymentService.charge(order.id);
// If payment fails, order and inventory are already modified!
return order;
}
}
```
**Correct (use DataSource.transaction for automatic rollback):**
```typescript
// Use DataSource.transaction() for automatic rollback
@Injectable()
export class OrdersService {
constructor(private dataSource: DataSource) {}
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
return this.dataSource.transaction(async (manager) => {
// All operations use the same transactional manager
const order = await manager.save(Order, { userId, status: 'pending' });
for (const item of items) {
await manager.save(OrderItem, { orderId: order.id, ...item });
await manager.decrement(
Inventory,
{ productId: item.productId },
'stock',
item.quantity,
);
}
// If this throws, everything rolls back
await this.paymentService.chargeWithManager(manager, order.id);
return order;
});
}
}
// QueryRunner for manual transaction control
@Injectable()
export class TransferService {
constructor(private dataSource: DataSource) {}
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Debit source account
await queryRunner.manager.decrement(
Account,
{ id: fromId },
'balance',
amount,
);
// Verify sufficient funds
const source = await queryRunner.manager.findOne(Account, {
where: { id: fromId },
});
if (source.balance < 0) {
throw new BadRequestException('Insufficient funds');
}
// Credit destination account
await queryRunner.manager.increment(
Account,
{ id: toId },
'balance',
amount,
);
// Log the transaction
await queryRunner.manager.save(TransactionLog, {
fromId,
toId,
amount,
timestamp: new Date(),
});
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
// Repository method with transaction support
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User) private repo: Repository<User>,
private dataSource: DataSource,
) {}
async createWithProfile(
userData: CreateUserDto,
profileData: CreateProfileDto,
): Promise<User> {
return this.dataSource.transaction(async (manager) => {
const user = await manager.save(User, userData);
await manager.save(Profile, { ...profileData, userId: user.id });
return user;
});
}
}
```
Reference: [TypeORM Transactions](https://typeorm.io/transactions)

View File

@@ -0,0 +1,222 @@
---
title: Implement Graceful Shutdown
impact: MEDIUM-HIGH
impactDescription: Proper shutdown handling ensures zero-downtime deployments
tags: devops, graceful-shutdown, lifecycle, kubernetes
---
## Implement Graceful Shutdown
Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments.
**Incorrect (ignoring shutdown signals):**
```typescript
// Ignore shutdown signals
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
// App crashes immediately on SIGTERM
// In-flight requests fail
// Database connections are abruptly closed
}
// Long-running tasks without cancellation
@Injectable()
export class ProcessingService {
async processLargeFile(file: File): Promise<void> {
// No way to interrupt this during shutdown
for (let i = 0; i < file.chunks.length; i++) {
await this.processChunk(file.chunks[i]);
// May run for minutes, blocking shutdown
}
}
}
```
**Correct (enable shutdown hooks and handle cleanup):**
```typescript
// Enable shutdown hooks in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable shutdown hooks
app.enableShutdownHooks();
// Optional: Add timeout for forced shutdown
const server = await app.listen(3000);
server.setTimeout(30000); // 30 second timeout
// Handle graceful shutdown
const signals = ['SIGTERM', 'SIGINT'];
signals.forEach((signal) => {
process.on(signal, async () => {
console.log(`Received ${signal}, starting graceful shutdown...`);
// Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
await app.close();
process.exit(0);
});
// Force exit after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
});
});
}
// Lifecycle hooks for cleanup
@Injectable()
export class DatabaseService implements OnApplicationShutdown {
private readonly connections: Connection[] = [];
async onApplicationShutdown(signal?: string): Promise<void> {
console.log(`Database service shutting down on ${signal}`);
// Close all connections gracefully
await Promise.all(
this.connections.map((conn) => conn.close()),
);
console.log('All database connections closed');
}
}
// Queue processor with graceful shutdown
@Injectable()
export class QueueService implements OnApplicationShutdown, OnModuleDestroy {
private isShuttingDown = false;
onModuleDestroy(): void {
this.isShuttingDown = true;
}
async onApplicationShutdown(): Promise<void> {
// Wait for current jobs to complete
await this.queue.close();
}
async processJob(job: Job): Promise<void> {
if (this.isShuttingDown) {
throw new Error('Service is shutting down');
}
await this.doWork(job);
}
}
// WebSocket gateway cleanup
@WebSocketGateway()
export class EventsGateway implements OnApplicationShutdown {
@WebSocketServer()
server: Server;
async onApplicationShutdown(): Promise<void> {
// Notify all connected clients
this.server.emit('shutdown', { message: 'Server is shutting down' });
// Close all connections
this.server.disconnectSockets();
}
}
// Health check integration
@Injectable()
export class ShutdownService {
private isShuttingDown = false;
startShutdown(): void {
this.isShuttingDown = true;
}
isShutdown(): boolean {
return this.isShuttingDown;
}
}
@Controller('health')
export class HealthController {
constructor(private shutdownService: ShutdownService) {}
@Get('ready')
@HealthCheck()
readiness(): Promise<HealthCheckResult> {
// Return 503 during shutdown - k8s stops sending traffic
if (this.shutdownService.isShutdown()) {
throw new ServiceUnavailableException('Shutting down');
}
return this.health.check([
() => this.db.pingCheck('database'),
]);
}
}
// Integrate with shutdown
@Injectable()
export class AppShutdownService implements OnApplicationShutdown {
constructor(private shutdownService: ShutdownService) {}
async onApplicationShutdown(): Promise<void> {
// Mark as unhealthy first
this.shutdownService.startShutdown();
// Wait for k8s to update endpoints
await this.sleep(5000);
// Then proceed with cleanup
}
}
// Request tracking for in-flight requests
@Injectable()
export class RequestTracker implements NestMiddleware, OnApplicationShutdown {
private activeRequests = 0;
private isShuttingDown = false;
private shutdownPromise: Promise<void> | null = null;
private resolveShutdown: (() => void) | null = null;
use(req: Request, res: Response, next: NextFunction): void {
if (this.isShuttingDown) {
res.status(503).send('Service Unavailable');
return;
}
this.activeRequests++;
res.on('finish', () => {
this.activeRequests--;
if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) {
this.resolveShutdown();
}
});
next();
}
async onApplicationShutdown(): Promise<void> {
this.isShuttingDown = true;
if (this.activeRequests > 0) {
console.log(`Waiting for ${this.activeRequests} requests to complete`);
this.shutdownPromise = new Promise((resolve) => {
this.resolveShutdown = resolve;
});
// Wait with timeout
await Promise.race([
this.shutdownPromise,
new Promise((resolve) => setTimeout(resolve, 30000)),
]);
}
console.log('All requests completed');
}
}
```
Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events)

View File

@@ -0,0 +1,167 @@
---
title: Use ConfigModule for Environment Configuration
impact: LOW-MEDIUM
impactDescription: Proper configuration prevents deployment failures
tags: devops, configuration, environment, validation
---
## Use ConfigModule for Environment Configuration
Use `@nestjs/config` for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety.
**Incorrect (accessing process.env directly):**
```typescript
// Access process.env directly
@Injectable()
export class DatabaseService {
constructor() {
// No validation, can fail at runtime
this.connection = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT), // NaN if missing
password: process.env.DB_PASSWORD, // undefined if missing
});
}
}
// Scattered env access
@Injectable()
export class EmailService {
sendEmail() {
// Different services access env differently
const apiKey = process.env.SENDGRID_API_KEY || 'default';
// Typos go unnoticed: process.env.SENDGRID_API_KY
}
}
```
**Correct (use @nestjs/config with validation):**
```typescript
// Setup validated configuration
import { ConfigModule, ConfigService, registerAs } from '@nestjs/config';
import * as Joi from 'joi';
// config/database.config.ts
export const databaseConfig = registerAs('database', () => ({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
}));
// config/app.config.ts
export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT, 10) || 3000,
environment: process.env.NODE_ENV || 'development',
apiPrefix: process.env.API_PREFIX || 'api',
}));
// config/validation.schema.ts
export const validationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
REDIS_URL: Joi.string().uri().required(),
});
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Available everywhere without importing
load: [databaseConfig, appConfig],
validationSchema,
validationOptions: {
abortEarly: true, // Stop on first error
allowUnknown: true, // Allow other env vars
},
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('database.host'),
port: config.get('database.port'),
username: config.get('database.username'),
password: config.get('database.password'),
database: config.get('database.database'),
autoLoadEntities: true,
}),
}),
],
})
export class AppModule {}
// Type-safe configuration access
export interface AppConfig {
port: number;
environment: 'development' | 'production' | 'test';
apiPrefix: string;
}
export interface DatabaseConfig {
host: string;
port: number;
username: string;
password: string;
database: string;
}
// Type-safe access
@Injectable()
export class AppService {
constructor(private config: ConfigService) {}
getPort(): number {
// Type-safe with generic
return this.config.get<number>('app.port');
}
getDatabaseConfig(): DatabaseConfig {
return this.config.get<DatabaseConfig>('database');
}
}
// Inject namespaced config directly
@Injectable()
export class DatabaseService {
constructor(
@Inject(databaseConfig.KEY)
private dbConfig: ConfigType<typeof databaseConfig>,
) {
// Full type inference!
const host = this.dbConfig.host; // string
const port = this.dbConfig.port; // number
}
}
// Environment files support
ConfigModule.forRoot({
envFilePath: [
`.env.${process.env.NODE_ENV}.local`,
`.env.${process.env.NODE_ENV}`,
'.env.local',
'.env',
],
});
// .env.development
// DB_HOST=localhost
// DB_PORT=5432
// .env.production
// DB_HOST=prod-db.example.com
// DB_PORT=5432
```
Reference: [NestJS Configuration](https://docs.nestjs.com/techniques/configuration)

View File

@@ -0,0 +1,232 @@
---
title: Use Structured Logging
impact: MEDIUM-HIGH
impactDescription: Structured logging enables effective debugging and monitoring
tags: devops, logging, structured-logs, pino
---
## Use Structured Logging
Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels.
**Incorrect (using console.log in production):**
```typescript
// Use console.log in production
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
console.log('Creating user:', dto);
// Not structured, no levels, lost in production logs
try {
const user = await this.repo.save(dto);
console.log('User created:', user.id);
return user;
} catch (error) {
console.log('Error:', error); // Using log for errors
throw error;
}
}
}
// Log sensitive data
console.log('Login attempt:', { email, password }); // SECURITY RISK!
// Inconsistent log format
logger.log('User ' + userId + ' created at ' + new Date());
// Hard to parse, no structure
```
**Correct (use structured logging with context):**
```typescript
// Configure logger in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger:
process.env.NODE_ENV === 'production'
? ['error', 'warn', 'log']
: ['error', 'warn', 'log', 'debug', 'verbose'],
});
}
// Use NestJS Logger with context
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
async createUser(dto: CreateUserDto): Promise<User> {
this.logger.log('Creating user', { email: dto.email });
try {
const user = await this.repo.save(dto);
this.logger.log('User created', { userId: user.id });
return user;
} catch (error) {
this.logger.error('Failed to create user', error.stack, {
email: dto.email,
});
throw error;
}
}
}
// Custom logger for JSON output
@Injectable()
export class JsonLogger implements LoggerService {
log(message: string, context?: object): void {
console.log(
JSON.stringify({
level: 'info',
timestamp: new Date().toISOString(),
message,
...context,
}),
);
}
error(message: string, trace?: string, context?: object): void {
console.error(
JSON.stringify({
level: 'error',
timestamp: new Date().toISOString(),
message,
trace,
...context,
}),
);
}
warn(message: string, context?: object): void {
console.warn(
JSON.stringify({
level: 'warn',
timestamp: new Date().toISOString(),
message,
...context,
}),
);
}
debug(message: string, context?: object): void {
console.debug(
JSON.stringify({
level: 'debug',
timestamp: new Date().toISOString(),
message,
...context,
}),
);
}
}
// Request context logging with ClsModule
import { ClsModule, ClsService } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
generateId: true,
},
}),
],
})
export class AppModule {}
// Middleware to set request context
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(private cls: ClsService) {}
use(req: Request, res: Response, next: NextFunction): void {
const requestId = req.headers['x-request-id'] || randomUUID();
this.cls.set('requestId', requestId);
this.cls.set('userId', req.user?.id);
res.setHeader('x-request-id', requestId);
next();
}
}
// Logger that includes request context
@Injectable()
export class ContextLogger {
constructor(private cls: ClsService) {}
log(message: string, data?: object): void {
console.log(
JSON.stringify({
level: 'info',
timestamp: new Date().toISOString(),
requestId: this.cls.get('requestId'),
userId: this.cls.get('userId'),
message,
...data,
}),
);
}
error(message: string, error: Error, data?: object): void {
console.error(
JSON.stringify({
level: 'error',
timestamp: new Date().toISOString(),
requestId: this.cls.get('requestId'),
userId: this.cls.get('userId'),
message,
error: error.message,
stack: error.stack,
...data,
}),
);
}
}
// Pino integration for high-performance logging
import { LoggerModule } from 'nestjs-pino';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
redact: ['req.headers.authorization', 'req.body.password'],
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
query: req.query,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
},
}),
],
})
export class AppModule {}
// Usage with Pino
@Injectable()
export class UsersService {
constructor(private logger: PinoLogger) {
this.logger.setContext(UsersService.name);
}
async findOne(id: string): Promise<User> {
this.logger.info({ userId: id }, 'Finding user');
// Pino uses first arg for data, second for message
}
}
```
Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger)

View File

@@ -0,0 +1,104 @@
---
title: Avoid Service Locator Anti-Pattern
impact: HIGH
impactDescription: Hides dependencies and breaks testability
tags: dependency-injection, anti-patterns, testing
---
## Avoid Service Locator Anti-Pattern
Avoid using `ModuleRef.get()` or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead.
**Incorrect (service locator anti-pattern):**
```typescript
// Use ModuleRef to get dependencies dynamically
@Injectable()
export class OrdersService {
constructor(private moduleRef: ModuleRef) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
// Dependencies are hidden - not visible in constructor
const usersService = this.moduleRef.get(UsersService);
const inventoryService = this.moduleRef.get(InventoryService);
const paymentService = this.moduleRef.get(PaymentService);
const user = await usersService.findOne(dto.userId);
// ... rest of logic
}
}
// Global singleton container
class ServiceContainer {
private static instance: ServiceContainer;
private services = new Map<string, any>();
static getInstance(): ServiceContainer {
if (!this.instance) {
this.instance = new ServiceContainer();
}
return this.instance;
}
get<T>(key: string): T {
return this.services.get(key);
}
}
```
**Correct (constructor injection with explicit dependencies):**
```typescript
// Use constructor injection - dependencies are explicit
@Injectable()
export class OrdersService {
constructor(
private usersService: UsersService,
private inventoryService: InventoryService,
private paymentService: PaymentService,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const user = await this.usersService.findOne(dto.userId);
const inventory = await this.inventoryService.check(dto.items);
// Dependencies are clear and testable
}
}
// Easy to test with mocks
describe('OrdersService', () => {
let service: OrdersService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: InventoryService, useValue: mockInventoryService },
{ provide: PaymentService, useValue: mockPaymentService },
],
}).compile();
service = module.get(OrdersService);
});
});
// VALID: Factory pattern for dynamic instantiation
@Injectable()
export class HandlerFactory {
constructor(private moduleRef: ModuleRef) {}
getHandler(type: string): Handler {
switch (type) {
case 'email':
return this.moduleRef.get(EmailHandler);
case 'sms':
return this.moduleRef.get(SmsHandler);
default:
return this.moduleRef.get(DefaultHandler);
}
}
}
```
Reference: [NestJS Module Reference](https://docs.nestjs.com/fundamentals/module-ref)

View File

@@ -0,0 +1,165 @@
---
title: Apply Interface Segregation Principle
impact: HIGH
impactDescription: Reduces coupling and improves testability by 30-50%
tags: dependency-injection, interfaces, solid, isp
---
## Apply Interface Segregation Principle
Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones.
**Incorrect (fat interface forcing unused dependencies):**
```typescript
// Fat interface - forces all consumers to depend on everything
interface NotificationService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
sendSms(phone: string, message: string): Promise<void>;
sendPush(userId: string, notification: PushPayload): Promise<void>;
sendSlack(channel: string, message: string): Promise<void>;
logNotification(type: string, payload: any): Promise<void>;
getDeliveryStatus(id: string): Promise<DeliveryStatus>;
retryFailed(id: string): Promise<void>;
scheduleNotification(dto: ScheduleDto): Promise<string>;
}
// Consumer only needs email, but must mock everything for tests
@Injectable()
export class OrdersService {
constructor(
private notifications: NotificationService, // Depends on 8 methods, uses 1
) {}
async confirmOrder(order: Order): Promise<void> {
await this.notifications.sendEmail(
order.customer.email,
'Order Confirmed',
`Your order ${order.id} has been confirmed.`,
);
}
}
// Testing is painful - must mock unused methods
const mockNotificationService = {
sendEmail: jest.fn(),
sendSms: jest.fn(), // Never used, but required
sendPush: jest.fn(), // Never used, but required
sendSlack: jest.fn(), // Never used, but required
logNotification: jest.fn(), // Never used, but required
getDeliveryStatus: jest.fn(), // Never used, but required
retryFailed: jest.fn(), // Never used, but required
scheduleNotification: jest.fn(), // Never used, but required
};
```
**Correct (segregated interfaces by capability):**
```typescript
// Segregated interfaces - each focused on one capability
interface EmailSender {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}
interface SmsSender {
sendSms(phone: string, message: string): Promise<void>;
}
interface PushSender {
sendPush(userId: string, notification: PushPayload): Promise<void>;
}
interface NotificationLogger {
logNotification(type: string, payload: any): Promise<void>;
}
interface NotificationScheduler {
scheduleNotification(dto: ScheduleDto): Promise<string>;
}
// Implementation can implement multiple interfaces
@Injectable()
export class NotificationService implements EmailSender, SmsSender, PushSender {
async sendEmail(to: string, subject: string, body: string): Promise<void> {
// Email implementation
}
async sendSms(phone: string, message: string): Promise<void> {
// SMS implementation
}
async sendPush(userId: string, notification: PushPayload): Promise<void> {
// Push implementation
}
}
// Or separate implementations
@Injectable()
export class SendGridEmailService implements EmailSender {
async sendEmail(to: string, subject: string, body: string): Promise<void> {
// SendGrid-specific implementation
}
}
// Consumer depends only on what it needs
@Injectable()
export class OrdersService {
constructor(
@Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency
) {}
async confirmOrder(order: Order): Promise<void> {
await this.emailSender.sendEmail(
order.customer.email,
'Order Confirmed',
`Your order ${order.id} has been confirmed.`,
);
}
}
// Testing is simple - only mock what's used
const mockEmailSender: EmailSender = {
sendEmail: jest.fn(),
};
// Module registration with tokens
export const EMAIL_SENDER = Symbol('EMAIL_SENDER');
export const SMS_SENDER = Symbol('SMS_SENDER');
@Module({
providers: [
{ provide: EMAIL_SENDER, useClass: SendGridEmailService },
{ provide: SMS_SENDER, useClass: TwilioSmsService },
],
exports: [EMAIL_SENDER, SMS_SENDER],
})
export class NotificationModule {}
```
**Combining interfaces when needed:**
```typescript
// Sometimes a consumer legitimately needs multiple capabilities
interface EmailAndSmsSender extends EmailSender, SmsSender {}
// Or use intersection types
type MultiChannelSender = EmailSender & SmsSender & PushSender;
// Consumer that genuinely needs multiple channels
@Injectable()
export class AlertService {
constructor(
@Inject(MULTI_CHANNEL_SENDER)
private sender: EmailSender & SmsSender,
) {}
async sendCriticalAlert(user: User, message: string): Promise<void> {
await Promise.all([
this.sender.sendEmail(user.email, 'Critical Alert', message),
this.sender.sendSms(user.phone, message),
]);
}
}
```
Reference: [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle)

View File

@@ -0,0 +1,221 @@
---
title: Honor Liskov Substitution Principle
impact: HIGH
impactDescription: Ensures implementations are truly interchangeable without breaking callers
tags: dependency-injection, inheritance, solid, lsp
---
## Honor Liskov Substitution Principle
Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations.
**Incorrect (implementation violates the contract):**
```typescript
// Base interface with clear contract
interface PaymentGateway {
/**
* Charges the specified amount.
* @returns PaymentResult on success
* @throws PaymentFailedException on payment failure
*/
charge(amount: number, currency: string): Promise<PaymentResult>;
}
// Production implementation - follows the contract
@Injectable()
export class StripeService implements PaymentGateway {
async charge(amount: number, currency: string): Promise<PaymentResult> {
const response = await this.stripe.charges.create({ amount, currency });
return { success: true, transactionId: response.id, amount };
}
}
// Mock that violates LSP - different behavior!
@Injectable()
export class MockPaymentService implements PaymentGateway {
async charge(amount: number, currency: string): Promise<PaymentResult> {
// VIOLATION 1: Throws for valid input (contract says return PaymentResult)
if (amount > 1000) {
throw new Error('Mock does not support large amounts');
}
// VIOLATION 2: Returns null instead of PaymentResult
if (currency !== 'USD') {
return null as any; // Real service would convert or reject properly
}
// VIOLATION 3: Missing required field
return { success: true } as PaymentResult; // Missing transactionId!
}
}
// Consumer trusts the contract
@Injectable()
export class OrdersService {
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
async checkout(order: Order): Promise<void> {
const result = await this.payment.charge(order.total, order.currency);
// These fail with MockPaymentService:
await this.saveTransaction(result.transactionId); // undefined!
await this.sendReceipt(result); // might be null!
}
}
```
**Correct (implementations honor the contract):**
```typescript
// Well-defined interface with documented behavior
interface PaymentGateway {
/**
* Charges the specified amount.
* @param amount - Amount in smallest currency unit (cents)
* @param currency - ISO 4217 currency code
* @returns PaymentResult with transactionId, success status, and amount
* @throws PaymentFailedException if charge is declined
* @throws InvalidCurrencyException if currency is not supported
*/
charge(amount: number, currency: string): Promise<PaymentResult>;
/**
* Refunds a previous charge.
* @throws TransactionNotFoundException if transactionId is invalid
*/
refund(transactionId: string, amount?: number): Promise<RefundResult>;
}
// Production implementation
@Injectable()
export class StripeService implements PaymentGateway {
async charge(amount: number, currency: string): Promise<PaymentResult> {
try {
const response = await this.stripe.charges.create({ amount, currency });
return {
success: true,
transactionId: response.id,
amount: response.amount,
};
} catch (error) {
if (error.type === 'card_error') {
throw new PaymentFailedException(error.message);
}
throw error;
}
}
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
// Implementation...
}
}
// Mock that honors LSP - same contract, same behavior shape
@Injectable()
export class MockPaymentService implements PaymentGateway {
private transactions = new Map<string, PaymentResult>();
async charge(amount: number, currency: string): Promise<PaymentResult> {
// Honor the contract: validate currency like real service would
if (!['USD', 'EUR', 'GBP'].includes(currency)) {
throw new InvalidCurrencyException(`Unsupported currency: ${currency}`);
}
// Simulate decline for specific test scenarios
if (amount === 99999) {
throw new PaymentFailedException('Card declined (test scenario)');
}
// Return same shape as production
const result: PaymentResult = {
success: true,
transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`,
amount,
};
this.transactions.set(result.transactionId, result);
return result;
}
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
// Honor the contract: throw if transaction not found
if (!this.transactions.has(transactionId)) {
throw new TransactionNotFoundException(transactionId);
}
return {
success: true,
refundId: `refund_${transactionId}`,
amount: amount ?? this.transactions.get(transactionId)!.amount,
};
}
}
// Consumer can swap implementations safely
@Injectable()
export class OrdersService {
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
async checkout(order: Order): Promise<Order> {
try {
const result = await this.payment.charge(order.total, order.currency);
// Works with both StripeService and MockPaymentService
order.transactionId = result.transactionId;
order.status = 'paid';
return order;
} catch (error) {
if (error instanceof PaymentFailedException) {
order.status = 'payment_failed';
return order;
}
throw error;
}
}
}
```
**Testing LSP compliance:**
```typescript
// Shared test suite that any implementation must pass
function testPaymentGatewayContract(
createGateway: () => PaymentGateway,
) {
describe('PaymentGateway contract', () => {
let gateway: PaymentGateway;
beforeEach(() => {
gateway = createGateway();
});
it('returns PaymentResult with all required fields', async () => {
const result = await gateway.charge(1000, 'USD');
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('transactionId');
expect(result).toHaveProperty('amount');
expect(typeof result.transactionId).toBe('string');
});
it('throws InvalidCurrencyException for unsupported currency', async () => {
await expect(gateway.charge(1000, 'INVALID'))
.rejects.toThrow(InvalidCurrencyException);
});
it('throws TransactionNotFoundException for invalid refund', async () => {
await expect(gateway.refund('nonexistent'))
.rejects.toThrow(TransactionNotFoundException);
});
});
}
// Run against all implementations
describe('StripeService', () => {
testPaymentGatewayContract(() => new StripeService(mockStripeClient));
});
describe('MockPaymentService', () => {
testPaymentGatewayContract(() => new MockPaymentService());
});
```
Reference: [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle)

View File

@@ -0,0 +1,86 @@
---
title: Prefer Constructor Injection
impact: CRITICAL
impactDescription: Required for proper DI and testing
tags: dependency-injection, constructor, testing
---
## Prefer Constructor Injection
Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support.
**Incorrect (property injection with hidden dependencies):**
```typescript
// Property injection - avoid unless necessary
@Injectable()
export class UsersService {
@Inject()
private userRepo: UserRepository; // Hidden dependency
@Inject('CONFIG')
private config: ConfigType; // Also hidden
async findAll() {
return this.userRepo.find();
}
}
// Problems:
// 1. Dependencies not visible in constructor
// 2. Service can be instantiated without dependencies in tests
// 3. TypeScript can't enforce dependency types at instantiation
```
**Correct (constructor injection with explicit dependencies):**
```typescript
// Constructor injection - explicit and testable
@Injectable()
export class UsersService {
constructor(
private readonly userRepo: UserRepository,
@Inject('CONFIG') private readonly config: ConfigType,
) {}
async findAll(): Promise<User[]> {
return this.userRepo.find();
}
}
// Testing is straightforward
describe('UsersService', () => {
let service: UsersService;
let mockRepo: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepo = {
find: jest.fn(),
save: jest.fn(),
} as any;
service = new UsersService(mockRepo, { dbUrl: 'test' });
});
it('should find all users', async () => {
mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]);
const result = await service.findAll();
expect(result).toHaveLength(1);
});
});
// Only use property injection for optional dependencies
@Injectable()
export class LoggingService {
@Optional()
@Inject('ANALYTICS')
private analytics?: AnalyticsService;
log(message: string) {
console.log(message);
this.analytics?.track('log', message); // Optional enhancement
}
}
```
Reference: [NestJS Providers](https://docs.nestjs.com/providers)

View File

@@ -0,0 +1,94 @@
---
title: Understand Provider Scopes
impact: CRITICAL
impactDescription: Prevents data leaks and performance issues
tags: dependency-injection, scopes, request-context
---
## Understand Provider Scopes
NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing.
**Incorrect (wrong scope usage):**
```typescript
// Request-scoped when not needed (performance hit)
@Injectable({ scope: Scope.REQUEST })
export class UsersService {
// This creates a new instance for EVERY request
// All dependencies also become request-scoped
async findAll() {
return this.userRepo.find();
}
}
// Singleton with mutable request state
@Injectable() // Default: singleton
export class RequestContextService {
private userId: string; // DANGER: Shared across all requests!
setUser(userId: string) {
this.userId = userId; // Overwrites for all concurrent requests
}
getUser() {
return this.userId; // Returns wrong user!
}
}
```
**Correct (appropriate scope for each use case):**
```typescript
// Singleton for stateless services (default, most common)
@Injectable()
export class UsersService {
constructor(private readonly userRepo: UserRepository) {}
async findById(id: string): Promise<User> {
return this.userRepo.findOne({ where: { id } });
}
}
// Request-scoped ONLY when you need request context
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
private userId: string;
setUser(userId: string) {
this.userId = userId;
}
getUser(): string {
return this.userId;
}
}
// Better: Use NestJS built-in request context
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class AuditService {
constructor(@Inject(REQUEST) private request: Request) {}
log(action: string) {
console.log(`User ${this.request.user?.id} performed ${action}`);
}
}
// Best: Use ClsModule for async context (no scope bubble-up)
import { ClsService } from 'nestjs-cls';
@Injectable() // Stays singleton!
export class AuditService {
constructor(private cls: ClsService) {}
log(action: string) {
const userId = this.cls.get('userId');
console.log(`User ${userId} performed ${action}`);
}
}
```
Reference: [NestJS Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes)

View File

@@ -0,0 +1,101 @@
---
title: Use Injection Tokens for Interfaces
impact: HIGH
impactDescription: Enables interface-based DI at runtime
tags: dependency-injection, tokens, interfaces
---
## Use Injection Tokens for Interfaces
TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments.
**Incorrect (interface can't be used as token):**
```typescript
// Interface can't be used as injection token
interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
@Injectable()
export class StripeService implements PaymentGateway {
charge(amount: number) { /* ... */ }
}
@Injectable()
export class OrdersService {
// This WON'T work - PaymentGateway doesn't exist at runtime
constructor(private payment: PaymentGateway) {}
}
```
**Correct (symbol tokens or abstract classes):**
```typescript
// Option 1: String/Symbol tokens (most flexible)
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
export interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
@Injectable()
export class StripeService implements PaymentGateway {
async charge(amount: number): Promise<PaymentResult> {
// Stripe implementation
}
}
@Injectable()
export class MockPaymentService implements PaymentGateway {
async charge(amount: number): Promise<PaymentResult> {
return { success: true, id: 'mock-id' };
}
}
// Module registration
@Module({
providers: [
{
provide: PAYMENT_GATEWAY,
useClass: process.env.NODE_ENV === 'test'
? MockPaymentService
: StripeService,
},
],
exports: [PAYMENT_GATEWAY],
})
export class PaymentModule {}
// Injection
@Injectable()
export class OrdersService {
constructor(
@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway,
) {}
async createOrder(dto: CreateOrderDto) {
await this.payment.charge(dto.amount);
}
}
// Option 2: Abstract class (carries runtime type info)
export abstract class PaymentGateway {
abstract charge(amount: number): Promise<PaymentResult>;
}
@Injectable()
export class StripeService extends PaymentGateway {
async charge(amount: number): Promise<PaymentResult> {
// Implementation
}
}
// No @Inject needed with abstract class
@Injectable()
export class OrdersService {
constructor(private payment: PaymentGateway) {}
}
```
Reference: [NestJS Custom Providers](https://docs.nestjs.com/fundamentals/custom-providers)

View File

@@ -0,0 +1,125 @@
---
title: Handle Async Errors Properly
impact: HIGH
impactDescription: Prevents process crashes from unhandled rejections
tags: error-handling, async, promises
---
## Handle Async Errors Properly
NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net.
**Incorrect (fire-and-forget without error handling):**
```typescript
// Fire-and-forget without error handling
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Fire and forget - if this fails, error is unhandled!
this.emailService.sendWelcome(user.email);
return user;
}
}
// Unhandled promise in event handler
@Injectable()
export class OrdersService {
@OnEvent('order.created')
handleOrderCreated(event: OrderCreatedEvent) {
// This returns a promise but it's not awaited!
this.processOrder(event);
// Errors will crash the process
}
private async processOrder(event: OrderCreatedEvent): Promise<void> {
await this.inventoryService.reserve(event.items);
await this.notificationService.send(event.userId);
}
}
// Missing try-catch in scheduled tasks
@Cron('0 0 * * *')
async dailyCleanup(): Promise<void> {
await this.cleanupService.run();
// If this throws, no error handling
}
```
**Correct (explicit async error handling):**
```typescript
// Handle fire-and-forget with explicit catch
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Explicitly catch and log errors
this.emailService.sendWelcome(user.email).catch((error) => {
this.logger.error('Failed to send welcome email', error.stack);
// Optionally queue for retry
});
return user;
}
}
// Properly handle async event handlers
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
try {
await this.processOrder(event);
} catch (error) {
this.logger.error('Failed to process order', { event, error });
// Don't rethrow - would crash the process
await this.deadLetterQueue.add('order.created', event);
}
}
}
// Safe scheduled tasks
@Injectable()
export class CleanupService {
private readonly logger = new Logger(CleanupService.name);
@Cron('0 0 * * *')
async dailyCleanup(): Promise<void> {
try {
await this.cleanupService.run();
this.logger.log('Daily cleanup completed');
} catch (error) {
this.logger.error('Daily cleanup failed', error.stack);
// Alert or retry logic
}
}
}
// Global unhandled rejection handler in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
await app.listen(3000);
}
```
Reference: [Node.js Unhandled Rejections](https://nodejs.org/api/process.html#event-unhandledrejection)

View File

@@ -0,0 +1,114 @@
---
title: Throw HTTP Exceptions from Services
impact: HIGH
impactDescription: Keeps controllers thin and simplifies error handling
tags: error-handling, exceptions, services
---
## Throw HTTP Exceptions from Services
It's acceptable (and often preferable) to throw `HttpException` subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes.
**Incorrect (return error objects instead of throwing):**
```typescript
// Return error objects instead of throwing
@Injectable()
export class UsersService {
async findById(id: string): Promise<{ user?: User; error?: string }> {
const user = await this.repo.findOne({ where: { id } });
if (!user) {
return { error: 'User not found' }; // Controller must check this
}
return { user };
}
}
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string) {
const result = await this.usersService.findById(id);
if (result.error) {
throw new NotFoundException(result.error);
}
return result.user;
}
}
```
**Correct (throw exceptions directly from service):**
```typescript
// Throw exceptions directly from service
@Injectable()
export class UsersService {
constructor(private readonly repo: UserRepository) {}
async findById(id: string): Promise<User> {
const user = await this.repo.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.repo.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('Email already registered');
}
return this.repo.save(dto);
}
async update(id: string, dto: UpdateUserDto): Promise<User> {
const user = await this.findById(id); // Throws if not found
Object.assign(user, dto);
return this.repo.save(user);
}
}
// Controller stays thin
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findById(id);
}
@Post()
create(@Body() dto: CreateUserDto): Promise<User> {
return this.usersService.create(dto);
}
}
// For layer-agnostic services, use domain exceptions
export class EntityNotFoundException extends Error {
constructor(
public readonly entity: string,
public readonly id: string,
) {
super(`${entity} with ID "${id}" not found`);
}
}
// Map to HTTP in exception filter
@Catch(EntityNotFoundException)
export class EntityNotFoundFilter implements ExceptionFilter {
catch(exception: EntityNotFoundException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(404).json({
statusCode: 404,
message: exception.message,
entity: exception.entity,
id: exception.id,
});
}
}
```
Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters)

View File

@@ -0,0 +1,140 @@
---
title: Use Exception Filters for Error Handling
impact: HIGH
impactDescription: Consistent, centralized error handling
tags: error-handling, exception-filters, consistency
---
## Use Exception Filters for Error Handling
Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions.
**Incorrect (manual error handling in controllers):**
```typescript
// Manual error handling in controllers
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string, @Res() res: Response) {
try {
const user = await this.usersService.findById(id);
if (!user) {
return res.status(404).json({
statusCode: 404,
message: 'User not found',
});
}
return res.json(user);
} catch (error) {
console.error(error);
return res.status(500).json({
statusCode: 500,
message: 'Internal server error',
});
}
}
}
```
**Correct (exception filters with consistent handling):**
```typescript
// Use built-in and custom exceptions
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
}
// Custom domain exception
export class UserNotFoundException extends NotFoundException {
constructor(userId: string) {
super({
statusCode: 404,
error: 'Not Found',
message: `User with ID "${userId}" not found`,
code: 'USER_NOT_FOUND',
});
}
}
// Custom exception filter for domain errors
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: DomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus?.() || 400;
response.status(status).json({
statusCode: status,
code: exception.code,
message: exception.message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// Global exception filter for unhandled errors
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
this.logger.error(
`${request.method} ${request.url}`,
exception instanceof Error ? exception.stack : exception,
);
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// Register globally in main.ts
app.useGlobalFilters(
new AllExceptionsFilter(app.get(Logger)),
new DomainExceptionFilter(),
);
// Or via module
@Module({
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule {}
```
Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters)

View File

@@ -0,0 +1,226 @@
---
title: Implement Health Checks for Microservices
impact: MEDIUM-HIGH
impactDescription: Health checks enable orchestrators to manage service lifecycle
tags: microservices, health-checks, terminus, kubernetes
---
## Implement Health Checks for Microservices
Implement liveness and readiness probes using `@nestjs/terminus`. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly.
**Incorrect (simple ping that doesn't check dependencies):**
```typescript
// Simple ping that doesn't check dependencies
@Controller('health')
export class HealthController {
@Get()
check(): string {
return 'OK'; // Service might be unhealthy but returns OK
}
}
// Health check that blocks on slow dependencies
@Controller('health')
export class HealthController {
@Get()
async check(): Promise<string> {
// If database is slow, health check times out
await this.userRepo.findOne({ where: { id: '1' } });
await this.redis.ping();
await this.externalApi.healthCheck();
return 'OK';
}
}
```
**Correct (use @nestjs/terminus for comprehensive health checks):**
```typescript
// Use @nestjs/terminus for comprehensive health checks
import {
HealthCheckService,
HttpHealthIndicator,
TypeOrmHealthIndicator,
HealthCheck,
DiskHealthIndicator,
MemoryHealthIndicator,
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private db: TypeOrmHealthIndicator,
private disk: DiskHealthIndicator,
private memory: MemoryHealthIndicator,
) {}
// Liveness probe - is the service alive?
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([
// Basic checks only
() => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB
]);
}
// Readiness probe - can the service handle traffic?
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('database'),
() =>
this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }),
() =>
this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
// Deep health check for debugging
@Get('deep')
@HealthCheck()
deepCheck() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
() =>
this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
() =>
this.http.pingCheck('external-api', 'https://api.example.com/health'),
]);
}
}
// Custom indicator for business-specific health
@Injectable()
export class QueueHealthIndicator extends HealthIndicator {
constructor(private queueService: QueueService) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
const queueStats = await this.queueService.getStats();
const isHealthy = queueStats.failedCount < 100;
const result = this.getStatus(key, isHealthy, {
waiting: queueStats.waitingCount,
active: queueStats.activeCount,
failed: queueStats.failedCount,
});
if (!isHealthy) {
throw new HealthCheckError('Queue unhealthy', result);
}
return result;
}
}
// Redis health indicator
@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
constructor(@InjectRedis() private redis: Redis) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
const pong = await this.redis.ping();
return this.getStatus(key, pong === 'PONG');
} catch (error) {
throw new HealthCheckError('Redis check failed', this.getStatus(key, false));
}
}
}
// Use custom indicators
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.redis.isHealthy('redis'),
() => this.queue.isHealthy('job-queue'),
]);
}
// Graceful shutdown handling
@Injectable()
export class GracefulShutdownService implements OnApplicationShutdown {
private isShuttingDown = false;
isShutdown(): boolean {
return this.isShuttingDown;
}
async onApplicationShutdown(signal: string): Promise<void> {
this.isShuttingDown = true;
console.log(`Shutting down on ${signal}`);
// Wait for in-flight requests
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
// Health check respects shutdown state
@Get('ready')
@HealthCheck()
readiness() {
if (this.shutdownService.isShutdown()) {
throw new ServiceUnavailableException('Shutting down');
}
return this.health.check([
() => this.db.pingCheck('database'),
]);
}
```
### Kubernetes Configuration
```yaml
# Kubernetes deployment with probes
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
template:
spec:
containers:
- name: api
image: api-service:latest
ports:
- containerPort: 3000
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30
```
Reference: [NestJS Terminus](https://docs.nestjs.com/recipes/terminus)

View File

@@ -0,0 +1,167 @@
---
title: Use Message and Event Patterns Correctly
impact: MEDIUM
impactDescription: Proper patterns ensure reliable microservice communication
tags: microservices, message-pattern, event-pattern, communication
---
## Use Message and Event Patterns Correctly
NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs.
**Incorrect (using wrong pattern for use case):**
```typescript
// Use @MessagePattern for fire-and-forget
@Controller()
export class NotificationsController {
@MessagePattern('user.created')
async handleUserCreated(data: UserCreatedEvent) {
// This WAITS for response, blocking the sender
await this.emailService.sendWelcome(data.email);
// If email fails, sender gets an error (coupling!)
}
}
// Use @EventPattern expecting a response
@Controller()
export class OrdersController {
@EventPattern('inventory.check')
async checkInventory(data: CheckInventoryDto) {
const available = await this.inventory.check(data);
return available; // This return value is IGNORED with @EventPattern!
}
}
// Tight coupling in client
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Blocks until notification service responds
await this.client.send('user.created', user).toPromise();
// If notification service is down, user creation fails!
return user;
}
}
```
**Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):**
```typescript
// MessagePattern: Request-Response (when you NEED a response)
@Controller()
export class InventoryController {
@MessagePattern({ cmd: 'check_inventory' })
async checkInventory(data: CheckInventoryDto): Promise<InventoryResult> {
const result = await this.inventoryService.check(data.productId, data.quantity);
return result; // Response sent back to caller
}
}
// Client expects response
@Injectable()
export class OrdersService {
async createOrder(dto: CreateOrderDto): Promise<Order> {
// Check inventory - we NEED this response to proceed
const inventory = await firstValueFrom(
this.inventoryClient.send<InventoryResult>(
{ cmd: 'check_inventory' },
{ productId: dto.productId, quantity: dto.quantity },
),
);
if (!inventory.available) {
throw new BadRequestException('Insufficient inventory');
}
return this.repo.save(dto);
}
}
// EventPattern: Fire-and-Forget (for notifications, side effects)
@Controller()
export class NotificationsController {
@EventPattern('user.created')
async handleUserCreated(data: UserCreatedEvent): Promise<void> {
// No return value needed - just process the event
await this.emailService.sendWelcome(data.email);
await this.analyticsService.track('user_signup', data);
// If this fails, it doesn't affect the sender
}
}
// Client emits event without waiting
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Fire and forget - doesn't block, doesn't wait
this.eventClient.emit('user.created', {
userId: user.id,
email: user.email,
timestamp: new Date(),
});
return user; // User creation succeeds regardless of event handling
}
}
// Hybrid pattern for critical events
@Injectable()
export class OrdersService {
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repo.save(dto);
// Critical: inventory reservation (use MessagePattern)
const reserved = await firstValueFrom(
this.inventoryClient.send({ cmd: 'reserve_inventory' }, {
orderId: order.id,
items: dto.items,
}),
);
if (!reserved.success) {
await this.repo.delete(order.id);
throw new BadRequestException('Could not reserve inventory');
}
// Non-critical: notifications (use EventPattern)
this.eventClient.emit('order.created', {
orderId: order.id,
userId: dto.userId,
total: dto.total,
});
return order;
}
}
// Error handling patterns
// MessagePattern errors propagate to caller
@MessagePattern({ cmd: 'get_user' })
async getUser(userId: string): Promise<User> {
const user = await this.repo.findOne({ where: { id: userId } });
if (!user) {
throw new RpcException('User not found'); // Received by caller
}
return user;
}
// EventPattern errors should be handled locally
@EventPattern('order.created')
async handleOrderCreated(data: OrderCreatedEvent): Promise<void> {
try {
await this.processOrder(data);
} catch (error) {
// Log and potentially retry - don't throw
this.logger.error('Failed to process order event', error);
await this.deadLetterQueue.add(data);
}
}
```
Reference: [NestJS Microservices](https://docs.nestjs.com/microservices/basics)

View File

@@ -0,0 +1,252 @@
---
title: Use Message Queues for Background Jobs
impact: MEDIUM-HIGH
impactDescription: Queues enable reliable background processing
tags: microservices, queues, bullmq, background-jobs
---
## Use Message Queues for Background Jobs
Use `@nestjs/bullmq` for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests.
**Incorrect (long-running tasks in HTTP handlers):**
```typescript
// Long-running tasks in HTTP handlers
@Controller('reports')
export class ReportsController {
@Post()
async generate(@Body() dto: GenerateReportDto): Promise<Report> {
// This blocks the request for potentially minutes
const data = await this.fetchLargeDataset(dto);
const report = await this.processData(data); // Slow!
await this.sendEmail(dto.email, report); // Can fail!
return report; // Client times out
}
}
// Fire-and-forget without retry
@Injectable()
export class EmailService {
async sendWelcome(email: string): Promise<void> {
// If this fails, email is never sent
await this.mailer.send({ to: email, template: 'welcome' });
// No retry, no tracking, no visibility
}
}
// Use setInterval for scheduled tasks
setInterval(async () => {
await cleanupOldRecords();
}, 60000); // No error handling, memory leaks
```
**Correct (use BullMQ for background processing):**
```typescript
// Configure BullMQ
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.forRoot({
connection: {
host: 'localhost',
port: 6379,
},
defaultJobOptions: {
removeOnComplete: 1000,
removeOnFail: 5000,
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
},
}),
BullModule.registerQueue(
{ name: 'email' },
{ name: 'reports' },
{ name: 'notifications' },
),
],
})
export class QueueModule {}
// Producer: Add jobs to queue
@Injectable()
export class ReportsService {
constructor(
@InjectQueue('reports') private reportsQueue: Queue,
) {}
async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> {
// Return immediately, process in background
const job = await this.reportsQueue.add('generate', dto, {
priority: dto.urgent ? 1 : 10,
delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0,
});
return { jobId: job.id };
}
async getJobStatus(jobId: string): Promise<JobStatus> {
const job = await this.reportsQueue.getJob(jobId);
return {
status: await job.getState(),
progress: job.progress,
result: job.returnvalue,
};
}
}
// Consumer: Process jobs
@Processor('reports')
export class ReportsProcessor {
private readonly logger = new Logger(ReportsProcessor.name);
@Process('generate')
async generateReport(job: Job<GenerateReportDto>): Promise<Report> {
this.logger.log(`Processing report job ${job.id}`);
// Update progress
await job.updateProgress(10);
const data = await this.fetchData(job.data);
await job.updateProgress(50);
const report = await this.processData(data);
await job.updateProgress(90);
await this.saveReport(report);
await job.updateProgress(100);
return report;
}
@OnQueueActive()
onActive(job: Job) {
this.logger.log(`Processing job ${job.id}`);
}
@OnQueueCompleted()
onCompleted(job: Job, result: any) {
this.logger.log(`Job ${job.id} completed`);
}
@OnQueueFailed()
onFailed(job: Job, error: Error) {
this.logger.error(`Job ${job.id} failed: ${error.message}`);
}
}
// Email queue with retry
@Processor('email')
export class EmailProcessor {
@Process('send')
async sendEmail(job: Job<SendEmailDto>): Promise<void> {
const { to, template, data } = job.data;
try {
await this.mailer.send({
to,
template,
context: data,
});
} catch (error) {
// BullMQ will retry based on job options
throw error;
}
}
}
// Usage
@Injectable()
export class NotificationService {
constructor(@InjectQueue('email') private emailQueue: Queue) {}
async sendWelcome(user: User): Promise<void> {
await this.emailQueue.add(
'send',
{
to: user.email,
template: 'welcome',
data: { name: user.name },
},
{
attempts: 5,
backoff: { type: 'exponential', delay: 5000 },
},
);
}
}
// Scheduled jobs
@Injectable()
export class ScheduledJobsService implements OnModuleInit {
constructor(@InjectQueue('maintenance') private queue: Queue) {}
async onModuleInit(): Promise<void> {
// Clean up old reports daily at midnight
await this.queue.add(
'cleanup',
{},
{
repeat: { cron: '0 0 * * *' },
jobId: 'daily-cleanup', // Prevent duplicates
},
);
// Send digest every hour
await this.queue.add(
'digest',
{},
{
repeat: { every: 60 * 60 * 1000 },
jobId: 'hourly-digest',
},
);
}
}
@Processor('maintenance')
export class MaintenanceProcessor {
@Process('cleanup')
async cleanup(): Promise<void> {
await this.cleanupOldReports();
await this.cleanupExpiredSessions();
}
@Process('digest')
async sendDigest(): Promise<void> {
const users = await this.getUsersForDigest();
for (const user of users) {
await this.emailQueue.add('send', { to: user.email, template: 'digest' });
}
}
}
// Queue monitoring with Bull Board
import { BullBoardModule } from '@bull-board/nestjs';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
@Module({
imports: [
BullBoardModule.forRoot({
route: '/admin/queues',
adapter: ExpressAdapter,
}),
BullBoardModule.forFeature({
name: 'email',
adapter: BullMQAdapter,
}),
BullBoardModule.forFeature({
name: 'reports',
adapter: BullMQAdapter,
}),
],
})
export class AdminModule {}
```
Reference: [NestJS Queues](https://docs.nestjs.com/techniques/queues)

View File

@@ -0,0 +1,109 @@
---
title: Use Async Lifecycle Hooks Correctly
impact: HIGH
impactDescription: Improper async handling blocks application startup
tags: performance, lifecycle, async, hooks
---
## Use Async Lifecycle Hooks Correctly
NestJS lifecycle hooks (`onModuleInit`, `onApplicationBootstrap`, etc.) support async operations. However, misusing them can block application startup or cause race conditions. Understand the lifecycle order and use hooks appropriately.
**Incorrect (fire-and-forget async without await):**
```typescript
// Fire-and-forget async without await
@Injectable()
export class DatabaseService implements OnModuleInit {
onModuleInit() {
// This runs but doesn't block - app starts before DB is ready!
this.connect();
}
private async connect() {
await this.pool.connect();
console.log('Database connected');
}
}
// Heavy blocking operations in constructor
@Injectable()
export class ConfigService {
private config: Config;
constructor() {
// BLOCKS entire module instantiation synchronously
this.config = fs.readFileSync('config.json');
}
}
```
**Correct (return promises from async hooks):**
```typescript
// Return promise from async hooks
@Injectable()
export class DatabaseService implements OnModuleInit {
private pool: Pool;
async onModuleInit(): Promise<void> {
// NestJS waits for this to complete before continuing
await this.pool.connect();
console.log('Database connected');
}
async onModuleDestroy(): Promise<void> {
// Clean up resources on shutdown
await this.pool.end();
console.log('Database disconnected');
}
}
// Use onApplicationBootstrap for cross-module dependencies
@Injectable()
export class CacheWarmerService implements OnApplicationBootstrap {
constructor(
private cache: CacheService,
private products: ProductsService,
) {}
async onApplicationBootstrap(): Promise<void> {
// All modules are initialized, safe to warm cache
const products = await this.products.findPopular();
await this.cache.warmup(products);
}
}
// Heavy init in async hooks, not constructor
@Injectable()
export class ConfigService implements OnModuleInit {
private config: Config;
constructor() {
// Keep constructor synchronous and fast
}
async onModuleInit(): Promise<void> {
// Async loading in lifecycle hook
this.config = await this.loadConfig();
}
private async loadConfig(): Promise<Config> {
const file = await fs.promises.readFile('config.json');
return JSON.parse(file.toString());
}
get<T>(key: string): T {
return this.config[key];
}
}
// Enable shutdown hooks in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // Enable SIGTERM/SIGINT handling
await app.listen(3000);
}
```
Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events)

View File

@@ -0,0 +1,121 @@
---
title: Use Lazy Loading for Large Modules
impact: MEDIUM
impactDescription: Improves startup time for large applications
tags: performance, lazy-loading, modules, optimization
---
## Use Lazy Loading for Large Modules
NestJS supports lazy-loading modules, which defers initialization until first use. This is valuable for large applications where some features are rarely used, serverless deployments where cold start time matters, or when certain modules have heavy initialization costs.
**Incorrect (loading everything eagerly):**
```typescript
// Load everything eagerly in a large app
@Module({
imports: [
UsersModule,
OrdersModule,
PaymentsModule,
ReportsModule, // Heavy, rarely used
AnalyticsModule, // Heavy, rarely used
AdminModule, // Only admins use this
LegacyModule, // Migration module, rarely used
BulkImportModule, // Used once a month
],
})
export class AppModule {}
// All modules initialize at startup, even if never used
// Slow cold starts in serverless
// Memory wasted on unused modules
```
**Correct (lazy load rarely-used modules):**
```typescript
// Use LazyModuleLoader for optional modules
import { LazyModuleLoader } from '@nestjs/core';
@Injectable()
export class ReportsService {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
async generateReport(type: string): Promise<Report> {
// Load module only when needed
const { ReportsModule } = await import('./reports/reports.module');
const moduleRef = await this.lazyModuleLoader.load(() => ReportsModule);
const reportsService = moduleRef.get(ReportsGeneratorService);
return reportsService.generate(type);
}
}
// Lazy load admin features with caching
@Injectable()
export class AdminService {
private adminModule: ModuleRef | null = null;
constructor(private lazyModuleLoader: LazyModuleLoader) {}
private async getAdminModule(): Promise<ModuleRef> {
if (!this.adminModule) {
const { AdminModule } = await import('./admin/admin.module');
this.adminModule = await this.lazyModuleLoader.load(() => AdminModule);
}
return this.adminModule;
}
async runAdminTask(task: string): Promise<void> {
const moduleRef = await this.getAdminModule();
const taskRunner = moduleRef.get(AdminTaskRunner);
await taskRunner.run(task);
}
}
// Reusable lazy loader service
@Injectable()
export class ModuleLoaderService {
private loadedModules = new Map<string, ModuleRef>();
constructor(private lazyModuleLoader: LazyModuleLoader) {}
async load<T>(
key: string,
importFn: () => Promise<{ default: Type<T> } | Type<T>>,
): Promise<ModuleRef> {
if (!this.loadedModules.has(key)) {
const module = await importFn();
const moduleType = 'default' in module ? module.default : module;
const moduleRef = await this.lazyModuleLoader.load(() => moduleType);
this.loadedModules.set(key, moduleRef);
}
return this.loadedModules.get(key)!;
}
}
// Preload modules in background after startup
@Injectable()
export class ModulePreloader implements OnApplicationBootstrap {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
async onApplicationBootstrap(): Promise<void> {
setTimeout(async () => {
await this.preloadModule(() => import('./reports/reports.module'));
}, 5000); // 5 seconds after startup
}
private async preloadModule(importFn: () => Promise<any>): Promise<void> {
try {
const module = await importFn();
const moduleType = module.default || Object.values(module)[0];
await this.lazyModuleLoader.load(() => moduleType);
} catch (error) {
console.warn('Failed to preload module', error);
}
}
}
```
Reference: [NestJS Lazy Loading Modules](https://docs.nestjs.com/fundamentals/lazy-loading-modules)

View File

@@ -0,0 +1,131 @@
---
title: Optimize Database Queries
impact: HIGH
impactDescription: Database queries are typically the largest source of latency
tags: performance, database, queries, optimization
---
## Optimize Database Queries
Select only needed columns, use proper indexes, avoid over-fetching relations, and consider query performance when designing your data access. Most API slowness traces back to inefficient database queries.
**Incorrect (over-fetching data and missing indexes):**
```typescript
// Select everything when you need few fields
@Injectable()
export class UsersService {
async findAllEmails(): Promise<string[]> {
const users = await this.repo.find();
// Fetches ALL columns for ALL users
return users.map((u) => u.email);
}
async getUserSummary(id: string): Promise<UserSummary> {
const user = await this.repo.findOne({
where: { id },
relations: ['posts', 'posts.comments', 'posts.comments.author', 'followers'],
});
// Over-fetches massive relation tree
return { name: user.name, postCount: user.posts.length };
}
}
// No indexes on frequently queried columns
@Entity()
export class Order {
@Column()
userId: string; // No index - full table scan on every lookup
@Column()
status: string; // No index - slow status filtering
}
```
**Correct (select only needed data with proper indexes):**
```typescript
// Select only needed columns
@Injectable()
export class UsersService {
async findAllEmails(): Promise<string[]> {
const users = await this.repo.find({
select: ['email'], // Only fetch email column
});
return users.map((u) => u.email);
}
// Use QueryBuilder for complex selections
async getUserSummary(id: string): Promise<UserSummary> {
return this.repo
.createQueryBuilder('user')
.select('user.name', 'name')
.addSelect('COUNT(post.id)', 'postCount')
.leftJoin('user.posts', 'post')
.where('user.id = :id', { id })
.groupBy('user.id')
.getRawOne();
}
// Fetch relations only when needed
async getFullProfile(id: string): Promise<User> {
return this.repo.findOne({
where: { id },
relations: ['posts'], // Only immediate relation
select: {
id: true,
name: true,
email: true,
posts: {
id: true,
title: true,
},
},
});
}
}
// Add indexes on frequently queried columns
@Entity()
@Index(['userId'])
@Index(['status'])
@Index(['createdAt'])
@Index(['userId', 'status']) // Composite index for common query pattern
export class Order {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
userId: string;
@Column()
status: string;
@CreateDateColumn()
createdAt: Date;
}
// Always paginate large datasets
@Injectable()
export class OrdersService {
async findAll(page = 1, limit = 20): Promise<PaginatedResult<Order>> {
const [items, total] = await this.repo.findAndCount({
skip: (page - 1) * limit,
take: limit,
order: { createdAt: 'DESC' },
});
return {
items,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
}
```
Reference: [TypeORM Query Builder](https://typeorm.io/select-query-builder)

View File

@@ -0,0 +1,128 @@
---
title: Use Caching Strategically
impact: HIGH
impactDescription: Dramatically reduces database load and response times
tags: performance, caching, redis, optimization
---
## Use Caching Strategically
Implement caching for expensive operations, frequently accessed data, and external API calls. Use NestJS CacheModule with appropriate TTLs and cache invalidation strategies. Don't cache everything - focus on high-impact areas.
**Incorrect (no caching or caching everything):**
```typescript
// No caching for expensive, repeated queries
@Injectable()
export class ProductsService {
async getPopular(): Promise<Product[]> {
// Runs complex aggregation query EVERY request
return this.productsRepo
.createQueryBuilder('p')
.leftJoin('p.orders', 'o')
.select('p.*, COUNT(o.id) as orderCount')
.groupBy('p.id')
.orderBy('orderCount', 'DESC')
.limit(20)
.getMany();
}
}
// Cache everything without thought
@Injectable()
export class UsersService {
@CacheKey('users')
@CacheTTL(3600)
@UseInterceptors(CacheInterceptor)
async findAll(): Promise<User[]> {
// Caching user list for 1 hour is wrong if data changes frequently
return this.usersRepo.find();
}
}
```
**Correct (strategic caching with proper invalidation):**
```typescript
// Setup caching module
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
stores: [
new KeyvRedis(config.get('REDIS_URL')),
],
ttl: 60 * 1000, // Default 60s
}),
}),
],
})
export class AppModule {}
// Manual caching for granular control
@Injectable()
export class ProductsService {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
private productsRepo: ProductRepository,
) {}
async getPopular(): Promise<Product[]> {
const cacheKey = 'products:popular';
// Try cache first
const cached = await this.cache.get<Product[]>(cacheKey);
if (cached) return cached;
// Cache miss - fetch and cache
const products = await this.fetchPopularProducts();
await this.cache.set(cacheKey, products, 5 * 60 * 1000); // 5 min TTL
return products;
}
// Invalidate cache on changes
async updateProduct(id: string, dto: UpdateProductDto): Promise<Product> {
const product = await this.productsRepo.save({ id, ...dto });
await this.cache.del('products:popular'); // Invalidate
return product;
}
}
// Decorator-based caching with auto-interceptor
@Controller('categories')
@UseInterceptors(CacheInterceptor)
export class CategoriesController {
@Get()
@CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change
findAll(): Promise<Category[]> {
return this.categoriesService.findAll();
}
@Get(':id')
@CacheTTL(60 * 1000) // 1 minute
@CacheKey('category')
findOne(@Param('id') id: string): Promise<Category> {
return this.categoriesService.findOne(id);
}
}
// Event-based cache invalidation
@Injectable()
export class CacheInvalidationService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
@OnEvent('product.created')
@OnEvent('product.updated')
@OnEvent('product.deleted')
async invalidateProductCaches(event: ProductEvent) {
await Promise.all([
this.cache.del('products:popular'),
this.cache.del(`product:${event.productId}`),
]);
}
}
```
Reference: [NestJS Caching](https://docs.nestjs.com/techniques/caching)

View File

@@ -0,0 +1,146 @@
---
title: Implement Secure JWT Authentication
impact: CRITICAL
impactDescription: Essential for secure APIs
tags: security, jwt, authentication, tokens
---
## Implement Secure JWT Authentication
Use `@nestjs/jwt` with `@nestjs/passport` for authentication. Store secrets securely, use appropriate token lifetimes, implement refresh tokens, and validate tokens properly. Never expose sensitive data in JWT payloads.
**Incorrect (insecure JWT implementation):**
```typescript
// Hardcode secrets
@Module({
imports: [
JwtModule.register({
secret: 'my-secret-key', // Exposed in code
signOptions: { expiresIn: '7d' }, // Too long
}),
],
})
export class AuthModule {}
// Store sensitive data in JWT
async login(user: User): Promise<{ accessToken: string }> {
const payload = {
sub: user.id,
email: user.email,
password: user.password, // NEVER include password!
ssn: user.ssn, // NEVER include sensitive data!
isAdmin: user.isAdmin, // Can be tampered if not verified
};
return { accessToken: this.jwtService.sign(payload) };
}
// Skip token validation
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: 'my-secret',
});
}
async validate(payload: any): Promise<any> {
return payload; // No validation of user existence
}
}
```
**Correct (secure JWT with refresh tokens):**
```typescript
// Secure JWT configuration
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: '15m', // Short-lived access tokens
issuer: config.get<string>('JWT_ISSUER'),
audience: config.get<string>('JWT_AUDIENCE'),
},
}),
}),
PassportModule.register({ defaultStrategy: 'jwt' }),
],
})
export class AuthModule {}
// Minimal JWT payload
@Injectable()
export class AuthService {
async login(user: User): Promise<TokenResponse> {
// Only include necessary, non-sensitive data
const payload: JwtPayload = {
sub: user.id,
email: user.email,
roles: user.roles,
iat: Math.floor(Date.now() / 1000),
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = await this.createRefreshToken(user.id);
return { accessToken, refreshToken, expiresIn: 900 };
}
private async createRefreshToken(userId: string): Promise<string> {
const token = randomBytes(32).toString('hex');
const hashedToken = await bcrypt.hash(token, 10);
await this.refreshTokenRepo.save({
userId,
token: hashedToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
return token;
}
}
// Proper JWT strategy with validation
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private config: ConfigService,
private usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>('JWT_SECRET'),
ignoreExpiration: false,
issuer: config.get<string>('JWT_ISSUER'),
audience: config.get<string>('JWT_AUDIENCE'),
});
}
async validate(payload: JwtPayload): Promise<User> {
// Verify user still exists and is active
const user = await this.usersService.findById(payload.sub);
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
// Verify token wasn't issued before password change
if (user.passwordChangedAt) {
const tokenIssuedAt = new Date(payload.iat * 1000);
if (tokenIssuedAt < user.passwordChangedAt) {
throw new UnauthorizedException('Token invalidated by password change');
}
}
return user;
}
}
```
Reference: [NestJS Authentication](https://docs.nestjs.com/security/authentication)

View File

@@ -0,0 +1,125 @@
---
title: Implement Rate Limiting
impact: HIGH
impactDescription: Protects against abuse and ensures fair resource usage
tags: security, rate-limiting, throttler, protection
---
## Implement Rate Limiting
Use `@nestjs/throttler` to limit request rates per client. Apply different limits for different endpoints - stricter for auth endpoints, more relaxed for read operations. Consider using Redis for distributed rate limiting in clustered deployments.
**Incorrect (no rate limiting on sensitive endpoints):**
```typescript
// No rate limiting on sensitive endpoints
@Controller('auth')
export class AuthController {
@Post('login')
async login(@Body() dto: LoginDto): Promise<TokenResponse> {
// Attackers can brute-force credentials
return this.authService.login(dto);
}
@Post('forgot-password')
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> {
// Can be abused to spam users with emails
return this.authService.sendResetEmail(dto.email);
}
}
// Same limits for all endpoints
@UseGuards(ThrottlerGuard)
@Controller('api')
export class ApiController {
@Get('public-data')
async getPublic() {} // Should allow more requests
@Post('process-payment')
async payment() {} // Should be more restrictive
}
```
**Correct (configured throttler with endpoint-specific limits):**
```typescript
// Configure throttler globally with multiple limits
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([
{
name: 'short',
ttl: 1000, // 1 second
limit: 3, // 3 requests per second
},
{
name: 'medium',
ttl: 10000, // 10 seconds
limit: 20, // 20 requests per 10 seconds
},
{
name: 'long',
ttl: 60000, // 1 minute
limit: 100, // 100 requests per minute
},
]),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
// Override limits per endpoint
@Controller('auth')
export class AuthController {
@Post('login')
@Throttle({ short: { limit: 5, ttl: 60000 } }) // 5 attempts per minute
async login(@Body() dto: LoginDto): Promise<TokenResponse> {
return this.authService.login(dto);
}
@Post('forgot-password')
@Throttle({ short: { limit: 3, ttl: 3600000 } }) // 3 per hour
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> {
return this.authService.sendResetEmail(dto.email);
}
}
// Skip throttling for certain routes
@Controller('health')
export class HealthController {
@Get()
@SkipThrottle()
check(): string {
return 'OK';
}
}
// Custom throttle per user type
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: Request): Promise<string> {
// Use user ID if authenticated, IP otherwise
return req.user?.id || req.ip;
}
protected async getLimit(context: ExecutionContext): Promise<number> {
const request = context.switchToHttp().getRequest();
// Higher limits for authenticated users
if (request.user) {
return request.user.isPremium ? 1000 : 200;
}
return 50; // Anonymous users
}
}
```
Reference: [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting)

View File

@@ -0,0 +1,139 @@
---
title: Sanitize Output to Prevent XSS
impact: HIGH
impactDescription: XSS vulnerabilities can compromise user sessions and data
tags: security, xss, sanitization, html
---
## Sanitize Output to Prevent XSS
While NestJS APIs typically return JSON (which browsers don't execute), XSS risks exist when rendering HTML, storing user content, or when frontend frameworks improperly handle API responses. Sanitize user-generated content before storage and use proper Content-Type headers.
**Incorrect (storing raw HTML without sanitization):**
```typescript
// Store raw HTML from users
@Injectable()
export class CommentsService {
async create(dto: CreateCommentDto): Promise<Comment> {
// User can inject: <script>steal(document.cookie)</script>
return this.repo.save({
content: dto.content, // Raw, unsanitized
authorId: dto.authorId,
});
}
}
// Return HTML without sanitization
@Controller('pages')
export class PagesController {
@Get(':slug')
@Header('Content-Type', 'text/html')
async getPage(@Param('slug') slug: string): Promise<string> {
const page = await this.pagesService.findBySlug(slug);
// If page.content contains user input, XSS is possible
return `<html><body>${page.content}</body></html>`;
}
}
// Reflect user input in errors
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
const user = await this.repo.findOne({ where: { id } });
if (!user) {
// XSS if id contains malicious content and error is rendered
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
```
**Correct (sanitize content and use proper headers):**
```typescript
// Sanitize HTML content before storage
import * as sanitizeHtml from 'sanitize-html';
@Injectable()
export class CommentsService {
private readonly sanitizeOptions: sanitizeHtml.IOptions = {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowedAttributes: {
a: ['href', 'title'],
},
allowedSchemes: ['http', 'https', 'mailto'],
};
async create(dto: CreateCommentDto): Promise<Comment> {
return this.repo.save({
content: sanitizeHtml(dto.content, this.sanitizeOptions),
authorId: dto.authorId,
});
}
}
// Use validation pipe to strip HTML
import { Transform } from 'class-transformer';
export class CreatePostDto {
@IsString()
@MaxLength(1000)
@Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] }))
title: string;
@IsString()
@Transform(({ value }) =>
sanitizeHtml(value, {
allowedTags: ['p', 'br', 'b', 'i', 'a'],
allowedAttributes: { a: ['href'] },
}),
)
content: string;
}
// Set proper Content-Type headers
@Controller('api')
export class ApiController {
@Get('data')
@Header('Content-Type', 'application/json')
async getData(): Promise<DataResponse> {
// JSON response - browser won't execute scripts
return this.service.getData();
}
}
// Sanitize error messages
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
const user = await this.repo.findOne({ where: { id } });
if (!user) {
// UUID validation ensures safe format
throw new NotFoundException('User not found');
}
return user;
}
// Use Helmet for CSP headers
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
}),
);
await app.listen(3000);
}
```
Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)

View File

@@ -0,0 +1,135 @@
---
title: Use Guards for Authentication and Authorization
impact: HIGH
impactDescription: Enforces access control before handlers execute
tags: security, guards, authentication, authorization
---
## Use Guards for Authentication and Authorization
Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers.
**Incorrect (manual auth checks in every handler):**
```typescript
// Manual auth checks in every handler
@Controller('admin')
export class AdminController {
@Get('users')
async getUsers(@Request() req) {
if (!req.user) {
throw new UnauthorizedException();
}
if (!req.user.roles.includes('admin')) {
throw new ForbiddenException();
}
return this.adminService.getUsers();
}
@Delete('users/:id')
async deleteUser(@Request() req, @Param('id') id: string) {
if (!req.user) {
throw new UnauthorizedException();
}
if (!req.user.roles.includes('admin')) {
throw new ForbiddenException();
}
return this.adminService.deleteUser(id);
}
}
```
**Correct (guards with declarative decorators):**
```typescript
// JWT Auth Guard
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// Check for @Public() decorator
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
request.user = await this.jwtService.verifyAsync(token);
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
private extractToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
// Roles Guard
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
// Decorators
export const Public = () => SetMetadata('isPublic', true);
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
// Register guards globally
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}
// Clean controller
@Controller('admin')
@Roles(Role.Admin) // Applied to all routes
export class AdminController {
@Get('users')
getUsers(): Promise<User[]> {
return this.adminService.getUsers();
}
@Delete('users/:id')
deleteUser(@Param('id') id: string): Promise<void> {
return this.adminService.deleteUser(id);
}
@Public() // Override: no auth required
@Get('health')
health() {
return { status: 'ok' };
}
}
```
Reference: [NestJS Guards](https://docs.nestjs.com/guards)

View File

@@ -0,0 +1,150 @@
---
title: Validate All Input with DTOs and Pipes
impact: HIGH
impactDescription: First line of defense against attacks
tags: security, validation, dto, pipes
---
## Validate All Input with DTOs and Pipes
Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing.
**Incorrect (trust raw input without validation):**
```typescript
// Trust raw input without validation
@Controller('users')
export class UsersController {
@Post()
create(@Body() body: any) {
// body could contain anything - SQL injection, XSS, etc.
return this.usersService.create(body);
}
@Get()
findAll(@Query() query: any) {
// query.limit could be "'; DROP TABLE users; --"
return this.usersService.findAll(query.limit);
}
}
// DTOs without validation decorators
export class CreateUserDto {
name: string; // No validation
email: string; // Could be "not-an-email"
age: number; // Could be "abc" or -999
}
```
**Correct (validated DTOs with global ValidationPipe):**
```typescript
// Enable ValidationPipe globally in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw on unknown properties
transform: true, // Auto-transform to DTO types
transformOptions: {
enableImplicitConversion: true,
},
}),
);
await app.listen(3000);
}
// Create well-validated DTOs
import {
IsString,
IsEmail,
IsInt,
Min,
Max,
IsOptional,
MinLength,
MaxLength,
Matches,
IsNotEmpty,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
@Transform(({ value }) => value?.trim())
name: string;
@IsEmail()
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@IsInt()
@Min(0)
@Max(150)
age: number;
@IsString()
@MinLength(8)
@MaxLength(100)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain uppercase, lowercase, and number',
})
password: string;
}
// Query DTO with defaults and transformation
export class FindUsersQueryDto {
@IsOptional()
@IsString()
@MaxLength(100)
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit: number = 20;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset: number = 0;
}
// Param validation
export class UserIdParamDto {
@IsUUID('4')
id: string;
}
@Controller('users')
export class UsersController {
@Post()
create(@Body() dto: CreateUserDto): Promise<User> {
// dto is guaranteed to be valid
return this.usersService.create(dto);
}
@Get()
findAll(@Query() query: FindUsersQueryDto): Promise<User[]> {
// query.limit is a number, query.search is sanitized
return this.usersService.findAll(query);
}
@Get(':id')
findOne(@Param() params: UserIdParamDto): Promise<User> {
// params.id is a valid UUID
return this.usersService.findById(params.id);
}
}
```
Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation)

View File

@@ -0,0 +1,178 @@
---
title: Use Supertest for E2E Testing
impact: HIGH
impactDescription: Validates the full request/response cycle
tags: testing, e2e, supertest, integration
---
## Use Supertest for E2E Testing
End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss.
**Incorrect (no proper E2E setup or teardown):**
```typescript
// Only unit test controllers
describe('UsersController', () => {
it('should return users', async () => {
const service = { findAll: jest.fn().mockResolvedValue([]) };
const controller = new UsersController(service as any);
const result = await controller.findAll();
expect(result).toEqual([]);
// Doesn't test: routes, guards, pipes, serialization
});
});
// E2E tests without proper setup/teardown
describe('Users API', () => {
it('should create user', async () => {
const app = await NestFactory.create(AppModule);
// No proper initialization
// No cleanup after test
// Hits real database
});
});
```
**Correct (proper E2E setup with Supertest):**
```typescript
// Proper E2E test setup
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('UsersController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// Apply same config as production
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('/users (POST)', () => {
it('should create a user', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'John', email: 'john@test.com' })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('id');
expect(res.body.name).toBe('John');
expect(res.body.email).toBe('john@test.com');
});
});
it('should return 400 for invalid email', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'John', email: 'invalid-email' })
.expect(400)
.expect((res) => {
expect(res.body.message).toContain('email');
});
});
});
describe('/users/:id (GET)', () => {
it('should return 404 for non-existent user', () => {
return request(app.getHttpServer())
.get('/users/non-existent-id')
.expect(404);
});
});
});
// Testing with authentication
describe('Protected Routes (e2e)', () => {
let app: INestApplication;
let authToken: string;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
// Get auth token
const loginResponse = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'test@test.com', password: 'password' });
authToken = loginResponse.body.accessToken;
});
it('should return 401 without token', () => {
return request(app.getHttpServer())
.get('/users/me')
.expect(401);
});
it('should return user profile with valid token', () => {
return request(app.getHttpServer())
.get('/users/me')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(res.body.email).toBe('test@test.com');
});
});
});
// Database isolation for E2E tests
describe('Orders API (e2e)', () => {
let app: INestApplication;
let dataSource: DataSource;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: '.env.test', // Test database config
}),
AppModule,
],
}).compile();
app = moduleFixture.createNestApplication();
dataSource = moduleFixture.get(DataSource);
await app.init();
});
beforeEach(async () => {
// Clean database between tests
await dataSource.synchronize(true);
});
afterAll(async () => {
await dataSource.destroy();
await app.close();
});
});
```
Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing)

View File

@@ -0,0 +1,179 @@
---
title: Mock External Services in Tests
impact: HIGH
impactDescription: Ensures fast, reliable, deterministic tests
tags: testing, mocking, external-services, jest
---
## Mock External Services in Tests
Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors.
**Incorrect (calling real APIs and databases):**
```typescript
// Call real APIs in tests
describe('PaymentService', () => {
it('should process payment', async () => {
const service = new PaymentService(new StripeClient(realApiKey));
// Hits real Stripe API!
const result = await service.charge('tok_visa', 1000);
// Slow, costs money, flaky
});
});
// Use real database
describe('UsersService', () => {
beforeEach(async () => {
await connection.query('DELETE FROM users'); // Modifies real DB
});
it('should create user', async () => {
const user = await service.create({ email: 'test@test.com' });
// Side effects on shared database
});
});
// Incomplete mocks
const mockHttpService = {
get: jest.fn().mockResolvedValue({ data: {} }),
// Missing error scenarios, missing other methods
};
```
**Correct (mock all external dependencies):**
```typescript
// Mock HTTP service properly
describe('WeatherService', () => {
let service: WeatherService;
let httpService: jest.Mocked<HttpService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
WeatherService,
{
provide: HttpService,
useValue: {
get: jest.fn(),
post: jest.fn(),
},
},
],
}).compile();
service = module.get(WeatherService);
httpService = module.get(HttpService);
});
it('should return weather data', async () => {
const mockResponse = {
data: { temperature: 72, humidity: 45 },
status: 200,
statusText: 'OK',
headers: {},
config: {},
};
httpService.get.mockReturnValue(of(mockResponse));
const result = await service.getWeather('NYC');
expect(result).toEqual({ temperature: 72, humidity: 45 });
});
it('should handle API timeout', async () => {
httpService.get.mockReturnValue(
throwError(() => new Error('ETIMEDOUT')),
);
await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable');
});
it('should handle rate limiting', async () => {
httpService.get.mockReturnValue(
throwError(() => ({
response: { status: 429, data: { message: 'Rate limited' } },
})),
);
await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException);
});
});
// Mock repository instead of database
describe('UsersService', () => {
let service: UsersService;
let repo: jest.Mocked<Repository<User>>;
beforeEach(async () => {
const mockRepo = {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepo },
],
}).compile();
service = module.get(UsersService);
repo = module.get(getRepositoryToken(User));
});
it('should find user by id', async () => {
const mockUser = { id: '1', name: 'John', email: 'john@test.com' };
repo.findOne.mockResolvedValue(mockUser);
const result = await service.findById('1');
expect(result).toEqual(mockUser);
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } });
});
});
// Create mock factory for complex SDKs
function createMockStripe(): jest.Mocked<Stripe> {
return {
paymentIntents: {
create: jest.fn(),
retrieve: jest.fn(),
confirm: jest.fn(),
cancel: jest.fn(),
},
customers: {
create: jest.fn(),
retrieve: jest.fn(),
},
} as any;
}
// Mock time for time-dependent tests
describe('TokenService', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should expire token after 1 hour', async () => {
const token = await service.createToken();
// Fast-forward time
jest.advanceTimersByTime(61 * 60 * 1000);
expect(await service.isValid(token)).toBe(false);
});
});
```
Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions)

View File

@@ -0,0 +1,153 @@
---
title: Use Testing Module for Unit Tests
impact: HIGH
impactDescription: Enables proper isolated testing with mocked dependencies
tags: testing, unit-tests, mocking, jest
---
## Use Testing Module for Unit Tests
Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation.
**Incorrect (manual instantiation bypassing DI):**
```typescript
// Instantiate services manually without DI
describe('UsersService', () => {
it('should create user', async () => {
// Manual instantiation bypasses DI
const repo = new UserRepository(); // Real repo!
const service = new UsersService(repo);
const user = await service.create({ name: 'Test' });
// This hits the real database!
});
});
// Test implementation details
describe('UsersController', () => {
it('should call service', async () => {
const service = { create: jest.fn() };
const controller = new UsersController(service as any);
await controller.create({ name: 'Test' });
expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior
});
});
```
**Correct (use Test.createTestingModule with mocked dependencies):**
```typescript
// Use Test.createTestingModule for proper DI
import { Test, TestingModule } from '@nestjs/testing';
describe('UsersService', () => {
let service: UsersService;
let repo: jest.Mocked<UserRepository>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UserRepository,
useValue: {
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
repo = module.get(UserRepository);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('should save and return user', async () => {
const dto = { name: 'John', email: 'john@test.com' };
const expectedUser = { id: '1', ...dto };
repo.save.mockResolvedValue(expectedUser);
const result = await service.create(dto);
expect(result).toEqual(expectedUser);
expect(repo.save).toHaveBeenCalledWith(dto);
});
it('should throw on duplicate email', async () => {
repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' });
await expect(
service.create({ name: 'Test', email: 'test@test.com' }),
).rejects.toThrow(ConflictException);
});
});
describe('findById', () => {
it('should return user when found', async () => {
const user = { id: '1', name: 'John' };
repo.findOne.mockResolvedValue(user);
const result = await service.findById('1');
expect(result).toEqual(user);
});
it('should throw NotFoundException when not found', async () => {
repo.findOne.mockResolvedValue(null);
await expect(service.findById('999')).rejects.toThrow(NotFoundException);
});
});
});
// Testing guards and interceptors
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
reflector = module.get<Reflector>(Reflector);
});
it('should allow when no roles required', () => {
const context = createMockExecutionContext({ user: { roles: [] } });
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
expect(guard.canActivate(context)).toBe(true);
});
it('should allow admin for admin-only route', () => {
const context = createMockExecutionContext({ user: { roles: ['admin'] } });
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
expect(guard.canActivate(context)).toBe(true);
});
});
function createMockExecutionContext(request: Partial<Request>): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => request,
}),
getHandler: () => jest.fn(),
getClass: () => jest.fn(),
} as ExecutionContext;
}
```
Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing)

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env npx ts-node
/**
* Build script for generating AGENTS.md from individual rule files
*
* Usage: npx ts-node scripts/build-agents.ts
*
* This script:
* 1. Reads all rule files from the rules/ directory
* 2. Parses YAML frontmatter for metadata
* 3. Groups rules by category based on filename prefix
* 4. Generates a consolidated AGENTS.md file
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Category definitions with ordering and metadata
const CATEGORIES = [
{ prefix: 'arch-', name: 'Architecture', impact: 'CRITICAL', section: 1 },
{ prefix: 'di-', name: 'Dependency Injection', impact: 'CRITICAL', section: 2 },
{ prefix: 'error-', name: 'Error Handling', impact: 'HIGH', section: 3 },
{ prefix: 'security-', name: 'Security', impact: 'HIGH', section: 4 },
{ prefix: 'perf-', name: 'Performance', impact: 'HIGH', section: 5 },
{ prefix: 'test-', name: 'Testing', impact: 'MEDIUM-HIGH', section: 6 },
{ prefix: 'db-', name: 'Database & ORM', impact: 'MEDIUM-HIGH', section: 7 },
{ prefix: 'api-', name: 'API Design', impact: 'MEDIUM', section: 8 },
{ prefix: 'micro-', name: 'Microservices', impact: 'MEDIUM', section: 9 },
{ prefix: 'devops-', name: 'DevOps & Deployment', impact: 'LOW-MEDIUM', section: 10 },
];
interface RuleFrontmatter {
title: string;
impact: string;
impactDescription: string;
tags: string[];
}
interface Rule {
filename: string;
frontmatter: RuleFrontmatter;
content: string;
category: string;
categorySection: number;
}
function parseFrontmatter(content: string): { frontmatter: RuleFrontmatter | null; body: string } {
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
return { frontmatter: null, body: content };
}
const frontmatterStr = match[1];
const body = match[2];
// Simple YAML parsing for our expected format
const frontmatter: Partial<RuleFrontmatter> = {};
const lines = frontmatterStr.split('\n');
let currentKey = '';
let inArray = false;
const arrayItems: string[] = [];
for (const line of lines) {
if (line.match(/^[a-zA-Z]+:/)) {
// Save previous array if we were collecting one
if (inArray && currentKey === 'tags') {
frontmatter.tags = arrayItems;
}
inArray = false;
arrayItems.length = 0;
const [key, ...valueParts] = line.split(':');
const value = valueParts.join(':').trim();
currentKey = key.trim();
if (value === '') {
// Might be start of array
inArray = true;
} else {
(frontmatter as any)[currentKey] = value;
}
} else if (inArray && line.trim().startsWith('-')) {
arrayItems.push(line.trim().replace(/^-\s*/, ''));
}
}
// Save final array if needed
if (inArray && currentKey === 'tags') {
frontmatter.tags = arrayItems;
}
return {
frontmatter: frontmatter as RuleFrontmatter,
body: body.trim()
};
}
function getCategoryForFile(filename: string): { name: string; section: number } | null {
for (const cat of CATEGORIES) {
if (filename.startsWith(cat.prefix)) {
return { name: cat.name, section: cat.section };
}
}
return null;
}
function readMetadata(): any {
const metadataPath = path.join(__dirname, '..', 'metadata.json');
return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
}
function readRules(): Rule[] {
const rulesDir = path.join(__dirname, '..', 'rules');
const files = fs.readdirSync(rulesDir)
.filter(f => f.endsWith('.md') && !f.startsWith('_'));
const rules: Rule[] = [];
for (const file of files) {
const filePath = path.join(rulesDir, file);
const content = fs.readFileSync(filePath, 'utf-8');
const { frontmatter, body } = parseFrontmatter(content);
if (!frontmatter) {
console.warn(`Warning: No frontmatter found in ${file}`);
continue;
}
const category = getCategoryForFile(file);
if (!category) {
console.warn(`Warning: Unknown category for ${file}`);
continue;
}
rules.push({
filename: file,
frontmatter,
content: body,
category: category.name,
categorySection: category.section
});
}
return rules;
}
function generateTableOfContents(rulesByCategory: Map<string, Rule[]>): string {
let toc = '## Table of Contents\n\n';
for (const cat of CATEGORIES) {
const rules = rulesByCategory.get(cat.name);
if (!rules || rules.length === 0) continue;
// Section anchor format: #1-architecture
const sectionAnchor = `${cat.section}-${cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
toc += `${cat.section}. [${cat.name}](#${sectionAnchor}) — **${cat.impact}**\n`;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
// Rule anchor format: #11-rule-title
const ruleNum = `${cat.section}${i + 1}`;
const anchor = `${ruleNum}-${rule.frontmatter.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
toc += ` - ${cat.section}.${i + 1} [${rule.frontmatter.title}](#${anchor})\n`;
}
}
return toc;
}
function generateAgentsMd(rules: Rule[], metadata: any): string {
// Group rules by category
const rulesByCategory = new Map<string, Rule[]>();
for (const rule of rules) {
if (!rulesByCategory.has(rule.category)) {
rulesByCategory.set(rule.category, []);
}
rulesByCategory.get(rule.category)!.push(rule);
}
// Sort rules within each category alphabetically
for (const [category, categoryRules] of rulesByCategory) {
categoryRules.sort((a, b) => a.filename.localeCompare(b.filename));
}
// Build document
let doc = `# NestJS Best Practices
**Version ${metadata.version}**
${metadata.organization}
${metadata.date}
> **Note:**
> This document is mainly for agents and LLMs to follow when maintaining,
> generating, or refactoring NestJS codebases. Humans may also find it
> useful, but guidance here is optimized for automation and consistency
> by AI-assisted workflows.
---
## Abstract
${metadata.abstract}
---
`;
// Add table of contents
doc += generateTableOfContents(rulesByCategory);
doc += '\n---\n\n';
// Add rules by category
for (const cat of CATEGORIES) {
const categoryRules = rulesByCategory.get(cat.name);
if (!categoryRules || categoryRules.length === 0) continue;
doc += `## ${cat.section}. ${cat.name}\n\n`;
doc += `**Section Impact: ${cat.impact}**\n\n`;
for (let i = 0; i < categoryRules.length; i++) {
const rule = categoryRules[i];
const ruleNumber = `${cat.section}.${i + 1}`;
// Add rule header with number (anchor will be auto-generated as #11-title)
doc += `### ${ruleNumber} ${rule.frontmatter.title}\n\n`;
doc += `**Impact: ${rule.frontmatter.impact}** — ${rule.frontmatter.impactDescription}\n\n`;
// Add rule content (skip the first header since we already added it)
let ruleContent = rule.content;
// Remove the first h1 or h2 header if it matches the title
ruleContent = ruleContent.replace(/^#{1,2}\s+.*\n+/, '');
// Remove the impact line if present (we already added it)
ruleContent = ruleContent.replace(/^\*\*Impact:.*\*\*.*\n+/, '');
doc += ruleContent;
doc += '\n\n---\n\n';
}
}
// Add references footer
doc += `## References
`;
for (const ref of metadata.references) {
doc += `- ${ref}\n`;
}
doc += `
---
*Generated by build-agents.ts on ${new Date().toISOString().split('T')[0]}*
`;
return doc;
}
function main() {
console.log('Building AGENTS.md...\n');
const metadata = readMetadata();
console.log(`Version: ${metadata.version}`);
console.log(`Organization: ${metadata.organization}\n`);
const rules = readRules();
console.log(`Found ${rules.length} rules\n`);
// Count by category
const counts = new Map<string, number>();
for (const rule of rules) {
counts.set(rule.category, (counts.get(rule.category) || 0) + 1);
}
console.log('Rules by category:');
for (const cat of CATEGORIES) {
const count = counts.get(cat.name) || 0;
if (count > 0) {
console.log(` ${cat.name}: ${count}`);
}
}
console.log('');
const agentsMd = generateAgentsMd(rules, metadata);
const outputPath = path.join(__dirname, '..', 'AGENTS.md');
fs.writeFileSync(outputPath, agentsMd);
console.log(`Generated AGENTS.md (${agentsMd.length} bytes)`);
console.log(`Output: ${outputPath}`);
}
main();

View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Build script for generating AGENTS.md
# Usage: ./build.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Check if ts-node is available
if command -v npx &> /dev/null; then
echo "Running build with ts-node..."
npx ts-node build-agents.ts
else
echo "Error: npx not found. Please install Node.js."
exit 1
fi

View File

@@ -0,0 +1,15 @@
{
"name": "nestjs-best-practices-scripts",
"version": "1.0.0",
"type": "module",
"description": "Build scripts for NestJS Best Practices skillset",
"scripts": {
"build": "npx ts-node build-agents.ts",
"build:watch": "npx nodemon --watch ../rules --ext md --exec 'npx ts-node build-agents.ts'"
},
"devDependencies": {
"typescript": "^5.0.0",
"ts-node": "^10.9.0",
"@types/node": "^20.0.0"
}
}

View File

@@ -0,0 +1,153 @@
---
name: next-best-practices
description: Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
user-invocable: false
---
# Next.js Best Practices
Apply these rules when writing or reviewing Next.js code.
## File Conventions
See [file-conventions.md](./file-conventions.md) for:
- Project structure and special files
- Route segments (dynamic, catch-all, groups)
- Parallel and intercepting routes
- Middleware rename in v16 (middleware → proxy)
## RSC Boundaries
Detect invalid React Server Component patterns.
See [rsc-boundaries.md](./rsc-boundaries.md) for:
- Async client component detection (invalid)
- Non-serializable props detection
- Server Action exceptions
## Async Patterns
Next.js 15+ async API changes.
See [async-patterns.md](./async-patterns.md) for:
- Async `params` and `searchParams`
- Async `cookies()` and `headers()`
- Migration codemod
## Runtime Selection
See [runtime-selection.md](./runtime-selection.md) for:
- Default to Node.js runtime
- When Edge runtime is appropriate
## Directives
See [directives.md](./directives.md) for:
- `'use client'`, `'use server'` (React)
- `'use cache'` (Next.js)
## Functions
See [functions.md](./functions.md) for:
- Navigation hooks: `useRouter`, `usePathname`, `useSearchParams`, `useParams`
- Server functions: `cookies`, `headers`, `draftMode`, `after`
- Generate functions: `generateStaticParams`, `generateMetadata`
## Error Handling
See [error-handling.md](./error-handling.md) for:
- `error.tsx`, `global-error.tsx`, `not-found.tsx`
- `redirect`, `permanentRedirect`, `notFound`
- `forbidden`, `unauthorized` (auth errors)
- `unstable_rethrow` for catch blocks
## Data Patterns
See [data-patterns.md](./data-patterns.md) for:
- Server Components vs Server Actions vs Route Handlers
- Avoiding data waterfalls (`Promise.all`, Suspense, preload)
- Client component data fetching
## Route Handlers
See [route-handlers.md](./route-handlers.md) for:
- `route.ts` basics
- GET handler conflicts with `page.tsx`
- Environment behavior (no React DOM)
- When to use vs Server Actions
## Metadata & OG Images
See [metadata.md](./metadata.md) for:
- Static and dynamic metadata
- `generateMetadata` function
- OG image generation with `next/og`
- File-based metadata conventions
## Image Optimization
See [image.md](./image.md) for:
- Always use `next/image` over `<img>`
- Remote images configuration
- Responsive `sizes` attribute
- Blur placeholders
- Priority loading for LCP
## Font Optimization
See [font.md](./font.md) for:
- `next/font` setup
- Google Fonts, local fonts
- Tailwind CSS integration
- Preloading subsets
## Bundling
See [bundling.md](./bundling.md) for:
- Server-incompatible packages
- CSS imports (not link tags)
- Polyfills (already included)
- ESM/CommonJS issues
- Bundle analysis
## Scripts
See [scripts.md](./scripts.md) for:
- `next/script` vs native script tags
- Inline scripts need `id`
- Loading strategies
- Google Analytics with `@next/third-parties`
## Hydration Errors
See [hydration-error.md](./hydration-error.md) for:
- Common causes (browser APIs, dates, invalid HTML)
- Debugging with error overlay
- Fixes for each cause
## Suspense Boundaries
See [suspense-boundaries.md](./suspense-boundaries.md) for:
- CSR bailout with `useSearchParams` and `usePathname`
- Which hooks require Suspense boundaries
## Parallel & Intercepting Routes
See [parallel-routes.md](./parallel-routes.md) for:
- Modal patterns with `@slot` and `(.)` interceptors
- `default.tsx` for fallbacks
- Closing modals correctly with `router.back()`
## Self-Hosting
See [self-hosting.md](./self-hosting.md) for:
- `output: 'standalone'` for Docker
- Cache handlers for multi-instance ISR
- What works vs needs extra setup
## Debug Tricks
See [debug-tricks.md](./debug-tricks.md) for:
- MCP endpoint for AI-assisted debugging
- Rebuild specific routes with `--debug-build-paths`

View File

@@ -0,0 +1,87 @@
# Async Patterns
In Next.js 15+, `params`, `searchParams`, `cookies()`, and `headers()` are asynchronous.
## Async Params and SearchParams
Always type them as `Promise<...>` and await them.
### Pages and Layouts
```tsx
type Props = { params: Promise<{ slug: string }> }
export default async function Page({ params }: Props) {
const { slug } = await params
}
```
### Route Handlers
```tsx
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
}
```
### SearchParams
```tsx
type Props = {
params: Promise<{ slug: string }>
searchParams: Promise<{ query?: string }>
}
export default async function Page({ params, searchParams }: Props) {
const { slug } = await params
const { query } = await searchParams
}
```
### Synchronous Components
Use `React.use()` for non-async components:
```tsx
import { use } from 'react'
type Props = { params: Promise<{ slug: string }> }
export default function Page({ params }: Props) {
const { slug } = use(params)
}
```
### generateMetadata
```tsx
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
return { title: slug }
}
```
## Async Cookies and Headers
```tsx
import { cookies, headers } from 'next/headers'
export default async function Page() {
const cookieStore = await cookies()
const headersList = await headers()
const theme = cookieStore.get('theme')
const userAgent = headersList.get('user-agent')
}
```
## Migration Codemod
```bash
npx @next/codemod@latest next-async-request-api .
```

View File

@@ -0,0 +1,180 @@
# Bundling
Fix common bundling issues with third-party packages.
## Server-Incompatible Packages
Some packages use browser APIs (`window`, `document`, `localStorage`) and fail in Server Components.
### Error Signs
```
ReferenceError: window is not defined
ReferenceError: document is not defined
ReferenceError: localStorage is not defined
Module not found: Can't resolve 'fs'
```
### Solution 1: Mark as Client-Only
If the package is only needed on client:
```tsx
// Bad: Fails - package uses window
import SomeChart from 'some-chart-library'
export default function Page() {
return <SomeChart />
}
// Good: Use dynamic import with ssr: false
import dynamic from 'next/dynamic'
const SomeChart = dynamic(() => import('some-chart-library'), {
ssr: false,
})
export default function Page() {
return <SomeChart />
}
```
### Solution 2: Externalize from Server Bundle
For packages that should run on server but have bundling issues:
```js
// next.config.js
module.exports = {
serverExternalPackages: ['problematic-package'],
}
```
Use this for:
- Packages with native bindings (sharp, bcrypt)
- Packages that don't bundle well (some ORMs)
- Packages with circular dependencies
### Solution 3: Client Component Wrapper
Wrap the entire usage in a client component:
```tsx
// components/ChartWrapper.tsx
'use client'
import { Chart } from 'chart-library'
export function ChartWrapper(props) {
return <Chart {...props} />
}
// app/page.tsx (server component)
import { ChartWrapper } from '@/components/ChartWrapper'
export default function Page() {
return <ChartWrapper data={data} />
}
```
## CSS Imports
Import CSS files instead of using `<link>` tags. Next.js handles bundling and optimization.
```tsx
// Bad: Manual link tag
<link rel="stylesheet" href="/styles.css" />
// Good: Import CSS
import './styles.css'
// Good: CSS Modules
import styles from './Button.module.css'
```
## Polyfills
Next.js includes common polyfills automatically. Don't load redundant ones from polyfill.io or similar CDNs.
Already included: `Array.from`, `Object.assign`, `Promise`, `fetch`, `Map`, `Set`, `Symbol`, `URLSearchParams`, and 50+ others.
```tsx
// Bad: Redundant polyfills
<script src="https://polyfill.io/v3/polyfill.min.js?features=fetch,Promise,Array.from" />
// Good: Next.js includes these automatically
```
## ESM/CommonJS Issues
### Error Signs
```
SyntaxError: Cannot use import statement outside a module
Error: require() of ES Module
Module not found: ESM packages need to be imported
```
### Solution: Transpile Package
```js
// next.config.js
module.exports = {
transpilePackages: ['some-esm-package', 'another-package'],
}
```
## Common Problematic Packages
| Package | Issue | Solution |
|---------|-------|----------|
| `sharp` | Native bindings | `serverExternalPackages: ['sharp']` |
| `bcrypt` | Native bindings | `serverExternalPackages: ['bcrypt']` or use `bcryptjs` |
| `canvas` | Native bindings | `serverExternalPackages: ['canvas']` |
| `recharts` | Uses window | `dynamic(() => import('recharts'), { ssr: false })` |
| `react-quill` | Uses document | `dynamic(() => import('react-quill'), { ssr: false })` |
| `mapbox-gl` | Uses window | `dynamic(() => import('mapbox-gl'), { ssr: false })` |
| `monaco-editor` | Uses window | `dynamic(() => import('@monaco-editor/react'), { ssr: false })` |
| `lottie-web` | Uses document | `dynamic(() => import('lottie-react'), { ssr: false })` |
## Bundle Analysis
Analyze bundle size with the built-in analyzer (Next.js 16.1+):
```bash
next experimental-analyze
```
This opens an interactive UI to:
- Filter by route, environment (client/server), and type
- Inspect module sizes and import chains
- View treemap visualization
Save output for comparison:
```bash
next experimental-analyze --output
# Output saved to .next/diagnostics/analyze
```
Reference: https://nextjs.org/docs/app/guides/package-bundling
## Migrating from Webpack to Turbopack
Turbopack is the default bundler in Next.js 15+. If you have custom webpack config, migrate to Turbopack-compatible alternatives:
```js
// next.config.js
module.exports = {
// Good: Works with Turbopack
serverExternalPackages: ['package'],
transpilePackages: ['package'],
// Bad: Webpack-only - migrate away from this
webpack: (config) => {
// custom webpack config
},
}
```
Reference: https://nextjs.org/docs/app/building-your-application/upgrading/from-webpack-to-turbopack

View File

@@ -0,0 +1,297 @@
# Data Patterns
Choose the right data fetching pattern for each use case.
## Decision Tree
```
Need to fetch data?
├── From a Server Component?
│ └── Use: Fetch directly (no API needed)
├── From a Client Component?
│ ├── Is it a mutation (POST/PUT/DELETE)?
│ │ └── Use: Server Action
│ └── Is it a read (GET)?
│ └── Use: Route Handler OR pass from Server Component
├── Need external API access (webhooks, third parties)?
│ └── Use: Route Handler
└── Need REST API for mobile app / external clients?
└── Use: Route Handler
```
## Pattern 1: Server Components (Preferred for Reads)
Fetch data directly in Server Components - no API layer needed.
```tsx
// app/users/page.tsx
async function UsersPage() {
// Direct database access - no API round-trip
const users = await db.user.findMany();
// Or fetch from external API
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
```
**Benefits**:
- No API to maintain
- No client-server waterfall
- Secrets stay on server
- Direct database access
## Pattern 2: Server Actions (Preferred for Mutations)
Server Actions are the recommended way to handle mutations.
```tsx
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.post.create({ data: { title } });
revalidatePath('/posts');
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidateTag('posts');
}
```
```tsx
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}
```
**Benefits**:
- End-to-end type safety
- Progressive enhancement (works without JS)
- Automatic request handling
- Integrated with React transitions
**Constraints**:
- POST only (no GET caching semantics)
- Internal use only (no external access)
- Cannot return non-serializable data
## Pattern 3: Route Handlers (APIs)
Use Route Handlers when you need a REST API.
```tsx
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET is cacheable
export async function GET(request: NextRequest) {
const posts = await db.post.findMany();
return NextResponse.json(posts);
}
// POST for mutations
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
```
**When to use**:
- External API access (mobile apps, third parties)
- Webhooks from external services
- GET endpoints that need HTTP caching
- OpenAPI/Swagger documentation needed
**When NOT to use**:
- Internal data fetching (use Server Components)
- Mutations from your UI (use Server Actions)
## Avoiding Data Waterfalls
### Problem: Sequential Fetches
```tsx
// Bad: Sequential waterfalls
async function Dashboard() {
const user = await getUser(); // Wait...
const posts = await getPosts(); // Then wait...
const comments = await getComments(); // Then wait...
return <div>...</div>;
}
```
### Solution 1: Parallel Fetching with Promise.all
```tsx
// Good: Parallel fetching
async function Dashboard() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
return <div>...</div>;
}
```
### Solution 2: Streaming with Suspense
```tsx
// Good: Show content progressively
import { Suspense } from 'react';
async function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserSection />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
</div>
);
}
async function UserSection() {
const user = await getUser(); // Fetches independently
return <div>{user.name}</div>;
}
async function PostsSection() {
const posts = await getPosts(); // Fetches independently
return <PostList posts={posts} />;
}
```
### Solution 3: Preload Pattern
```tsx
// lib/data.ts
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
export const preloadUser = (id: string) => {
void getUser(id); // Fire and forget
};
```
```tsx
// app/user/[id]/page.tsx
import { getUser, preloadUser } from '@/lib/data';
export default async function UserPage({ params }) {
const { id } = await params;
// Start fetching early
preloadUser(id);
// Do other work...
// Data likely ready by now
const user = await getUser(id);
return <div>{user.name}</div>;
}
```
## Client Component Data Fetching
When Client Components need data:
### Option 1: Pass from Server Component (Preferred)
```tsx
// Server Component
async function Page() {
const data = await fetchData();
return <ClientComponent initialData={data} />;
}
// Client Component
'use client';
function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData);
// ...
}
```
### Option 2: Fetch on Mount (When Necessary)
```tsx
'use client';
import { useEffect, useState } from 'react';
function ClientComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(setData);
}, []);
if (!data) return <Loading />;
return <div>{data.value}</div>;
}
```
### Option 3: Server Action for Reads (Works But Not Ideal)
Server Actions can be called from Client Components for reads, but this is not their intended purpose:
```tsx
'use client';
import { getData } from './actions';
import { useEffect, useState } from 'react';
function ClientComponent() {
const [data, setData] = useState(null);
useEffect(() => {
getData().then(setData);
}, []);
return <div>{data?.value}</div>;
}
```
**Note**: Server Actions always use POST, so no HTTP caching. Prefer Route Handlers for cacheable reads.
## Quick Reference
| Pattern | Use Case | HTTP Method | Caching |
|---------|----------|-------------|---------|
| Server Component fetch | Internal reads | Any | Full Next.js caching |
| Server Action | Mutations, form submissions | POST only | No |
| Route Handler | External APIs, webhooks | Any | GET can be cached |
| Client fetch to API | Client-side reads | Any | HTTP cache headers |

View File

@@ -0,0 +1,105 @@
# Debug Tricks
Tricks to speed up debugging Next.js applications.
## MCP Endpoint (Dev Server)
Next.js exposes a `/_next/mcp` endpoint in development for AI-assisted debugging via MCP (Model Context Protocol).
- **Next.js 16+**: Enabled by default, use `next-devtools-mcp`
- **Next.js < 16**: Requires `experimental.mcpServer: true` in next.config.js
Reference: https://nextjs.org/docs/app/guides/mcp
**Important**: Find the actual port of the running Next.js dev server (check terminal output or `package.json` scripts). Don't assume port 3000.
### Request Format
The endpoint uses JSON-RPC 2.0 over HTTP POST:
```bash
curl -X POST http://localhost:<port>/_next/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "<tool-name>",
"arguments": {}
}
}'
```
### Available Tools
#### `get_errors`
Get current errors from dev server (build errors, runtime errors with source-mapped stacks):
```json
{ "name": "get_errors", "arguments": {} }
```
#### `get_routes`
Discover all routes by scanning filesystem:
```json
{ "name": "get_routes", "arguments": {} }
// Optional: { "name": "get_routes", "arguments": { "routerType": "app" } }
```
Returns: `{ "appRouter": ["/", "/api/users/[id]", ...], "pagesRouter": [...] }`
#### `get_project_metadata`
Get project path and dev server URL:
```json
{ "name": "get_project_metadata", "arguments": {} }
```
Returns: `{ "projectPath": "/path/to/project", "devServerUrl": "http://localhost:3000" }`
#### `get_page_metadata`
Get runtime metadata about current page render (requires active browser session):
```json
{ "name": "get_page_metadata", "arguments": {} }
```
Returns segment trie data showing layouts, boundaries, and page components.
#### `get_logs`
Get path to Next.js development log file:
```json
{ "name": "get_logs", "arguments": {} }
```
Returns path to `<distDir>/logs/next-development.log`
#### `get_server_action_by_id`
Locate a Server Action by ID:
```json
{ "name": "get_server_action_by_id", "arguments": { "actionId": "<action-id>" } }
```
### Example: Get Errors
```bash
curl -X POST http://localhost:<port>/_next/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"get_errors","arguments":{}}}'
```
## Rebuild Specific Routes (Next.js 16+)
Use `--debug-build-paths` to rebuild only specific routes instead of the entire app:
```bash
# Rebuild a specific route
next build --debug-build-paths "/dashboard"
# Rebuild routes matching a glob
next build --debug-build-paths "/api/*"
# Dynamic routes
next build --debug-build-paths "/blog/[slug]"
```
Use this to:
- Quickly verify a build fix without full rebuild
- Debug static generation issues for specific pages
- Iterate faster on build errors

View File

@@ -0,0 +1,73 @@
# Directives
## React Directives
These are React directives, not Next.js specific.
### `'use client'`
Marks a component as a Client Component. Required for:
- React hooks (`useState`, `useEffect`, etc.)
- Event handlers (`onClick`, `onChange`)
- Browser APIs (`window`, `localStorage`)
```tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
```
Reference: https://react.dev/reference/rsc/use-client
### `'use server'`
Marks a function as a Server Action. Can be passed to Client Components.
```tsx
'use server'
export async function submitForm(formData: FormData) {
// Runs on server
}
```
Or inline within a Server Component:
```tsx
export default function Page() {
async function submit() {
'use server'
// Runs on server
}
return <form action={submit}>...</form>
}
```
Reference: https://react.dev/reference/rsc/use-server
---
## Next.js Directive
### `'use cache'`
Marks a function or component for caching. Part of Next.js Cache Components.
```tsx
'use cache'
export async function getCachedData() {
return await fetchData()
}
```
Requires `cacheComponents: true` in `next.config.ts`.
For detailed usage including cache profiles, `cacheLife()`, `cacheTag()`, and `updateTag()`, see the `next-cache-components` skill.
Reference: https://nextjs.org/docs/app/api-reference/directives/use-cache

View File

@@ -0,0 +1,227 @@
# Error Handling
Handle errors gracefully in Next.js applications.
Reference: https://nextjs.org/docs/app/getting-started/error-handling
## Error Boundaries
### `error.tsx`
Catches errors in a route segment and its children:
```tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
```
**Important:** `error.tsx` must be a Client Component.
### `global-error.tsx`
Catches errors in root layout:
```tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}
```
**Important:** Must include `<html>` and `<body>` tags.
## Server Actions: Navigation API Gotcha
**Do NOT wrap navigation APIs in try-catch.** They throw special errors that Next.js handles internally.
Reference: https://nextjs.org/docs/app/api-reference/functions/redirect#behavior
```tsx
'use server'
import { redirect } from 'next/navigation'
import { notFound } from 'next/navigation'
// Bad: try-catch catches the navigation "error"
async function createPost(formData: FormData) {
try {
const post = await db.post.create({ ... })
redirect(`/posts/${post.id}`) // This throws!
} catch (error) {
// redirect() throw is caught here - navigation fails!
return { error: 'Failed to create post' }
}
}
// Good: Call navigation APIs outside try-catch
async function createPost(formData: FormData) {
let post
try {
post = await db.post.create({ ... })
} catch (error) {
return { error: 'Failed to create post' }
}
redirect(`/posts/${post.id}`) // Outside try-catch
}
// Good: Re-throw navigation errors
async function createPost(formData: FormData) {
try {
const post = await db.post.create({ ... })
redirect(`/posts/${post.id}`)
} catch (error) {
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
throw error // Re-throw navigation errors
}
return { error: 'Failed to create post' }
}
}
```
Same applies to:
- `redirect()` - 307 temporary redirect
- `permanentRedirect()` - 308 permanent redirect
- `notFound()` - 404 not found
- `forbidden()` - 403 forbidden
- `unauthorized()` - 401 unauthorized
Use `unstable_rethrow()` to re-throw these errors in catch blocks:
```tsx
import { unstable_rethrow } from 'next/navigation'
async function action() {
try {
// ...
redirect('/success')
} catch (error) {
unstable_rethrow(error) // Re-throws Next.js internal errors
return { error: 'Something went wrong' }
}
}
```
## Redirects
```tsx
import { redirect, permanentRedirect } from 'next/navigation'
// 307 Temporary - use for most cases
redirect('/new-path')
// 308 Permanent - use for URL migrations (cached by browsers)
permanentRedirect('/new-url')
```
## Auth Errors
Trigger auth-related error pages:
```tsx
import { forbidden, unauthorized } from 'next/navigation'
async function Page() {
const session = await getSession()
if (!session) {
unauthorized() // Renders unauthorized.tsx (401)
}
if (!session.hasAccess) {
forbidden() // Renders forbidden.tsx (403)
}
return <Dashboard />
}
```
Create corresponding error pages:
```tsx
// app/forbidden.tsx
export default function Forbidden() {
return <div>You don't have access to this resource</div>
}
// app/unauthorized.tsx
export default function Unauthorized() {
return <div>Please log in to continue</div>
}
```
## Not Found
### `not-found.tsx`
Custom 404 page for a route segment:
```tsx
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find the requested resource</p>
</div>
)
}
```
### Triggering Not Found
```tsx
import { notFound } from 'next/navigation'
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await getPost(id)
if (!post) {
notFound() // Renders closest not-found.tsx
}
return <div>{post.title}</div>
}
```
## Error Hierarchy
Errors bubble up to the nearest error boundary:
```
app/
├── error.tsx # Catches errors from all children
├── blog/
│ ├── error.tsx # Catches errors in /blog/*
│ └── [slug]/
│ ├── error.tsx # Catches errors in /blog/[slug]
│ └── page.tsx
└── layout.tsx # Errors here go to global-error.tsx
```

View File

@@ -0,0 +1,140 @@
# File Conventions
Next.js App Router uses file-based routing with special file conventions.
## Project Structure
Reference: https://nextjs.org/docs/app/getting-started/project-structure
```
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── not-found.tsx # 404 UI
├── global-error.tsx # Global error UI
├── route.ts # API endpoint
├── template.tsx # Re-rendered layout
├── default.tsx # Parallel route fallback
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
└── (group)/ # Route group (no URL impact)
└── page.tsx
```
## Special Files
| File | Purpose |
|------|---------|
| `page.tsx` | UI for a route segment |
| `layout.tsx` | Shared UI for segment and children |
| `loading.tsx` | Loading UI (Suspense boundary) |
| `error.tsx` | Error UI (Error boundary) |
| `not-found.tsx` | 404 UI |
| `route.ts` | API endpoint |
| `template.tsx` | Like layout but re-renders on navigation |
| `default.tsx` | Fallback for parallel routes |
## Route Segments
```
app/
├── blog/ # Static segment: /blog
├── [slug]/ # Dynamic segment: /:slug
├── [...slug]/ # Catch-all: /a/b/c
├── [[...slug]]/ # Optional catch-all: / or /a/b/c
└── (marketing)/ # Route group (ignored in URL)
```
## Parallel Routes
```
app/
├── @analytics/
│ └── page.tsx
├── @sidebar/
│ └── page.tsx
└── layout.tsx # Receives { analytics, sidebar } as props
```
## Intercepting Routes
```
app/
├── feed/
│ └── page.tsx
├── @modal/
│ └── (.)photo/[id]/ # Intercepts /photo/[id] from /feed
│ └── page.tsx
└── photo/[id]/
└── page.tsx
```
Conventions:
- `(.)` - same level
- `(..)` - one level up
- `(..)(..)` - two levels up
- `(...)` - from root
## Private Folders
```
app/
├── _components/ # Private folder (not a route)
│ └── Button.tsx
└── page.tsx
```
Prefix with `_` to exclude from routing.
## Middleware / Proxy
### Next.js 14-15: `middleware.ts`
```ts
// middleware.ts (root of project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Auth, redirects, rewrites, etc.
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
```
### Next.js 16+: `proxy.ts`
Renamed for clarity - same capabilities, different names:
```ts
// proxy.ts (root of project)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
// Same logic as middleware
return NextResponse.next();
}
export const proxyConfig = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
```
| Version | File | Export | Config |
|---------|------|--------|--------|
| v14-15 | `middleware.ts` | `middleware()` | `config` |
| v16+ | `proxy.ts` | `proxy()` | `proxyConfig` |
**Migration**: Run `npx @next/codemod@latest upgrade` to auto-rename.
## File Conventions Reference
Reference: https://nextjs.org/docs/app/api-reference/file-conventions

View File

@@ -0,0 +1,245 @@
# Font Optimization
Use `next/font` for automatic font optimization with zero layout shift.
## Google Fonts
```tsx
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}
```
## Multiple Fonts
```tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-roboto-mono',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}
```
Use in CSS:
```css
body {
font-family: var(--font-inter);
}
code {
font-family: var(--font-roboto-mono);
}
```
## Font Weights and Styles
```tsx
// Single weight
const inter = Inter({
subsets: ['latin'],
weight: '400',
})
// Multiple weights
const inter = Inter({
subsets: ['latin'],
weight: ['400', '500', '700'],
})
// Variable font (recommended) - includes all weights
const inter = Inter({
subsets: ['latin'],
// No weight needed - variable fonts support all weights
})
// With italic
const inter = Inter({
subsets: ['latin'],
style: ['normal', 'italic'],
})
```
## Local Fonts
```tsx
import localFont from 'next/font/local'
const myFont = localFont({
src: './fonts/MyFont.woff2',
})
// Multiple files for different weights
const myFont = localFont({
src: [
{
path: './fonts/MyFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/MyFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
})
// Variable font
const myFont = localFont({
src: './fonts/MyFont-Variable.woff2',
variable: '--font-my-font',
})
```
## Tailwind CSS Integration
```tsx
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
)
}
```
```js
// tailwind.config.js
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)'],
},
},
},
}
```
## Preloading Subsets
Only load needed character subsets:
```tsx
// Latin only (most common)
const inter = Inter({ subsets: ['latin'] })
// Multiple subsets
const inter = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] })
```
## Display Strategy
Control font loading behavior:
```tsx
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Default - shows fallback, swaps when loaded
})
// Options:
// 'auto' - browser decides
// 'block' - short block period, then swap
// 'swap' - immediate fallback, swap when ready (recommended)
// 'fallback' - short block, short swap, then fallback
// 'optional' - short block, no swap (use if font is optional)
```
## Don't Use Manual Font Links
Always use `next/font` instead of `<link>` tags for Google Fonts.
```tsx
// Bad: Manual link tag (blocks rendering, no optimization)
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
// Bad: Missing display and preconnect
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
// Good: Use next/font (self-hosted, zero layout shift)
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
```
## Common Mistakes
```tsx
// Bad: Importing font in every component
// components/Button.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] }) // Creates new instance each time!
// Good: Import once in layout, use CSS variable
// app/layout.tsx
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
// Bad: Using @import in CSS (blocks rendering)
/* globals.css */
@import url('https://fonts.googleapis.com/css2?family=Inter');
// Good: Use next/font (self-hosted, no network request)
import { Inter } from 'next/font/google'
// Bad: Loading all weights when only using a few
const inter = Inter({ subsets: ['latin'] }) // Loads all weights
// Good: Specify only needed weights (for non-variable fonts)
const inter = Inter({ subsets: ['latin'], weight: ['400', '700'] })
// Bad: Missing subset - loads all characters
const inter = Inter({})
// Good: Always specify subset
const inter = Inter({ subsets: ['latin'] })
```
## Font in Specific Components
```tsx
// For component-specific fonts, export from a shared file
// lib/fonts.ts
import { Inter, Playfair_Display } from 'next/font/google'
export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
export const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair' })
// components/Heading.tsx
import { playfair } from '@/lib/fonts'
export function Heading({ children }) {
return <h1 className={playfair.className}>{children}</h1>
}
```

View File

@@ -0,0 +1,108 @@
# Functions
Next.js function APIs.
Reference: https://nextjs.org/docs/app/api-reference/functions
## Navigation Hooks (Client)
| Hook | Purpose | Reference |
|------|---------|-----------|
| `useRouter` | Programmatic navigation (`push`, `replace`, `back`, `refresh`) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-router) |
| `usePathname` | Get current pathname | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-pathname) |
| `useSearchParams` | Read URL search parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-search-params) |
| `useParams` | Access dynamic route parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-params) |
| `useSelectedLayoutSegment` | Active child segment (one level) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) |
| `useSelectedLayoutSegments` | All active segments below layout | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) |
| `useLinkStatus` | Check link prefetch status | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) |
| `useReportWebVitals` | Report Core Web Vitals metrics | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals) |
## Server Functions
| Function | Purpose | Reference |
|----------|---------|-----------|
| `cookies` | Read/write cookies | [Docs](https://nextjs.org/docs/app/api-reference/functions/cookies) |
| `headers` | Read request headers | [Docs](https://nextjs.org/docs/app/api-reference/functions/headers) |
| `draftMode` | Enable preview of unpublished CMS content | [Docs](https://nextjs.org/docs/app/api-reference/functions/draft-mode) |
| `after` | Run code after response finishes streaming | [Docs](https://nextjs.org/docs/app/api-reference/functions/after) |
| `connection` | Wait for connection before dynamic rendering | [Docs](https://nextjs.org/docs/app/api-reference/functions/connection) |
| `userAgent` | Parse User-Agent header | [Docs](https://nextjs.org/docs/app/api-reference/functions/userAgent) |
## Generate Functions
| Function | Purpose | Reference |
|----------|---------|-----------|
| `generateStaticParams` | Pre-render dynamic routes at build time | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) |
| `generateMetadata` | Dynamic metadata | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) |
| `generateViewport` | Dynamic viewport config | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-viewport) |
| `generateSitemaps` | Multiple sitemaps for large sites | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) |
| `generateImageMetadata` | Multiple OG images per route | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata) |
## Request/Response
| Function | Purpose | Reference |
|----------|---------|-----------|
| `NextRequest` | Extended Request with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-request) |
| `NextResponse` | Extended Response with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-response) |
| `ImageResponse` | Generate OG images | [Docs](https://nextjs.org/docs/app/api-reference/functions/image-response) |
## Common Examples
### Navigation
Use `next/link` for internal navigation instead of `<a>` tags.
```tsx
// Bad: Plain anchor tag
<a href="/about">About</a>
// Good: Next.js Link
import Link from 'next/link'
<Link href="/about">About</Link>
```
Active link styling:
```tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export function NavLink({ href, children }) {
const pathname = usePathname()
return (
<Link href={href} className={pathname === href ? 'active' : ''}>
{children}
</Link>
)
}
```
### Static Generation
```tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
```
### After Response
```tsx
import { after } from 'next/server'
export async function POST(request: Request) {
const data = await processRequest(request)
after(async () => {
await logAnalytics(data)
})
return Response.json({ success: true })
}
```

View File

@@ -0,0 +1,91 @@
# Hydration Errors
Diagnose and fix React hydration mismatch errors.
## Error Signs
- "Hydration failed because the initial UI does not match"
- "Text content does not match server-rendered HTML"
## Debugging
In development, click the hydration error to see the server/client diff.
## Common Causes and Fixes
### Browser-only APIs
```tsx
// Bad: Causes mismatch - window doesn't exist on server
<div>{window.innerWidth}</div>
// Good: Use client component with mounted check
'use client'
import { useState, useEffect } from 'react'
export function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return mounted ? children : null
}
```
### Date/Time Rendering
Server and client may be in different timezones:
```tsx
// Bad: Causes mismatch
<span>{new Date().toLocaleString()}</span>
// Good: Render on client only
'use client'
const [time, setTime] = useState<string>()
useEffect(() => setTime(new Date().toLocaleString()), [])
```
### Random Values or IDs
```tsx
// Bad: Random values differ between server and client
<div id={Math.random().toString()}>
// Good: Use useId hook
import { useId } from 'react'
function Input() {
const id = useId()
return <input id={id} />
}
```
### Invalid HTML Nesting
```tsx
// Bad: Invalid - div inside p
<p><div>Content</div></p>
// Bad: Invalid - p inside p
<p><p>Nested</p></p>
// Good: Valid nesting
<div><p>Content</p></div>
```
### Third-party Scripts
Scripts that modify DOM during hydration.
```tsx
// Good: Use next/script with afterInteractive
import Script from 'next/script'
export default function Page() {
return (
<Script
src="https://example.com/script.js"
strategy="afterInteractive"
/>
)
}
```

View File

@@ -0,0 +1,173 @@
# Image Optimization
Use `next/image` for automatic image optimization.
## Always Use next/image
```tsx
// Bad: Avoid native img
<img src="/hero.png" alt="Hero" />
// Good: Use next/image
import Image from 'next/image'
<Image src="/hero.png" alt="Hero" width={800} height={400} />
```
## Required Props
Images need explicit dimensions to prevent layout shift:
```tsx
// Local images - dimensions inferred automatically
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" />
// Remote images - must specify width/height
<Image src="https://example.com/image.jpg" alt="Hero" width={800} height={400} />
// Or use fill for parent-relative sizing
<div style={{ position: 'relative', width: '100%', height: 400 }}>
<Image src="/hero.png" alt="Hero" fill style={{ objectFit: 'cover' }} />
</div>
```
## Remote Images Configuration
Remote domains must be configured in `next.config.js`:
```js
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: '*.cdn.com', // Wildcard subdomain
},
],
},
}
```
## Responsive Images
Use `sizes` to tell the browser which size to download:
```tsx
// Full-width hero
<Image
src="/hero.png"
alt="Hero"
fill
sizes="100vw"
/>
// Responsive grid (3 columns on desktop, 1 on mobile)
<Image
src="/card.png"
alt="Card"
fill
sizes="(max-width: 768px) 100vw, 33vw"
/>
// Fixed sidebar image
<Image
src="/avatar.png"
alt="Avatar"
width={200}
height={200}
sizes="200px"
/>
```
## Blur Placeholder
Prevent layout shift with placeholders:
```tsx
// Local images - automatic blur hash
import heroImage from './hero.png'
<Image src={heroImage} alt="Hero" placeholder="blur" />
// Remote images - provide blurDataURL
<Image
src="https://example.com/image.jpg"
alt="Hero"
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>
// Or use color placeholder
<Image
src="https://example.com/image.jpg"
alt="Hero"
width={800}
height={400}
placeholder="empty"
style={{ backgroundColor: '#e0e0e0' }}
/>
```
## Priority Loading
Use `priority` for above-the-fold images (LCP):
```tsx
// Hero image - loads immediately
<Image src="/hero.png" alt="Hero" fill priority />
// Below-fold images - lazy loaded by default (no priority needed)
<Image src="/card.png" alt="Card" width={400} height={300} />
```
## Common Mistakes
```tsx
// Bad: Missing sizes with fill - downloads largest image
<Image src="/hero.png" alt="Hero" fill />
// Good: Add sizes for proper responsive behavior
<Image src="/hero.png" alt="Hero" fill sizes="100vw" />
// Bad: Using width/height for aspect ratio only
<Image src="/hero.png" alt="Hero" width={16} height={9} />
// Good: Use actual display dimensions or fill with sizes
<Image src="/hero.png" alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
// Bad: Remote image without config
<Image src="https://untrusted.com/image.jpg" alt="Image" width={400} height={300} />
// Error: Invalid src prop, hostname not configured
// Good: Add hostname to next.config.js remotePatterns
```
## Static Export
When using `output: 'export'`, use `unoptimized` or custom loader:
```tsx
// Option 1: Disable optimization
<Image src="/hero.png" alt="Hero" width={800} height={400} unoptimized />
// Option 2: Global config
// next.config.js
module.exports = {
output: 'export',
images: { unoptimized: true },
}
// Option 3: Custom loader (Cloudinary, Imgix, etc.)
const cloudinaryLoader = ({ src, width, quality }) => {
return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`
}
<Image loader={cloudinaryLoader} src="sample.jpg" alt="Sample" width={800} height={400} />
```

View File

@@ -0,0 +1,301 @@
# Metadata
Add SEO metadata to Next.js pages using the Metadata API.
## Important: Server Components Only
The `metadata` object and `generateMetadata` function are **only supported in Server Components**. They cannot be used in Client Components.
If the target page has `'use client'`:
1. Remove `'use client'` if possible, move client logic to child components
2. Or extract metadata to a parent Server Component layout
3. Or split the file: Server Component with metadata imports Client Components
## Static Metadata
```tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Page Title',
description: 'Page description for search engines',
}
```
## Dynamic Metadata
```tsx
import type { Metadata } from 'next'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return { title: post.title, description: post.description }
}
```
## Avoid Duplicate Fetches
Use React `cache()` when the same data is needed for both metadata and page:
```tsx
import { cache } from 'react'
export const getPost = cache(async (slug: string) => {
return await db.posts.findFirst({ where: { slug } })
})
```
## Viewport
Separate from metadata for streaming support:
```tsx
import type { Viewport } from 'next'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#000000',
}
// Or dynamic
export function generateViewport({ params }): Viewport {
return { themeColor: getThemeColor(params) }
}
```
## Title Templates
In root layout for consistent naming:
```tsx
export const metadata: Metadata = {
title: { default: 'Site Name', template: '%s | Site Name' },
}
```
## Metadata File Conventions
Reference: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions
Place these files in `app/` directory (or route segments):
| File | Purpose |
|------|---------|
| `favicon.ico` | Favicon |
| `icon.png` / `icon.svg` | App icon |
| `apple-icon.png` | Apple app icon |
| `opengraph-image.png` | OG image |
| `twitter-image.png` | Twitter card image |
| `sitemap.ts` / `sitemap.xml` | Sitemap (use `generateSitemaps` for multiple) |
| `robots.ts` / `robots.txt` | Robots directives |
| `manifest.ts` / `manifest.json` | Web app manifest |
## SEO Best Practice: Static Files Are Often Enough
For most sites, **static metadata files provide excellent SEO coverage**:
```
app/
├── favicon.ico
├── opengraph-image.png # Works for both OG and Twitter
├── sitemap.ts
├── robots.ts
└── layout.tsx # With title/description metadata
```
**Tips:**
- A single `opengraph-image.png` covers both Open Graph and Twitter (Twitter falls back to OG)
- Static `title` and `description` in layout metadata is sufficient for most pages
- Only use dynamic `generateMetadata` when content varies per page
---
# OG Image Generation
Generate dynamic Open Graph images using `next/og`.
## Important Rules
1. **Use `next/og`** - not `@vercel/og` (it's built into Next.js)
2. **No searchParams** - OG images can't access search params, use route params instead
3. **Avoid Edge runtime** - Use default Node.js runtime
```tsx
// Good
import { ImageResponse } from 'next/og'
// Bad
// import { ImageResponse } from '@vercel/og'
// export const runtime = 'edge'
```
## Basic OG Image
```tsx
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'Site Name'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function Image() {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Hello World
</div>
),
{ ...size }
)
}
```
## Dynamic OG Image
```tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'Blog Post'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = { params: Promise<{ slug: string }> }
export default async function Image({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return new ImageResponse(
(
<div
style={{
fontSize: 48,
background: 'linear-gradient(to bottom, #1a1a1a, #333)',
color: 'white',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}
>
<div style={{ fontSize: 64, fontWeight: 'bold' }}>{post.title}</div>
<div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div>
</div>
),
{ ...size }
)
}
```
## Custom Fonts
```tsx
import { ImageResponse } from 'next/og'
import { join } from 'path'
import { readFile } from 'fs/promises'
export default async function Image() {
const fontPath = join(process.cwd(), 'assets/fonts/Inter-Bold.ttf')
const fontData = await readFile(fontPath)
return new ImageResponse(
(
<div style={{ fontFamily: 'Inter', fontSize: 64 }}>
Custom Font Text
</div>
),
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, style: 'normal' }],
}
)
}
```
## File Naming
- `opengraph-image.tsx` - Open Graph (Facebook, LinkedIn)
- `twitter-image.tsx` - Twitter/X cards (optional, falls back to OG)
## Styling Notes
ImageResponse uses Flexbox layout:
- Use `display: 'flex'`
- No CSS Grid support
- Styles must be inline objects
## Multiple OG Images
Use `generateImageMetadata` for multiple images per route:
```tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export async function generateImageMetadata({ params }) {
const images = await getPostImages(params.slug)
return images.map((img, idx) => ({
id: idx,
alt: img.alt,
size: { width: 1200, height: 630 },
contentType: 'image/png',
}))
}
export default async function Image({ params, id }) {
const images = await getPostImages(params.slug)
const image = images[id]
return new ImageResponse(/* ... */)
}
```
## Multiple Sitemaps
Use `generateSitemaps` for large sites:
```tsx
// app/sitemap.ts
import type { MetadataRoute } from 'next'
export async function generateSitemaps() {
// Return array of sitemap IDs
return [{ id: 0 }, { id: 1 }, { id: 2 }]
}
export default async function sitemap({
id,
}: {
id: number
}): Promise<MetadataRoute.Sitemap> {
const start = id * 50000
const end = start + 50000
const products = await getProducts(start, end)
return products.map((product) => ({
url: `https://example.com/product/${product.id}`,
lastModified: product.updatedAt,
}))
}
```
Generates `/sitemap/0.xml`, `/sitemap/1.xml`, etc.

View File

@@ -0,0 +1,287 @@
# Parallel & Intercepting Routes
Parallel routes render multiple pages in the same layout. Intercepting routes show a different UI when navigating from within your app vs direct URL access. Together they enable modal patterns.
## File Structure
```
app/
├── @modal/ # Parallel route slot
│ ├── default.tsx # Required! Returns null
│ ├── (.)photos/ # Intercepts /photos/*
│ │ └── [id]/
│ │ └── page.tsx # Modal content
│ └── [...]catchall/ # Optional: catch unmatched
│ └── page.tsx
├── photos/
│ └── [id]/
│ └── page.tsx # Full page (direct access)
├── layout.tsx # Renders both children and @modal
└── page.tsx
```
## Step 1: Root Layout with Slot
```tsx
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
);
}
```
## Step 2: Default File (Critical!)
**Every parallel route slot MUST have a `default.tsx`** to prevent 404s on hard navigation.
```tsx
// app/@modal/default.tsx
export default function Default() {
return null;
}
```
Without this file, refreshing any page will 404 because Next.js can't determine what to render in the `@modal` slot.
## Step 3: Intercepting Route (Modal)
The `(.)` prefix intercepts routes at the same level.
```tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal';
export default async function PhotoModal({
params
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
);
}
```
## Step 4: Full Page (Direct Access)
```tsx
// app/photos/[id]/page.tsx
export default async function PhotoPage({
params
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
const photo = await getPhoto(id);
return (
<div className="full-page">
<img src={photo.url} alt={photo.title} />
<h1>{photo.title}</h1>
</div>
);
}
```
## Step 5: Modal Component with Correct Closing
**Critical: Use `router.back()` to close modals, NOT `router.push()` or `<Link>`.**
```tsx
// components/modal.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef } from 'react';
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter();
const overlayRef = useRef<HTMLDivElement>(null);
// Close on escape key
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
router.back(); // Correct
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [router]);
// Close on overlay click
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
router.back(); // Correct
}
}, [router]);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<button
onClick={() => router.back()} // Correct!
className="absolute top-4 right-4"
>
Close
</button>
{children}
</div>
</div>
);
}
```
### Why NOT `router.push('/')` or `<Link href="/">`?
Using `push` or `Link` to "close" a modal:
1. Adds a new history entry (back button shows modal again)
2. Doesn't properly clear the intercepted route
3. Can cause the modal to flash or persist unexpectedly
`router.back()` correctly:
1. Removes the intercepted route from history
2. Returns to the previous page
3. Properly unmounts the modal
## Route Matcher Reference
Matchers match **route segments**, not filesystem paths:
| Matcher | Matches | Example |
|---------|---------|---------|
| `(.)` | Same level | `@modal/(.)photos` intercepts `/photos` |
| `(..)` | One level up | `@modal/(..)settings` from `/dashboard/@modal` intercepts `/settings` |
| `(..)(..)` | Two levels up | Rarely used |
| `(...)` | From root | `@modal/(...)photos` intercepts `/photos` from anywhere |
**Common mistake**: Thinking `(..)` means "parent folder" - it means "parent route segment".
## Handling Hard Navigation
When users directly visit `/photos/123` (bookmark, refresh, shared link):
- The intercepting route is bypassed
- The full `photos/[id]/page.tsx` renders
- Modal doesn't appear (expected behavior)
If you want the modal to appear on direct access too, you need additional logic:
```tsx
// app/photos/[id]/page.tsx
import { Modal } from '@/components/modal';
export default async function PhotoPage({ params }) {
const { id } = await params;
const photo = await getPhoto(id);
// Option: Render as modal on direct access too
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
);
}
```
## Common Gotchas
### 1. Missing `default.tsx` → 404 on Refresh
Every `@slot` folder needs a `default.tsx` that returns `null` (or appropriate content).
### 2. Modal Persists After Navigation
You're using `router.push()` instead of `router.back()`.
### 3. Nested Parallel Routes Need Defaults Too
If you have `@modal` inside a route group, each level needs its own `default.tsx`:
```
app/
├── (marketing)/
│ ├── @modal/
│ │ └── default.tsx # Needed!
│ └── layout.tsx
└── layout.tsx
```
### 4. Intercepted Route Shows Wrong Content
Check your matcher:
- `(.)photos` intercepts `/photos` from the same route level
- If your `@modal` is in `app/dashboard/@modal`, use `(.)photos` to intercept `/dashboard/photos`, not `/photos`
### 5. TypeScript Errors with `params`
In Next.js 15+, `params` is a Promise:
```tsx
// Correct
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
}
```
## Complete Example: Photo Gallery Modal
```
app/
├── @modal/
│ ├── default.tsx
│ └── (.)photos/
│ └── [id]/
│ └── page.tsx
├── photos/
│ ├── page.tsx # Gallery grid
│ └── [id]/
│ └── page.tsx # Full photo page
├── layout.tsx
└── page.tsx
```
Links in the gallery:
```tsx
// app/photos/page.tsx
import Link from 'next/link';
export default async function Gallery() {
const photos = await getPhotos();
return (
<div className="grid grid-cols-3 gap-4">
{photos.map(photo => (
<Link key={photo.id} href={`/photos/${photo.id}`}>
<img src={photo.thumbnail} alt={photo.title} />
</Link>
))}
</div>
);
}
```
Clicking a photo → Modal opens (intercepted)
Direct URL → Full page renders
Refresh while modal open → Full page renders

View File

@@ -0,0 +1,146 @@
# Route Handlers
Create API endpoints with `route.ts` files.
## Basic Usage
```tsx
// app/api/users/route.ts
export async function GET() {
const users = await getUsers()
return Response.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await createUser(body)
return Response.json(user, { status: 201 })
}
```
## Supported Methods
`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`
## GET Handler Conflicts with page.tsx
**A `route.ts` and `page.tsx` cannot coexist in the same folder.**
```
app/
├── api/
│ └── users/
│ └── route.ts # /api/users
└── users/
├── page.tsx # /users (page)
└── route.ts # Warning: Conflicts with page.tsx!
```
If you need both a page and an API at the same path, use different paths:
```
app/
├── users/
│ └── page.tsx # /users (page)
└── api/
└── users/
└── route.ts # /api/users (API)
```
## Environment Behavior
Route handlers run in a **Server Component-like environment**:
- Yes: Can use `async/await`
- Yes: Can access `cookies()`, `headers()`
- Yes: Can use Node.js APIs
- No: Cannot use React hooks
- No: Cannot use React DOM APIs
- No: Cannot use browser APIs
```tsx
// Bad: This won't work - no React DOM in route handlers
import { renderToString } from 'react-dom/server'
export async function GET() {
const html = renderToString(<Component />) // Error!
return new Response(html)
}
```
## Dynamic Route Handlers
```tsx
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const user = await getUser(id)
if (!user) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(user)
}
```
## Request Helpers
```tsx
export async function GET(request: Request) {
// URL and search params
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')
// Headers
const authHeader = request.headers.get('authorization')
// Cookies (Next.js helper)
const cookieStore = await cookies()
const token = cookieStore.get('token')
return Response.json({ query, token })
}
```
## Response Helpers
```tsx
// JSON response
return Response.json({ data })
// With status
return Response.json({ error: 'Not found' }, { status: 404 })
// With headers
return Response.json(data, {
headers: {
'Cache-Control': 'max-age=3600',
},
})
// Redirect
return Response.redirect(new URL('/login', request.url))
// Stream
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
```
## When to Use Route Handlers vs Server Actions
| Use Case | Route Handlers | Server Actions |
|----------|----------------|----------------|
| Form submissions | No | Yes |
| Data mutations from UI | No | Yes |
| Third-party webhooks | Yes | No |
| External API consumption | Yes | No |
| Public REST API | Yes | No |
| File uploads | Both work | Both work |
**Prefer Server Actions** for mutations triggered from your UI.
**Use Route Handlers** for external integrations and public APIs.

View File

@@ -0,0 +1,159 @@
# RSC Boundaries
Detect and prevent invalid patterns when crossing Server/Client component boundaries.
## Detection Rules
### 1. Async Client Components Are Invalid
Client components **cannot** be async functions. Only Server Components can be async.
**Detect:** File has `'use client'` AND component is `async function` or returns `Promise`
```tsx
// Bad: async client component
'use client'
export default async function UserProfile() {
const user = await getUser() // Cannot await in client component
return <div>{user.name}</div>
}
// Good: Remove async, fetch data in parent server component
// page.tsx (server component - no 'use client')
export default async function Page() {
const user = await getUser()
return <UserProfile user={user} />
}
// UserProfile.tsx (client component)
'use client'
export function UserProfile({ user }: { user: User }) {
return <div>{user.name}</div>
}
```
```tsx
// Bad: async arrow function client component
'use client'
const Dashboard = async () => {
const data = await fetchDashboard()
return <div>{data}</div>
}
// Good: Fetch in server component, pass data down
```
### 2. Non-Serializable Props to Client Components
Props passed from Server → Client must be JSON-serializable.
**Detect:** Server component passes these to a client component:
- Functions (except Server Actions with `'use server'`)
- `Date` objects
- `Map`, `Set`, `WeakMap`, `WeakSet`
- Class instances
- `Symbol` (unless globally registered)
- Circular references
```tsx
// Bad: Function prop
// page.tsx (server)
export default function Page() {
const handleClick = () => console.log('clicked')
return <ClientButton onClick={handleClick} />
}
// Good: Define function inside client component
// ClientButton.tsx
'use client'
export function ClientButton() {
const handleClick = () => console.log('clicked')
return <button onClick={handleClick}>Click</button>
}
```
```tsx
// Bad: Date object (silently becomes string, then crashes)
// page.tsx (server)
export default async function Page() {
const post = await getPost()
return <PostCard createdAt={post.createdAt} /> // Date object
}
// PostCard.tsx (client) - will crash on .getFullYear()
'use client'
export function PostCard({ createdAt }: { createdAt: Date }) {
return <span>{createdAt.getFullYear()}</span> // Runtime error!
}
// Good: Serialize to string on server
// page.tsx (server)
export default async function Page() {
const post = await getPost()
return <PostCard createdAt={post.createdAt.toISOString()} />
}
// PostCard.tsx (client)
'use client'
export function PostCard({ createdAt }: { createdAt: string }) {
const date = new Date(createdAt)
return <span>{date.getFullYear()}</span>
}
```
```tsx
// Bad: Class instance
const user = new UserModel(data)
<ClientProfile user={user} /> // Methods will be stripped
// Good: Pass plain object
const user = await getUser()
<ClientProfile user={{ id: user.id, name: user.name }} />
```
```tsx
// Bad: Map/Set
<ClientComponent items={new Map([['a', 1]])} />
// Good: Convert to array/object
<ClientComponent items={Object.fromEntries(map)} />
<ClientComponent items={Array.from(set)} />
```
### 3. Server Actions Are the Exception
Functions marked with `'use server'` CAN be passed to client components.
```tsx
// Valid: Server Action can be passed
// actions.ts
'use server'
export async function submitForm(formData: FormData) {
// server-side logic
}
// page.tsx (server)
import { submitForm } from './actions'
export default function Page() {
return <ClientForm onSubmit={submitForm} /> // OK!
}
// ClientForm.tsx (client)
'use client'
export function ClientForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) {
return <form action={onSubmit}>...</form>
}
```
## Quick Reference
| Pattern | Valid? | Fix |
|---------|--------|-----|
| `'use client'` + `async function` | No | Fetch in server parent, pass data |
| Pass `() => {}` to client | No | Define in client or use server action |
| Pass `new Date()` to client | No | Use `.toISOString()` |
| Pass `new Map()` to client | No | Convert to object/array |
| Pass class instance to client | No | Pass plain object |
| Pass server action to client | Yes | - |
| Pass `string/number/boolean` | Yes | - |
| Pass plain object/array | Yes | - |

View File

@@ -0,0 +1,39 @@
# Runtime Selection
## Use Node.js Runtime by Default
Use the default Node.js runtime for new routes and pages. Only use Edge runtime if the project already uses it or there's a specific requirement.
```tsx
// Good: Default - no runtime config needed (uses Node.js)
export default function Page() { ... }
// Caution: Only if already used in project or specifically required
export const runtime = 'edge'
```
## When to Use Each
### Node.js Runtime (Default)
- Full Node.js API support
- File system access (`fs`)
- Full `crypto` support
- Database connections
- Most npm packages work
### Edge Runtime
- Only for specific edge-location latency requirements
- Limited API (no `fs`, limited `crypto`)
- Smaller cold start
- Geographic distribution needs
## Detection
**Before adding `runtime = 'edge'`**, check:
1. Does the project already use Edge runtime?
2. Is there a specific latency requirement?
3. Are all dependencies Edge-compatible?
If unsure, use Node.js runtime.

View File

@@ -0,0 +1,141 @@
# Scripts
Loading third-party scripts in Next.js.
## Use next/script
Always use `next/script` instead of native `<script>` tags for better performance.
```tsx
// Bad: Native script tag
<script src="https://example.com/script.js"></script>
// Good: Next.js Script component
import Script from 'next/script'
<Script src="https://example.com/script.js" />
```
## Inline Scripts Need ID
Inline scripts require an `id` attribute for Next.js to track them.
```tsx
// Bad: Missing id
<Script dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
// Good: Has id
<Script id="my-script" dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
// Good: Inline with id
<Script id="show-banner">
{`document.getElementById('banner').classList.remove('hidden')`}
</Script>
```
## Don't Put Script in Head
`next/script` should not be placed inside `next/head`. It handles its own positioning.
```tsx
// Bad: Script inside Head
import Head from 'next/head'
import Script from 'next/script'
<Head>
<Script src="/analytics.js" />
</Head>
// Good: Script outside Head
<Head>
<title>Page</title>
</Head>
<Script src="/analytics.js" />
```
## Loading Strategies
```tsx
// afterInteractive (default) - Load after page is interactive
<Script src="/analytics.js" strategy="afterInteractive" />
// lazyOnload - Load during idle time
<Script src="/widget.js" strategy="lazyOnload" />
// beforeInteractive - Load before page is interactive (use sparingly)
// Only works in app/layout.tsx or pages/_document.js
<Script src="/critical.js" strategy="beforeInteractive" />
// worker - Load in web worker (experimental)
<Script src="/heavy.js" strategy="worker" />
```
## Google Analytics
Use `@next/third-parties` instead of inline GA scripts.
```tsx
// Bad: Inline GA script
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" />
<Script id="ga-init">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXX');`}
</Script>
// Good: Next.js component
import { GoogleAnalytics } from '@next/third-parties/google'
export default function Layout({ children }) {
return (
<html>
<body>{children}</body>
<GoogleAnalytics gaId="G-XXXXX" />
</html>
)
}
```
## Google Tag Manager
```tsx
import { GoogleTagManager } from '@next/third-parties/google'
export default function Layout({ children }) {
return (
<html>
<GoogleTagManager gtmId="GTM-XXXXX" />
<body>{children}</body>
</html>
)
}
```
## Other Third-Party Scripts
```tsx
// YouTube embed
import { YouTubeEmbed } from '@next/third-parties/google'
<YouTubeEmbed videoid="dQw4w9WgXcQ" />
// Google Maps
import { GoogleMapsEmbed } from '@next/third-parties/google'
<GoogleMapsEmbed
apiKey="YOUR_API_KEY"
mode="place"
q="Brooklyn+Bridge,New+York,NY"
/>
```
## Quick Reference
| Pattern | Issue | Fix |
|---------|-------|-----|
| `<script src="...">` | No optimization | Use `next/script` |
| `<Script>` without id | Can't track inline scripts | Add `id` attribute |
| `<Script>` inside `<Head>` | Wrong placement | Move outside Head |
| Inline GA/GTM scripts | No optimization | Use `@next/third-parties` |
| `strategy="beforeInteractive"` outside layout | Won't work | Only use in root layout |

View File

@@ -0,0 +1,371 @@
# Self-Hosting Next.js
Deploy Next.js outside of Vercel with confidence.
## Quick Start: Standalone Output
For Docker or any containerized deployment, use standalone output:
```js
// next.config.js
module.exports = {
output: 'standalone',
};
```
This creates a minimal `standalone` folder with only production dependencies:
```
.next/
├── standalone/
│ ├── server.js # Entry point
│ ├── node_modules/ # Only production deps
│ └── .next/ # Build output
└── static/ # Must be copied separately
```
## Docker Deployment
### Dockerfile
```dockerfile
FROM node:20-alpine AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
```
### Docker Compose
```yaml
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
```
## PM2 Deployment
For traditional server deployments:
```js
// ecosystem.config.js
module.exports = {
apps: [{
name: 'nextjs',
script: '.next/standalone/server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
}],
};
```
```bash
npm run build
pm2 start ecosystem.config.js
```
## ISR and Cache Handlers
### The Problem
ISR (Incremental Static Regeneration) uses filesystem caching by default. This **breaks with multiple instances**:
- Instance A regenerates page → saves to its local disk
- Instance B serves stale page → doesn't see Instance A's cache
- Load balancer sends users to random instances → inconsistent content
### Solution: Custom Cache Handler
Next.js 14+ supports custom cache handlers for shared storage:
```js
// next.config.js
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // Disable in-memory cache
};
```
#### Redis Cache Handler Example
```js
// cache-handler.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const CACHE_PREFIX = 'nextjs:';
module.exports = class CacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
const data = await redis.get(CACHE_PREFIX + key);
if (!data) return null;
const parsed = JSON.parse(data);
return {
value: parsed.value,
lastModified: parsed.lastModified,
};
}
async set(key, data, ctx) {
const cacheData = {
value: data,
lastModified: Date.now(),
};
// Set TTL based on revalidate option
if (ctx?.revalidate) {
await redis.setex(
CACHE_PREFIX + key,
ctx.revalidate,
JSON.stringify(cacheData)
);
} else {
await redis.set(CACHE_PREFIX + key, JSON.stringify(cacheData));
}
}
async revalidateTag(tags) {
// Implement tag-based invalidation
// This requires tracking which keys have which tags
}
};
```
#### S3 Cache Handler Example
```js
// cache-handler.js
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.CACHE_BUCKET;
module.exports = class CacheHandler {
async get(key) {
try {
const response = await s3.send(new GetObjectCommand({
Bucket: BUCKET,
Key: `cache/${key}`,
}));
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (err) {
if (err.name === 'NoSuchKey') return null;
throw err;
}
}
async set(key, data, ctx) {
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: `cache/${key}`,
Body: JSON.stringify({
value: data,
lastModified: Date.now(),
}),
ContentType: 'application/json',
}));
}
};
```
## What Works vs What Needs Setup
| Feature | Single Instance | Multi-Instance | Notes |
|---------|----------------|----------------|-------|
| SSR | Yes | Yes | No special setup |
| SSG | Yes | Yes | Built at deploy time |
| ISR | Yes | Needs cache handler | Filesystem cache breaks |
| Image Optimization | Yes | Yes | CPU-intensive, consider CDN |
| Middleware | Yes | Yes | Runs on Node.js |
| Edge Runtime | Limited | Limited | Some features Node-only |
| `revalidatePath/Tag` | Yes | Needs cache handler | Must share cache |
| `next/font` | Yes | Yes | Fonts bundled at build |
| Draft Mode | Yes | Yes | Cookie-based |
## Image Optimization
Next.js Image Optimization works out of the box but is CPU-intensive.
### Option 1: Built-in (Simple)
Works automatically, but consider:
- Set `deviceSizes` and `imageSizes` in config to limit variants
- Use `minimumCacheTTL` to reduce regeneration
```js
// next.config.js
module.exports = {
images: {
minimumCacheTTL: 60 * 60 * 24, // 24 hours
deviceSizes: [640, 750, 1080, 1920], // Limit sizes
},
};
```
### Option 2: External Loader (Recommended for Scale)
Offload to Cloudinary, Imgix, or similar:
```js
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/image-loader.js',
},
};
```
```js
// lib/image-loader.js
export default function cloudinaryLoader({ src, width, quality }) {
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`];
return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`;
}
```
## Environment Variables
### Build-time vs Runtime
```js
// Available at build time only (baked into bundle)
NEXT_PUBLIC_API_URL=https://api.example.com
// Available at runtime (server-side only)
DATABASE_URL=postgresql://...
API_SECRET=...
```
### Runtime Configuration
For truly dynamic config, don't use `NEXT_PUBLIC_*`. Instead:
```tsx
// app/api/config/route.ts
export async function GET() {
return Response.json({
apiUrl: process.env.API_URL,
features: process.env.FEATURES?.split(','),
});
}
```
## OpenNext: Serverless Without Vercel
[OpenNext](https://open-next.js.org/) adapts Next.js for AWS Lambda, Cloudflare Workers, etc.
```bash
npx create-sst@latest
# or
npx @opennextjs/aws build
```
Supports:
- AWS Lambda + CloudFront
- Cloudflare Workers
- Netlify Functions
- Deno Deploy
## Health Check Endpoint
Always include a health check for load balancers:
```tsx
// app/api/health/route.ts
export async function GET() {
try {
// Optional: check database connection
// await db.$queryRaw`SELECT 1`;
return Response.json({ status: 'healthy' }, { status: 200 });
} catch (error) {
return Response.json({ status: 'unhealthy' }, { status: 503 });
}
}
```
## Pre-Deployment Checklist
1. **Build locally first**: `npm run build` - catch errors before deploy
2. **Test standalone output**: `node .next/standalone/server.js`
3. **Set `output: 'standalone'`** for Docker
4. **Configure cache handler** for multi-instance ISR
5. **Set `HOSTNAME="0.0.0.0"`** for containers
6. **Copy `public/` and `.next/static/`** - not included in standalone
7. **Add health check endpoint**
8. **Test ISR revalidation** after deployment
9. **Monitor memory usage** - Node.js defaults may need tuning
## Testing Cache Handler
**Critical**: Test your cache handler on every Next.js upgrade:
```bash
# Start multiple instances
PORT=3001 node .next/standalone/server.js &
PORT=3002 node .next/standalone/server.js &
# Trigger ISR revalidation
curl http://localhost:3001/api/revalidate?path=/posts
# Verify both instances see the update
curl http://localhost:3001/posts
curl http://localhost:3002/posts
# Should return identical content
```

View File

@@ -0,0 +1,67 @@
# Suspense Boundaries
Client hooks that cause CSR bailout without Suspense boundaries.
## useSearchParams
Always requires Suspense boundary in static routes. Without it, the entire page becomes client-side rendered.
```tsx
// Bad: Entire page becomes CSR
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchBar() {
const searchParams = useSearchParams()
return <div>Query: {searchParams.get('q')}</div>
}
```
```tsx
// Good: Wrap in Suspense
import { Suspense } from 'react'
import SearchBar from './search-bar'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SearchBar />
</Suspense>
)
}
```
## usePathname
Requires Suspense boundary when route has dynamic parameters.
```tsx
// In dynamic route [slug]
// Bad: No Suspense
'use client'
import { usePathname } from 'next/navigation'
export function Breadcrumb() {
const pathname = usePathname()
return <nav>{pathname}</nav>
}
```
```tsx
// Good: Wrap in Suspense
<Suspense fallback={<BreadcrumbSkeleton />}>
<Breadcrumb />
</Suspense>
```
If you use `generateStaticParams`, Suspense is optional.
## Quick Reference
| Hook | Suspense Required |
|------|-------------------|
| `useSearchParams()` | Yes |
| `usePathname()` | Yes (dynamic routes) |
| `useParams()` | No |
| `useRouter()` | No |

View File

@@ -23,6 +23,7 @@ import { winstonConfig } from './modules/monitoring/logger/winston.config';
// Entities & Interceptors // Entities & Interceptors
import { AuditLog } from './common/entities/audit-log.entity'; import { AuditLog } from './common/entities/audit-log.entity';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor'; import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard'; import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
// Modules // Modules
@@ -176,6 +177,11 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
provide: APP_INTERCEPTOR, provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor, useClass: AuditLogInterceptor,
}, },
// 🔑 4. Register Global Interceptor (Idempotency) — ป้องกัน duplicate POST/PUT requests
{
provide: APP_INTERCEPTOR,
useClass: IdempotencyInterceptor,
},
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,5 +1,5 @@
// File: src/common/exceptions/http-exception.filter.ts // File: src/common/exceptions/http-exception.filter.ts
// บันทึกการแก้ไข: ปรับปรุง Global Filter ให้จัดการ Error ปลอดภัยสำหรับ Production และ Log ละเอียดใน Dev (T1.1) // Fix #3 & #4: แทน console.error ด้วย Logger, เพิ่ม ErrorResponseBody interface
import { import {
ExceptionFilter, ExceptionFilter,
@@ -11,6 +11,16 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
interface ErrorResponseBody {
statusCode: number;
timestamp: string;
path: string;
message?: unknown;
error?: string;
stack?: string;
[key: string]: unknown;
}
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name); private readonly logger = new Logger(HttpExceptionFilter.name);
@@ -33,49 +43,46 @@ export class HttpExceptionFilter implements ExceptionFilter {
: { message: 'Internal server error' }; : { message: 'Internal server error' };
// จัดรูปแบบ Error Message ให้เป็น Object เสมอ // จัดรูปแบบ Error Message ให้เป็น Object เสมอ
let errorBody: any = let errorBody: Record<string, unknown> =
typeof exceptionResponse === 'string' typeof exceptionResponse === 'string'
? { message: exceptionResponse } ? { message: exceptionResponse }
: exceptionResponse; : (exceptionResponse as Record<string, unknown>);
// 3. 📝 Logging Strategy (แยกตามความรุนแรง) // 3. 📝 Logging Strategy (แยกตามความรุนแรง)
if (status >= 500) { if (status >= 500) {
// 💥 Critical Error: Log stack trace เต็มๆ // 💥 Critical Error: Log stack trace เต็มๆ
this.logger.error( this.logger.error(
`💥 HTTP ${status} Error on ${request.method} ${request.url}`, `HTTP ${status} Error on ${request.method} ${request.url}`,
exception instanceof Error exception instanceof Error ? exception.stack : JSON.stringify(exception)
? exception.stack
: JSON.stringify(exception),
); );
// 👇👇 สิ่งที่คุณต้องการ: Log ดิบๆ ให้เห็นชัดใน Docker Console 👇👇
console.error('💥 REAL CRITICAL ERROR:', exception);
} else { } else {
// ⚠️ Client Error (400, 401, 403, 404): Log แค่ Warning พอ ไม่ต้อง Stack Trace // ⚠️ Client Error (400, 401, 403, 404): Log แค่ Warning พอ ไม่ต้อง Stack Trace
this.logger.warn( this.logger.warn(
`⚠️ HTTP ${status} Error on ${request.method} ${request.url}: ${JSON.stringify(errorBody.message || errorBody)}`, `HTTP ${status} Error on ${request.method} ${request.url}: ${JSON.stringify(errorBody['message'] ?? errorBody)}`
); );
} }
// 4. 🔒 Security & Response Formatting // 4. 🔒 Security & Response Formatting
// กรณี Production และเป็น Error 500 -> ต้องซ่อนรายละเอียดความผิดพลาดของ Server // กรณี Production และเป็น Error 500 -> ต้องซ่อนรายละเอียดความผิดพลาดของ Server
if (status === 500 && process.env.NODE_ENV === 'production') { if (status === 500 && process.env['NODE_ENV'] === 'production') {
errorBody = { errorBody = {
message: 'Internal server error', message: 'Internal server error',
// อาจเพิ่ม reference code เพื่อให้ user แจ้ง support ได้ เช่น code: 'ERR-500'
}; };
} }
// 5. Construct Final Response // 5. Construct Final Response (type-safe)
const responseBody = { const responseBody: ErrorResponseBody = {
statusCode: status, statusCode: status,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
path: request.url, path: request.url,
...errorBody, // Spread message, error, validation details ...errorBody,
}; };
// 🛠️ Development Mode: แถม Stack Trace ไปให้ Frontend Debug ง่ายขึ้น // 🛠️ Development Mode: แถม Stack Trace ไปให้ Frontend Debug ง่ายขึ้น
if (process.env.NODE_ENV !== 'production' && exception instanceof Error) { if (
process.env['NODE_ENV'] !== 'production' &&
exception instanceof Error
) {
responseBody.stack = exception.stack; responseBody.stack = exception.stack;
} }

View File

@@ -1,3 +1,7 @@
// File: src/common/interceptors/audit-log.interceptor.ts
// Fix #2: Replaced `as unknown as AuditLog` with CreateAuditLogPayload typed interface
// Lint fixes: async-in-tap pattern, null vs undefined entityId, safe any handling
import { import {
CallHandler, CallHandler,
ExecutionContext, ExecutionContext,
@@ -16,6 +20,17 @@ import { AuditLog } from '../entities/audit-log.entity';
import { AUDIT_KEY, AuditMetadata } from '../decorators/audit.decorator'; import { AUDIT_KEY, AuditMetadata } from '../decorators/audit.decorator';
import { User } from '../../modules/user/entities/user.entity'; import { User } from '../../modules/user/entities/user.entity';
/** Typed payload for creating AuditLog, replacing the `as unknown as AuditLog` cast */
interface CreateAuditLogPayload {
userId: number | null | undefined;
action: string;
entityType?: string;
entityId?: string;
ipAddress?: string;
userAgent?: string;
severity: string;
}
@Injectable() @Injectable()
export class AuditLogInterceptor implements NestInterceptor { export class AuditLogInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditLogInterceptor.name); private readonly logger = new Logger(AuditLogInterceptor.name);
@@ -23,13 +38,13 @@ export class AuditLogInterceptor implements NestInterceptor {
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
@InjectRepository(AuditLog) @InjectRepository(AuditLog)
private auditLogRepo: Repository<AuditLog>, private auditLogRepo: Repository<AuditLog>
) {} ) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const auditMetadata = this.reflector.getAllAndOverride<AuditMetadata>( const auditMetadata = this.reflector.getAllAndOverride<AuditMetadata>(
AUDIT_KEY, AUDIT_KEY,
[context.getHandler(), context.getClass()], [context.getHandler(), context.getClass()]
); );
if (!auditMetadata) { if (!auditMetadata) {
@@ -37,44 +52,70 @@ export class AuditLogInterceptor implements NestInterceptor {
} }
const request = context.switchToHttp().getRequest<Request>(); const request = context.switchToHttp().getRequest<Request>();
const user = (request as any).user as User; const user = (request as Request & { user?: User }).user;
const rawIp = request.ip || request.socket.remoteAddress; const rawIp: string | string[] | undefined =
const ip = Array.isArray(rawIp) ? rawIp[0] : rawIp; request.ip ?? request.socket.remoteAddress;
const ip: string | undefined = Array.isArray(rawIp) ? rawIp[0] : rawIp;
const userAgent = request.get('user-agent'); const userAgent = request.get('user-agent');
return next.handle().pipe( return next.handle().pipe(
tap(async (data) => { // Use void for fire-and-forget: tap() does not support async callbacks
tap((data: unknown) => {
void this.saveAuditLog(
data,
auditMetadata,
request,
user,
ip,
userAgent
);
})
);
}
/** Extracted async method to aoid "Promise returned in tap" lint warning */
private async saveAuditLog(
data: unknown,
auditMetadata: AuditMetadata,
request: Request,
user: User | undefined,
ip: string | undefined,
userAgent: string | undefined
): Promise<void> {
try { try {
let entityId = null; let entityId: string | undefined;
if (data && typeof data === 'object') { if (data !== null && typeof data === 'object') {
if ('id' in data) entityId = String(data.id); const dataRecord = data as Record<string, unknown>;
else if ('audit_id' in data) entityId = String(data.audit_id); if ('id' in dataRecord) {
else if ('user_id' in data) entityId = String(data.user_id); entityId = String(dataRecord['id']);
} else if ('audit_id' in dataRecord) {
entityId = String(dataRecord['audit_id']);
} else if ('user_id' in dataRecord) {
entityId = String(dataRecord['user_id']);
}
} }
if (!entityId && request.params.id) { if (!entityId && request.params['id']) {
entityId = String(request.params.id); entityId = String(request.params['id']);
} }
// ✅ FIX: ใช้ user?.user_id || null const payload: CreateAuditLogPayload = {
const auditLog = this.auditLogRepo.create({ userId: user?.user_id ?? null,
userId: user ? user.user_id : null,
action: auditMetadata.action, action: auditMetadata.action,
entityType: auditMetadata.entityType, entityType: auditMetadata.entityType,
entityId: entityId, entityId,
ipAddress: ip, ipAddress: ip,
userAgent: userAgent, userAgent,
severity: 'INFO', severity: 'INFO',
} as unknown as AuditLog); // ✨ Trick: Cast ผ่าน unknown เพื่อล้าง Error ถ้า TS ยังไม่อัปเดต };
const auditLog = this.auditLogRepo.create(payload as Partial<AuditLog>);
await this.auditLogRepo.save(auditLog); await this.auditLogRepo.save(auditLog);
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to create audit log for ${auditMetadata.action}: ${(error as Error).message}`, `Failed to create audit log for ${auditMetadata.action}: ${(error as Error).message}`
);
}
}),
); );
} }
} }
}

View File

@@ -1,3 +1,6 @@
// File: src/common/interceptors/transform.interceptor.ts
// Fix #1: แก้ไข `any` type ให้ถูกต้องตาม nestjs-best-practices (TypeScript Strict Mode)
import { import {
Injectable, Injectable,
NestInterceptor, NestInterceptor,
@@ -7,40 +10,67 @@ import {
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
export interface Response<T> { /** Metadata สำหรับ Paginated Response */
export interface ResponseMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
/** Standard API Response Wrapper */
export interface ApiResponse<T> {
statusCode: number; statusCode: number;
message: string; message: string;
data: T; data: T;
meta?: any; meta?: ResponseMeta;
}
/** Internal shape สำหรับ paginated data ที่ service ส่งมา */
interface PaginatedPayload<T> {
data: T[];
meta: ResponseMeta;
message?: string;
}
function isPaginatedPayload<T>(value: unknown): value is PaginatedPayload<T> {
return (
typeof value === 'object' &&
value !== null &&
'data' in value &&
'meta' in value &&
Array.isArray((value as PaginatedPayload<T>).data)
);
} }
@Injectable() @Injectable()
export class TransformInterceptor<T> export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>> implements NestInterceptor<T, ApiResponse<T>>
{ {
intercept( intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler next: CallHandler<T>
): Observable<Response<T>> { ): Observable<ApiResponse<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: T) => {
const response = context.switchToHttp().getResponse(); const response = context.switchToHttp().getResponse<{ statusCode: number }>();
// Handle Pagination Response (Standardize) // Handle Pagination Response (Standardize)
// ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา // ถ้า data มี structure { data: [], meta: {} } ให้ unzip ออกมา
if (data && data.data && data.meta) { if (isPaginatedPayload(data)) {
return { return {
statusCode: response.statusCode, statusCode: response.statusCode,
message: data.message || 'Success', message: data.message ?? 'Success',
data: data.data, data: data.data as unknown as T,
meta: data.meta, meta: data.meta,
}; };
} }
const dataAsRecord = data as Record<string, unknown>;
return { return {
statusCode: response.statusCode, statusCode: response.statusCode,
message: data?.message || 'Success', message: (dataAsRecord?.['message'] as string | undefined) ?? 'Success',
data: data?.result || data, data: (dataAsRecord?.['result'] as T | undefined) ?? data,
}; };
}) })
); );

View File

@@ -125,7 +125,11 @@ export class CorrespondenceService {
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity // [Fix #6] Fetch real ORG Code from Organization entity
const originatorOrg = await this.orgRepo.findOne({
where: { id: userOrgId },
});
const orgCode = originatorOrg?.organizationCode ?? 'UNK';
// [v1.5.1] Extract recipient organization from recipients array (Primary TO) // [v1.5.1] Extract recipient organization from recipients array (Primary TO)
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO'); const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
@@ -217,7 +221,8 @@ export class CorrespondenceService {
); );
} }
this.searchService.indexDocument({ // Fire-and-forget search indexing (non-blocking, void intentional)
void this.searchService.indexDocument({
id: savedCorr.id, id: savedCorr.id,
type: 'correspondence', type: 'correspondence',
docNumber: docNumber.number, docNumber: docNumber.number,
@@ -491,8 +496,13 @@ export class CorrespondenceService {
if (updateDto.recipients) { if (updateDto.recipients) {
// Safe check for 'type' or 'recipientType' (mismatch safeguard) // Safe check for 'type' or 'recipientType' (mismatch safeguard)
interface RecipientInput {
type?: string;
recipientType?: string;
organizationId?: number;
}
const newToRecipient = updateDto.recipients.find( const newToRecipient = updateDto.recipients.find(
(r: any) => r.type === 'TO' || r.recipientType === 'TO' (r: RecipientInput) => r.type === 'TO' || r.recipientType === 'TO'
); );
newRecipientId = newToRecipient?.organizationId; newRecipientId = newToRecipient?.organizationId;
@@ -521,7 +531,13 @@ export class CorrespondenceService {
if (recOrg) recipientCode = recOrg.organizationCode; if (recOrg) recipientCode = recOrg.organizationCode;
} }
const orgCode = 'ORG'; // Placeholder - should be fetched from Originator if needed in future // [Fix #6] Fetch real ORG Code from originator organization
const originatorOrgForUpdate = await this.orgRepo.findOne({
where: {
id: updateDto.originatorId ?? currentCorr.originatorId ?? 0,
},
});
const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK';
// Prepare Contexts // Prepare Contexts
const oldCtx = { const oldCtx = {

View File

@@ -1,17 +1,21 @@
"use client"; 'use client';
import { useState, useEffect } from "react"; import { useState, useEffect } from 'react';
import { TemplateEditor } from "@/components/numbering/template-editor"; import { useParams } from 'next/navigation';
import { SequenceViewer } from "@/components/numbering/sequence-viewer"; import { TemplateEditor } from '@/components/numbering/template-editor';
import { numberingApi } from "@/lib/api/numbering"; import { SequenceViewer } from '@/components/numbering/sequence-viewer';
import { NumberingTemplate } from "@/lib/api/numbering"; // Correct import import { numberingApi } from '@/lib/api/numbering';
import { useRouter } from "next/navigation"; import { NumberingTemplate } from '@/lib/api/numbering';
import { Loader2 } from "lucide-react"; import { useRouter } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Loader2 } from 'lucide-react';
import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useProjects } from "@/hooks/use-projects"; import { useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
import { useProjects } from '@/hooks/use-projects';
import { toast } from 'sonner';
export default function EditTemplatePage({ params }: { params: { id: string } }) { export default function EditTemplatePage() {
const params = useParams();
const id = params['id'] as string;
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [template, setTemplate] = useState<NumberingTemplate | null>(null); const [template, setTemplate] = useState<NumberingTemplate | null>(null);
@@ -24,33 +28,36 @@ export default function EditTemplatePage({ params }: { params: { id: string } })
const contractId = contracts[0]?.id; const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId); const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3'; const selectedProjectName =
projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName ||
'LCBP3';
useEffect(() => { useEffect(() => {
const fetchTemplate = async () => { const fetchTemplate = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await numberingApi.getTemplate(parseInt(params.id)); const data = await numberingApi.getTemplate(parseInt(id));
if (data) { if (data) {
setTemplate(data); setTemplate(data);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch template", error); toast.error('Failed to load template');
console.error('[EditTemplatePage] fetchTemplate:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchTemplate(); fetchTemplate();
}, [params.id]); }, [id]);
const handleSave = async (data: Partial<NumberingTemplate>) => { const handleSave = async (data: Partial<NumberingTemplate>) => {
try { try {
await numberingApi.saveTemplate({ ...data, id: parseInt(params.id) }); await numberingApi.saveTemplate({ ...data, id: parseInt(id) });
router.push("/admin/numbering"); router.push('/admin/doc-control/numbering');
} catch (error) { } catch (error) {
console.error("Failed to update template", error); toast.error('Failed to update template');
alert("Failed to update template"); console.error('[EditTemplatePage] handleSave:', error);
} }
}; };

View File

@@ -5,6 +5,7 @@ import { numberingApi, NumberingTemplate } from "@/lib/api/numbering";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data"; import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import { toast } from 'sonner';
export default function NewTemplatePage() { export default function NewTemplatePage() {
const router = useRouter(); const router = useRouter();
@@ -17,20 +18,21 @@ export default function NewTemplatePage() {
const contractId = contracts[0]?.id; const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId); const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3'; const selectedProjectName =
projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3';
const handleSave = async (data: Partial<NumberingTemplate>) => { const handleSave = async (data: Partial<NumberingTemplate>) => {
try { try {
await numberingApi.saveTemplate(data); await numberingApi.saveTemplate(data);
router.push("/admin/numbering"); router.push("/admin/numbering");
} catch (error) { } catch (error) {
console.error("Failed to create template", error); toast.error('Failed to create template');
alert("Failed to create template"); console.error('[NewTemplatePage]', error);
} }
}; };
const handleCancel = () => { const handleCancel = () => {
router.push("/admin/numbering"); router.push('/admin/doc-control/numbering');
}; };
return ( return (

View File

@@ -14,6 +14,7 @@ import { workflowApi } from '@/lib/api/workflows';
import { WorkflowType } from '@/types/workflow'; import { WorkflowType } from '@/types/workflow';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function NewWorkflowPage() { export default function NewWorkflowPage() {
const router = useRouter(); const router = useRouter();
@@ -31,8 +32,8 @@ export default function NewWorkflowPage() {
await workflowApi.createWorkflow(workflowData); await workflowApi.createWorkflow(workflowData);
router.push('/admin/doc-control/workflows'); router.push('/admin/doc-control/workflows');
} catch (error) { } catch (error) {
console.error('Failed to create workflow', error); toast.error('Failed to create workflow');
alert('Failed to create workflow'); console.error('[NewWorkflowPage]', error);
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@@ -8,6 +8,7 @@ import { Plus, Edit, Copy, Trash, Loader2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { Workflow } from '@/types/workflow'; import { Workflow } from '@/types/workflow';
import { workflowApi } from '@/lib/api/workflows'; import { workflowApi } from '@/lib/api/workflows';
import { toast } from 'sonner';
export default function WorkflowsPage() { export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState<Workflow[]>([]); const [workflows, setWorkflows] = useState<Workflow[]>([]);
@@ -20,7 +21,8 @@ export default function WorkflowsPage() {
const data = await workflowApi.getWorkflows(); const data = await workflowApi.getWorkflows();
setWorkflows(data); setWorkflows(data);
} catch (error) { } catch (error) {
console.error('Failed to fetch workflows', error); toast.error('Failed to load workflows');
console.error('[WorkflowsPage]', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[Admin Error Boundary]', error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
<AlertTriangle className="h-10 w-10 text-amber-500" />
<div>
<h2 className="text-lg font-semibold">Admin Panel Error</h2>
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An error occurred in the admin panel.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
</div>
<div className="flex gap-3">
<Button onClick={reset} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Try again
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/admin">Back to Admin</Link>
</Button>
</div>
</div>
);
}

View File

@@ -12,9 +12,10 @@ import { format } from "date-fns";
export default async function DrawingDetailPage({ export default async function DrawingDetailPage({
params, params,
}: { }: {
params: { id: string }; params: Promise<{ id: string }>;
}) { }) {
const id = parseInt(params.id); const { id: rawId } = await params;
const id = parseInt(rawId);
if (isNaN(id)) { if (isNaN(id)) {
notFound(); notFound();
} }

View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { AlertCircle, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[Dashboard Error Boundary]', error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<div>
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An error occurred while loading this page.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
</div>
<div className="flex gap-3">
<Button onClick={reset} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Try again
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">Back to Dashboard</Link>
</Button>
</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import apiClient from "@/lib/api/client"; import apiClient from "@/lib/api/client";
import { toast } from "sonner";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Schemas // Schemas
@@ -64,11 +65,11 @@ export default function ProfilePage() {
newPassword: data.newPassword, newPassword: data.newPassword,
}); });
alert("เปลี่ยนรหัสผ่านสำเร็จ"); // ในอนาคตใช้ Toast toast.success('เปลี่ยนรหัสผ่านสำเร็จ');
reset(); reset();
} catch (error) { } catch (error) {
console.error(error); toast.error('ไม่สามารถเปลี่ยนรหัสผ่านได้: รหัสผ่านปัจจุบันไม่ถูกต้อง');
alert("ไม่สามารถเปลี่ยนรหัสผ่านได้: รหัสผ่านปัจจุบันไม่ถูกต้อง"); console.error('[ProfilePage] onPasswordSubmit:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -273,7 +274,7 @@ export default function ProfilePage() {
</div> </div>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button variant="outline" onClick={() => alert("บันทึกการตั้งค่าแจ้งเตือนแล้ว")}> <Button variant="outline" onClick={() => toast.success('บันทึกการตั้งค่าแจ้งเตือนแล้ว')}>
</Button> </Button>
</CardFooter> </CardFooter>

View File

@@ -27,7 +27,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import apiClient from "@/lib/api/client"; import { toast } from "sonner";
// 1. กำหนด Schema สำหรับตรวจสอบข้อมูล (Validation) // 1. กำหนด Schema สำหรับตรวจสอบข้อมูล (Validation)
// อ้างอิงจาก Data Dictionary ตาราง projects // อ้างอิงจาก Data Dictionary ตาราง projects
@@ -58,7 +58,6 @@ export default function CreateProjectPage() {
register, register,
handleSubmit, handleSubmit,
setValue, // ใช้สำหรับ manual set value (เช่น Select) setValue, // ใช้สำหรับ manual set value (เช่น Select)
watch, // ใช้ดูค่า (สำหรับ Debug หรือ Conditional Logic)
formState: { errors }, formState: { errors },
} = useForm<ProjectValues>({ } = useForm<ProjectValues>({
resolver: zodResolver(projectSchema), resolver: zodResolver(projectSchema),
@@ -82,12 +81,12 @@ export default function CreateProjectPage() {
// await apiClient.post("/projects", data); // await apiClient.post("/projects", data);
alert("สร้างโครงการสำเร็จ"); // TODO: เปลี่ยนเป็น Toast toast.success('สร้างโครงการสำเร็จ');
router.push("/projects"); router.push('/projects');
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
console.error("Failed to create project:", error); toast.error('เกิดข้อผิดพลาดในการสร้างโครงการ');
alert("เกิดข้อผิดพลาดในการสร้างโครงการ"); console.error('[CreateProjectPage]', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -202,7 +201,7 @@ export default function CreateProjectPage() {
เราต้องใช้ onValueChange เพื่อเชื่อมกับ React Hook Form เราต้องใช้ onValueChange เพื่อเชื่อมกับ React Hook Form
*/} */}
<Select <Select
onValueChange={(value: any) => setValue("status", value)} onValueChange={(value: 'Active' | 'Inactive' | 'On Hold') => setValue('status', value)}
defaultValue="Active" defaultValue="Active"
> >
<SelectTrigger> <SelectTrigger>

35
frontend/app/error.tsx Normal file
View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[App Error Boundary]', error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-4">
<AlertCircle className="h-12 w-12 text-destructive" />
<div>
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground mt-1 text-sm max-w-md">
{error.message || 'An unexpected error occurred. Please try again.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mt-2">Error ID: {error.digest}</p>
)}
</div>
<Button onClick={reset} variant="outline">
Try again
</Button>
</div>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { useEffect } from 'react';
// global-error.tsx catches errors in the root layout.tsx itself.
// It MUST include its own <html> and <body> tags per Next.js spec.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[Global Error Boundary]', error);
}, [error]);
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: '16px',
textAlign: 'center',
padding: '16px',
}}
>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Application Error
</h2>
<p style={{ color: '#6b7280', fontSize: '0.875rem', maxWidth: '400px' }}>
{error.message || 'A critical error occurred. Please refresh the page.'}
</p>
{error.digest && (
<p style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
Error ID: {error.digest}
</p>
)}
<button
onClick={reset}
style={{
padding: '8px 16px',
border: '1px solid #d1d5db',
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: 'white',
}}
>
Try again
</button>
</div>
</body>
</html>
);
}

View File

@@ -19,6 +19,8 @@ const nextConfig = {
], ],
// ลดขนาดไฟล์รูปภาพที่จะถูก optimize // ลดขนาดไฟล์รูปภาพที่จะถูก optimize
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// Cache optimized images for 24h (reduces re-optimization on containers)
minimumCacheTTL: 86400,
}, },
// 4. (Optional) Rewrites: กรณีต้องการ Proxy API ผ่าน Next.js เพื่อเลี่ยง CORS ใน Dev // 4. (Optional) Rewrites: กรณีต้องการ Proxy API ผ่าน Next.js เพื่อเลี่ยง CORS ใน Dev

15
skills-lock.json Normal file
View File

@@ -0,0 +1,15 @@
{
"version": 1,
"skills": {
"nestjs-best-practices": {
"source": "Kadajett/agent-nestjs-skills",
"sourceType": "github",
"computedHash": "68658903272e1015f3ec5084c7d0988216a47f42f675ed819a675250fc6a23cd"
},
"next-best-practices": {
"source": "vercel-labs/next-skills",
"sourceType": "github",
"computedHash": "c31ddd68ba5798a79a516c619b1e1fc4408cc4876ad600fefa4f5add4425719d"
}
}
}

View File

@@ -3,10 +3,10 @@
--- ---
title: 'Objectives' title: 'Objectives'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: - related: -
--- ---

View File

@@ -0,0 +1,313 @@
# 4. Access Control & RBAC Matrix (V1.8.0)
---
title: 'Access Control & RBAC Matrix'
version: 1.8.0
status: APPROVED
owner: Nattanin Peancharoen / Development Team
last_updated: 2026-02-23
related:
- specs/02-architecture/02-01-system-architecture.md
- specs/03-implementation/03-02-backend-guidelines.md
- specs/07-database/07-01-data-dictionary-v1.8.0.md
- specs/05-decisions/ADR-005-redis-usage-strategy.md
- specs/05-decisions/ADR-001-unified-workflow-engine.md
references:
- [RBAC Implementation](../../99-archives/ADR-004-rbac-implementation.md)
- [Access Control](../../99-archives/01-04-access-control.md)
---
## 4.1. Overview and Problem Statement
LCBP3-DMS จัดการสิทธิ์การเข้าถึงข้อมูลที่ซับซ้อน ได้แก่:
- **Multi-Organization**: หลายองค์กรใช้ระบบร่วมกัน แต่ต้องแยกข้อมูลตามบริบท
- **Project-Based**: โครงการสามารถมีหลายสัญญาย่อย (Contracts)
- **Hierarchical Permissions**: สิทธิ์ระดับบนสามารถครอบคลุมระดับล่าง
- **Dynamic Roles**: สิทธิ์สามารถปรับเปลี่ยน Role หรือเพิ่ม Role พิเศษได้โดยไม่ต้อง Deploy ระบบใหม่.
Users และ Organizations สามารถเข้าดูหรือแก้ไขเอกสารได้จากสิทธิ์ที่ได้รับ ระบบออกแบบด้วย **4-Level Hierarchical Role-Based Access Control (RBAC)** ដើម្បីรองรับความซับซ้อนนี้.
### Key Requirements
1. User หนึ่งคนสามารถมีหลาย Roles ในหลาย Scopes
2. Permission Inheritance (Global → Organization → Project → Contract)
3. Fine-grained Access Control (เช่น "อนุญาตให้ดู Correspondence เฉพาะใน Project A รวมถึง Contract ภายใต้ Project A เท่านั้น")
4. Performance (Check permission ผ่าน Redis ต้องเร็ว < 10ms)
---
## 4.2. Permission Hierarchy & Enforcement
### 4-Level Scope Hierarchy
การออกแบบสิทธิ์ครอบคลุม Scope ลำดับขั้นดังนี้:
```text
Global (ทั้งระบบ)
├─ Organization (ระดับองค์กร)
│ ├─ Project (ระดับโครงการ)
│ │ └─ Contract (ระดับสัญญา)
│ │
│ └─ Project B
│ └─ Contract B
└─ Organization 2
└─ Project C
```
**Permission Enforcement:**
- เมื่อตรวจสอบสิทธิ์ ระบบจะพิจารณาสิทธิ์จากทุก Level ที่ผู้ใช้มี และใช้สิทธิ์ที่ **"ครอบคลุมที่สุด (Most Permissive)"** ในบริบท (Context) นั้นมาเป็นเกณฑ์.
- *Example*: User A เป็น `Viewer` ในองค์กร (ระดับ Organization Level) แต่มอบหมายหน้าที่ให้เป็น `Editor` ในตำแหน่งของ Project X. เมื่อ User A ดำเนินการในบริบท Context ของ Project X (หรือ Contract ที่อยู่ใต้ Project X), User A จะสามารถทำงานด้วยสิทธิ์ `Editor` ทันที.
---
## 4.3. Role and Scope Summary
| Role | Scope | Description | Key Permissions |
| :------------------- | :----------- | :------------------------- | :-------------------------------------------------------------------------------------------------------------------- |
| **Superadmin** | Global | System administrator | Do everything in the system, manage organizations, manage global data |
| **Org Admin** | Organization | Organization administrator | Manage users in the organization, manage roles/permissions within the organization, view organization reports |
| **Document Control** | Organization | Document controller | Add/edit/delete documents, set document permissions within the organization |
| **Editor** | Organization | Document editor | Edit documents that have been assigned to them |
| **Viewer** | Organization | Document viewer | View documents that have access permissions |
| **Project Manager** | Project | Project manager | Manage members in the project (add/delete/assign roles), create/manage contracts in the project, view project reports |
| **Contract Admin** | Contract | Contract administrator | Manage users in the contract, manage roles/permissions within the contract, view contract reports |
### Master Data Management Authority
| Master Data | Manager | Scope |
| :-------------------------------------- | :------------------------------ | :------------------------------ |
| Document Type (Correspondence, RFA) | **Superadmin** | Global |
| Document Status (Draft, Approved, etc.) | **Superadmin** | Global |
| Shop Drawing Category | **Project Manager** | Project (สร้างใหม่ได้ภายในโครงการ) |
| Tags | **Org Admin / Project Manager** | Organization / Project |
| Custom Roles | **Superadmin / Org Admin** | Global / Organization |
| Document Numbering Formats | **Superadmin / Admin** | Global / Organization |
---
## 4.4. Onboarding Workflow
- **4.4.1. Create Organization**
- **Superadmin** สร้าง Organization ใหม่ (e.g. Company A)
- **Superadmin** แต่งตั้ง User อย่างน้อย 1 คน ให้เป็น **Org Admin** หรือ **Document Control**
- **4.4.2. Add Users to Organization**
- **Org Admin** เพิ่ม users (`Editor`, `Viewer`) เข้าสู่งองค์กร
- **4.4.3. Assign Users to Project**
- **Project Manager** เชิญ/กำหนดผู้ใช้เข้าสู่ Project. ในขั้นตอนนี้จะกำหนด **Project Role**
- **4.4.4. Assign Users to Contract**
- **Contract Admin** เลือก users จาก Project และมอบหมายหน้าที่เจาะจงใน Contract ขั้นตอนนี้จะกำหนด **Contract Role**
- **4.4.5. Security Onboarding**
- บังคับ Users to change password เป็นครั้งแรก
- ฝึกอบรม (Security awareness training) สำหรับ users ที่มีสิทธิ์สูงระดับแอดมิน.
- บันทึก Audit Log ทุกเหตุการณ์เกี่ยวกับการมอบหมาย/ตั้งค่า Permissions.
---
## 4.5. Implementation Details
### 4.5.1 Database Schema (RBAC Tables)
```sql
-- Role Definitions with Scope
CREATE TABLE roles (
role_id INT PRIMARY KEY AUTO_INCREMENT,
role_name VARCHAR(100) NOT NULL,
scope ENUM('Global', 'Organization', 'Project', 'Contract') NOT NULL,
description TEXT,
is_system BOOLEAN DEFAULT FALSE
);
-- Granular Permissions
CREATE TABLE permissions (
permission_id INT PRIMARY KEY AUTO_INCREMENT,
permission_name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
module VARCHAR(50),
-- Scope Reference ENUM includes CONTRACT
scope_level ENUM('GLOBAL', 'ORG', 'PROJECT', 'CONTRACT')
);
-- Role -> Permissions Mapping
CREATE TABLE role_permissions (
role_id INT,
permission_id INT,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(permission_id) ON DELETE CASCADE
);
-- User Role Assignments with Context Map
CREATE TABLE user_assignments (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
role_id INT NOT NULL,
organization_id INT NULL,
project_id INT NULL,
contract_id INT NULL,
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
-- ควบคุมโครงสร้าง: ไม่ให้ระบุ Scope เลื่อนลอยข้ามขั้นต้องมีชั้นเดียวชัดเจน
CONSTRAINT chk_scope CHECK (
(organization_id IS NOT NULL AND project_id IS NULL AND contract_id IS NULL) OR
(organization_id IS NULL AND project_id IS NOT NULL AND contract_id IS NULL) OR
(organization_id IS NULL AND project_id IS NULL AND contract_id IS NOT NULL) OR
(organization_id IS NULL AND project_id IS NULL AND contract_id IS NULL)
)
);
```
### 4.5.2 Setup CASL Ability Rules
เพื่อให้ **"การสืบทอดสิทธิ์ (Inheritance Logic)"** ทำงานได้ถูกต้อง เช่น บทบาทระดับ `Project Manager` ให้แผ่คลุมไปถึงทรัพยากรระดับ `Contract` ด้านในด้วย (Logic `project_id -> all embedded contracts`).
```typescript
// ability.factory.ts
import { AbilityBuilder, PureAbility } from '@casl/ability';
export type AppAbility = PureAbility<[string, any]>;
@Injectable()
export class AbilityFactory {
async createForUser(user: User): Promise<AppAbility> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility);
const assignments = await this.getUserAssignments(user.user_id);
for (const assignment of assignments) {
const role = await this.getRole(assignment.role_id);
const permissions = await this.getRolePermissions(role.role_id);
for (const permission of permissions) {
// e.g. 'correspondence.create', 'project.view'
const [subject, action] = permission.permission_name.split('.');
// Apply Scope conditions based on the Role's specified scope level
switch (role.scope) {
case 'Global':
// ได้รับสิทธิ์กับองค์ประกอบทั้งหมด
can(action, subject);
break;
case 'Organization':
// อนุญาตการ Action ทั้งในองค์กร และโปรเจกต์ภายใต้องค์กร
can(action, subject, { organization_id: assignment.organization_id });
// Advanced Case: In some queries mapped to Projects/Contracts, support fallback checks
// can(action, subject, { '__orgIdFallback': assignment.organization_id });
break;
case 'Project':
// สืบทอดสิทธิ์ไปยัง Project ID นั้นๆ เสมอ และสิทธิ์ครอบคลุมถึงทุก Contracts ที่ผูกกับ Project นี้
can(action, subject, { project_id: assignment.project_id });
break;
case 'Contract':
// จำกัดเฉพาะใน Contract นั้น ตรงเป้าหมาย
can(action, subject, { contract_id: assignment.contract_id });
break;
}
}
}
return build();
}
}
```
### 4.5.3 Token Management & Redis Permission Cache
- **Payload Optimization**: JWT Access Token ให้เก็บเฉพาะ `userId` และ ข้อมูล Sessions ขั้นต้น. โครงสร้าง Permissions List ปริมาณมากจะ**ไม่อยู่ใน Token**.
- **Permission Caching**: นำโครงสร้างสิทธิ์ทั้งหมด (Ability Rules ที่ประกอบแล้วจาก 4.5.2) ไป Cache ไว้เป็น Key ภายใต้ฐานข้อมูล **Redis** โดยคงระยะการตั้ง TTL ประมาณ 30 นาที `1800` วินาที. ลดขนาด Payload ลง และเพื่อเป้าหมาย Performance ที่ `< 10ms`.
### 4.5.4 Permission Guard Enforcement (NestJS)
```typescript
// permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
private redis: Redis
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.get<string>('permission', context.getHandler());
if (!permission) return true; // Route without specific permission requirements
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check Redis cache first
const cacheKey = `user:${user.user_id}:permissions`;
let abilityRules = await this.redis.get(cacheKey);
if (!abilityRules) {
const newAbility = await this.abilityFactory.createForUser(user);
abilityRules = newAbility.rules;
// Store in Cache (1800ms TTL)
await this.redis.set(cacheKey, JSON.stringify(abilityRules), 'EX', 1800);
} else {
abilityRules = JSON.parse(abilityRules);
}
const ability = new PureAbility(abilityRules);
const [action, subject] = permission.split('.');
// Evaluate mapped payload / resource parameters directly into CASL engine
const resource = { ...request.params, ...request.body };
return ability.can(action, subject, resource);
}
}
```
Controller Example Usage:
```typescript
@Controller('correspondences')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class CorrespondenceController {
@Post()
@RequirePermission('correspondence.create')
async create(@Body() dto: CreateCorrespondenceDto) {
// Accessible only when CASL Evaluate "correspondence.create" successfully fits the parameters limits (Project/Contract scope check)
}
}
```
---
## 4.6. Cache Invalidation Strategy
เพื่อให้ระบบ Security Update ได้รวดเร็วและปลอดภัย ต้องมีกลไกสั่งรีเซ็ตสิทธิ์ (Invalidation):
1. **When Role Assignemnt Modified**: เมื่อช้อมูลในตาราง `user_assignments` (เช่นลบ/เพิ่ม user ระดับโปรเจกต์) หรือตาราง `role_permissions` (สลับสิทธิ์ของกลุ่ม) เกิดการแก้ไขในฐานข้อมูลแล้ว.
2. **Execute Invalidation**: API Services ต้องทำการลบ Cache เก่าที่ฝั่งขัดข้องทันที ผ่านคำสั่ง `DEL user:{user_id}:permissions` ณ ฝั่ง Redis.
3. ระบบจะทำงานโปรโตซ้ำ (Cold Boot) เข้ามาหยิบ DB -> สร้าง CASL Factory ใหม่ ใน First API Request อีกครั้งในทันที.
---
## 4.7. Appendix: ADR-004 Decision History & Justification
ส่วนอ้างอิงและประวัติศาสตร์การตัดสินใจพิจารณาในอดีต (Reference) ก่อนการนำมาปรับใช้ร่าง Architecture ฉบับ V1.8.0.
### Considered Options Before Version 1.5.0
1. **Option 1**: Simple Role-Based (No Scope)
- *Pros*: Very simple implementation, Easy to understand
- *Cons*: ไม่รองรับ Multi-organization, Superadmin เห็นข้อมูลองค์กรอื่นมั่วข้ามกลุ่ม
2. **Option 2**: Organization-Only Scope
- *Pros*: แยกข้อมูลระหว่าง Organizations ได้ชัดเจน
- *Cons*: ไม่รองรับระดับ Sub-level Permissions (Project/Contract) ไม่เพียงพอกับ Business Rule.
3. **Option 3**: **4-Level Hierarchical RBAC (Selected)**
- *Pros*: ครอบคลุม Use Case สูงสุด (Maximum Flexibility), รองรับ Hierarchy สืบทอดสิทธิ์ (Inheritance), ขอบเขตจำกัดข้อมูลอย่างรัดกุมระดับ Contract (Data Isolation).
- *Cons*: Complexity ทางด้านการ Implement, Invalidate Caching หางานให้ฝั่ง Operations, Developer Learning Curve.
**Decision Rationale:**
เลือกแนวทางที่ 3 รองรับความต้องการของ Construction Projects ที่มีการแบ่งกลุ่มรับเหมาช่วงย่อยระดับ Contracts ต่อเนื่อง (Future proof). แก้ไขปัญหา Performance Cons ด้วยแนวคิดการประจุ Redis Caching ข้ามขั้ว และล้าง Invalidation เฉพาะเมื่อ Triggering Database Updates (อ้างอิงหัวข้อ 4.6).

View File

@@ -0,0 +1,786 @@
# 3.11 Document Numbering Management & Implementation (V1.8.0)
---
title: 'Specifications & Implementation Guide: Document Numbering System'
version: 1.8.0
status: draft
owner: Nattanin Peancharoen / Development Team
last_updated: 2026-02-23
related:
- specs/01-requirements/01-01-objectives.md
- specs/02-architecture/README.md
- specs/03-implementation/03-02-backend-guidelines.md
- specs/04-operations/04-08-document-numbering-operations.md
- specs/07-database/07-01-data-dictionary-v1.8.0.md
- specs/05-decisions/ADR-002-document-numbering-strategy.md
Clean Version v1.8.0 Scope of Changes:
- รวม Functional Requirements เข้ากับ Implementation Guide
- เลือกใช้ Single Numbering System (Option A) `document_number_counters` เป็น Authoritative Counter
- เพิ่ม Idempotency Key, Reservation (Two-Phase Commit)
- Number State Machine, Pattern Validate UTF-8, Cancellation Rule (Void/Replace)
references:
- [Document Numbering](../../99-archives/01-03.11-document-numbering.md)
- [Document Numbering](../../99-archives/03-04-document-numbering.md)
---
> **📖 เอกสารฉบับนี้เป็นการรวมรายละเอียดจาก `01-03.11-document-numbering.md` และ `03-04-document-numbering.md` ให้อยู่ในฉบับเดียวสำหรับใช้อ้างอิงการออกแบบเชิง Functional และการพัฒนา Technology Component**
---
## 1. Overview & วัตถุประสงค์ (Purpose)
ระบบ Document Numbering สำหรับสร้างเลขที่เอกสารอัตโนมัติที่มีความเป็นเอกลักษณ์ (unique) และสามารถติดตามได้ (traceable) สำหรับเอกสารทุกประเภทในระบบ LCBP3-DMS
### 1.1 Requirements Summary & Scope
- **Auto-generation**: สร้างเลขที่อัตโนมัติ ไม่ซ้ำ (Unique) ยืดหยุ่น
- **Configurable Templates**: รองรับแบบฟอร์มกำหนดค่า สำหรับโปรเจกต์ ประเภทเอกสาร ฯลฯ
- **Uniqueness Guarantee**: การันตี Uniqueness ใน Concurrent Environment (Race Conditions)
- **Manual override**: รองรับการ Import หรือ Override สำหรับเอกสารเก่า
- **Cancelled/Void Handling**: ไม่นำหมายเลขที่ใช้ หรือ Cancel/Void กลับมาใช้ใหม่ (No reuse)
- **Audit Logging**: บันทึกเหตุการณ์ Operation ทั้งหมดอย่างละเอียดครบถ้วน 7 ปี
### 1.2 Technology Stack
| Component | Technology |
| ----------------- | -------------------- |
| Backend Framework | NestJS 10.x |
| ORM | TypeORM 0.3.x |
| Database | MariaDB 11.8 |
| Cache/Lock | Redis 7.x + Redlock |
| Message Queue | BullMQ |
| Monitoring | Prometheus + Grafana |
### 1.3 Architectural Decision (AD-DN-001)
ระบบเลือกใช้ **Option A**:
- `document_number_counters` เป็น Core / Authoritative Counter System.
- `document_numbering_configs` (หรือ `document_number_formats`) ใช้เฉพาะกำหนดระเบียบเลข (Template format) และ Permission.
- เหตุผล: ลดความซ้ำซ้อน, ป้องกัน Counter Mismatch, Debug ง่าย, Ops เคลียร์.
---
## 2. Business Rules & Logic
### 2.1 Counter Logic & Reset Policy
การนับเลขจะแยกตาม **Counter Key** ที่ประกอบด้วยหลายส่วน ซึ่งขึ้นกับประเภทเอกสาร
* `(project_id, originator_organization_id, recipient_organization_id, correspondence_type_id, sub_type_id, rfa_type_id, discipline_id, reset_scope)`
| Document Type | Reset Policy | Counter Key Format / Details |
| ---------------------------------- | ------------------ | ------------------------------------------------------------------------------ |
| Correspondence (LETTER, MEMO, RFI) | Yearly reset | `(project_id, originator, recipient, type_id, 0, 0, 0, 'YEAR_2025')` |
| Transmittal | Yearly reset | `(project_id, originator, recipient, type_id, sub_type_id, 0, 0, 'YEAR_2025')` |
| RFA | No reset | `(project_id, originator, 0, type_id, 0, rfa_type_id, discipline_id, 'NONE')` |
| Drawing | Separate Namespace | `DRAWING::<project>::<contract>` (ไม่ได้ใช้ counter rules เดียวกัน) |
### 2.2 Format Templates & Supported Tokens
**Supported Token Types**:
* `{PROJECT}`: รหัสโครงการ (เช่น `LCBP3`)
* `{ORIGINATOR}`: รหัสองค์กรส่ง (เช่น `คคง.`)
* `{RECIPIENT}`: รหัสองค์กรรับหลัก (เช่น `สคฉ.3`) *ไม่ใช้กับ RFA
* `{CORR_TYPE}`: รหัสประเภทเอกสาร (เช่น `RFA`, `LETTER`)
* `{SUB_TYPE}`: ประเภทย่อย (สำหรับ Transmittal)
* `{RFA_TYPE}`: รหัสประเภท RFA (เช่น `SDW`, `RPT`)
* `{DISCIPLINE}`: รหัสสาขาวิชา (เช่น `STR`, `CV`)
* `{SEQ:n}`: Running Number ตามจำนวนหลัก `n` ลบด้วยศูนย์นำหน้า
* `{YEAR:B.E.}`, `{YEAR:A.D.}`, `{YYYY}`, `{YY}`, `{MM}`: สัญลักษณ์บอกเวลาและปฏิทิน.
* `{REV}`: Revision Code (เช่น `A`, `B`)
**Token Validation Grammar**
```ebnf
TEMPLATE := TOKEN ("-" TOKEN)*
TOKEN := SIMPLE | PARAM
SIMPLE := "{PROJECT}" | "{ORIGINATOR}" | "{RECIPIENT}" | "{CORR_TYPE}" | "{DISCIPLINE}" | "{RFA_TYPE}" | "{REV}" | "{YYYY}" | "{YY}" | "{MM}"
PARAM := "{SEQ:" DIGIT+ "}"
DIGIT := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
```
### 2.3 Character & Format Rules (BR-DN-002, BR-DN-003)
- Document number **must be printable UTF8** (Thai, English, Numbers, `-`, `_`, `.`). ไม่อนุญาต Control characters, newlines.
- ต้องยาวระหว่าง 10 ถึง 50 ตัวอักษร
- ต้องกำหนด Token `{SEQ:n}` ลำดับที่ exactly once. ห้ามเป็น Unknown token ใดๆ.
### 2.4 Number State Machine & Idempotency
1. **States Lifecycle**: `RESERVED` (TTL 5 mins) → `CONFIRMED``VOID` / `CANCELLED`. Document ที่ Confirmed แล้วสามารถมีพฤติกรรม VOID ในอนาคตเพื่อแทนที่ด้วยเอกสารใหม่ได้ การ Request จะได้ Document ชุดใหม่ทดแทนต่อเนื่องทันที. ห้าม Reuse เลขเดิมโดยสิ้นเชิง.
2. **Idempotency Key Support**: ทุก API ในการ Generator จำเป็นต้องระบุ HTTP Header `Idempotency-Key` ป้องกันระบบสร้างเลขเบิ้ล (Double Submission). ถ้าระบบได้รับคู่ Request + Key ชุดเดิม จะ Response เลขที่เอกสารเดิม.
---
## 3. Functional Requirements
* **FR-DN-001 (Sequential Auto-generation)**: ระบบตอบกลับความรวดเร็วที่ระดับ < 100ms โดยที่ทน Concurrent ได้ ทนต่อปัญหา Duplicate
* **FR-DN-002 (Configurable)**: สามารถเปลี่ยนรูปแบบเทมเพลตผ่านระบบ Admin ได้ด้วยการ Validation ก่อน حفظ
* **FR-DN-003 (Scope-based sequences)**: รองรับ Scope แยกระดับเอกสาร
* **FR-DN-004 (Manual Override)**: ระบบรองรับการตั้งเลขด้วยตนเองสำหรับ Admin Level พร้อมระบุเหตุผลผ่าน Audit Trails เป็นหลักฐานสำคัญ (Import Legacy, Correction)
* **FR-DN-005 (Bulk Import)**: รับเข้าระบบจากไฟล์ Excel/CSV และแก้ไข Counters Sequence ต่อเนื่องพร้อมเช็ค Duplicates.
* **FR-DN-006 (Skip Cancelled)**: ไม่ให้สิทธิ์ดึงเอกสารยกเลิกใช้งานซ้ำ. คงรักษาสภาพ Audit ต่อไป.
* **FR-DN-007 (Void & Replace)**: เปลี่ยน Status เลขเป็น VOID ไม่มีการ Delete. Reference Link เอกสารใหม่ที่เกิดขึ้นทดแทนอิงตาม `voided_from_id`.
* **FR-DN-008 (Race Condition Prevention)**: จัดการ Race Condition (RedLock + Database Pessimistic Locking) เพื่อ Guarantee zero duplicate numbers ที่ Load 1000 req/s.
* **FR-DN-009 (Two-phase Commit)**: แบ่งการออกเลขเป็นช่วง Reserve 5 นาที แล้วค่อยเรียก Confirm หลังจากได้เอกสารที่ Submit เรียบร้อย (ลดอาการเลขหาย/เลขฟันหลอที่ยังไม่ถูกใช้).
* **FR-DN-010/011 (Audit / Metrics Alerting)**: Audit ทุกๆ Step / Transaction ไว้ใน DB ให้เสิร์ชได้เกิน 7 ปี. ส่งแจ้งเตือนถ้า Sequence เริ่มเต็ม (เกิน 90%) หรือ Rate error เริ่มสูง.
---
## 4. Module System & Code Architecture
### 4.1 Folder Structure
```
backend/src/modules/document-numbering/
├── document-numbering.module.ts
├── controllers/ # document-numbering.controller.ts, admin.controller.ts, metrics.controller.ts
├── services/ # Main orchestration (document-numbering.service.ts), lock, counter, reserve, format, audit
├── entities/ # DB Entities mappings
├── dto/ # DTOs
├── validators/ # template.validator.ts
├── guards/ # manual-override.guard.ts
└── jobs/ # counter-reset.job.ts (Cron)
```
### 4.2 Sequence Process Architecture
**1. Number Generation Process Diagram**
```mermaid
sequenceDiagram
participant C as Client
participant S as NumberingService
participant L as LockService
participant CS as CounterService
participant DB as Database
participant R as Redis
C->>S: generateDocumentNumber(dto)
S->>L: acquireLock(counterKey)
L->>R: REDLOCK acquire
R-->>L: lock acquired
L-->>S: lock handle
S->>CS: incrementCounter(counterKey)
CS->>DB: BEGIN TRANSACTION
CS->>DB: SELECT FOR UPDATE
CS->>DB: UPDATE last_number
CS->>DB: COMMIT
DB-->>CS: newNumber
CS-->>S: sequence
S->>S: formatNumber(template, seq)
S->>L: releaseLock()
L->>R: REDLOCK release
S-->>C: documentNumber
```
**2. Two-Phase Commit Pattern (Reserve / Confirm)**
```mermaid
sequenceDiagram
participant C as Client
participant RS as ReservationService
participant SS as SequenceService
participant R as Redis
Note over C,R: Phase 1: Reserve
C->>RS: reserve(documentType)
RS->>SS: getNextSequence()
SS-->>RS: documentNumber
RS->>R: SETEX reservation:{token} (TTL: 5min)
RS-->>C: {token, documentNumber, expiresAt}
Note over C,R: Phase 2: Confirm
C->>RS: confirm(token)
RS->>R: GET reservation:{token}
R-->>RS: reservationData
RS->>R: DEL reservation:{token}
RS-->>C: documentNumber (confirmed)
```
### 4.3 Lock Service (Redis Redlock Example)
```typescript
@Injectable()
export class DocumentNumberingLockService {
constructor(@InjectRedis() private readonly redis: Redis) {
this.redlock = new Redlock([redis], { driftFactor: 0.01, retryCount: 5, retryDelay: 100, retryJitter: 50 });
}
async acquireLock(counterKey: CounterKey): Promise<Redlock.Lock> {
const lockKey = this.buildLockKey(counterKey);
return await this.redlock.acquire([lockKey], /* ttl */ 5000); // 5 sec retention
}
}
```
### 4.4 Counter Service & Transaction Strategy (Optimistic Example)
```typescript
async incrementCounter(counterKey: CounterKey): Promise<number> {
const MAX_RETRIES = 2;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await this.dataSource.transaction(async (manager) => {
const counter = await manager.findOne(DocumentNumberCounter, { /* rules */ });
if (!counter) {
// Insert base 1
return 1;
}
counter.lastNumber += 1;
await manager.save(counter); // Trigger Optimistic Version Check
return counter.lastNumber;
});
} catch (error) {
// Loop if version mismatch
}
}
}
```
---
## 5. Database Schema Details
### 5.1 Format Storage & Counters
```sql
-- Format Template Configuration
CREATE TABLE document_number_formats (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
correspondence_type_id INT NULL,
format_template VARCHAR(100) NOT NULL,
reset_sequence_yearly TINYINT(1) DEFAULT 1,
UNIQUE KEY idx_unique_project_type (project_id, correspondence_type_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- Active Sequences
CREATE TABLE document_number_counters (
project_id INT NOT NULL,
correspondence_type_id INT NULL,
originator_organization_id INT NOT NULL,
recipient_organization_id INT NOT NULL DEFAULT 0,
sub_type_id INT DEFAULT 0,
rfa_type_id INT DEFAULT 0,
discipline_id INT DEFAULT 0,
reset_scope VARCHAR(20) NOT NULL,
last_number INT DEFAULT 0 NOT NULL,
version INT DEFAULT 0 NOT NULL,
PRIMARY KEY (... 8 fields combination ...),
INDEX idx_counter_lookup (project_id, correspondence_type_id, reset_scope)
);
```
### 5.2 Two-Phase Commit Reservations
```sql
CREATE TABLE document_number_reservations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(36) NOT NULL UNIQUE COMMENT 'UUID v4',
document_number VARCHAR(100) NOT NULL UNIQUE,
status ENUM('RESERVED', 'CONFIRMED', 'CANCELLED', 'VOID') NOT NULL DEFAULT 'RESERVED',
document_id INT NULL COMMENT 'Link after confirmed',
expires_at DATETIME(6) NOT NULL,
... Context fields ...
);
```
### 5.3 Audit Trails & Error Logs
```sql
CREATE TABLE document_number_audit (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
document_number VARCHAR(100) NOT NULL,
operation ENUM('RESERVE', 'CONFIRM', 'CANCEL', 'MANUAL_OVERRIDE', 'VOID', 'GENERATE') NOT NULL,
counter_key JSON NOT NULL,
is_success BOOLEAN DEFAULT TRUE,
lock_wait_ms INT,
... Extraneous Auditing fields ...
);
CREATE TABLE document_number_errors (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
error_type ENUM('LOCK_TIMEOUT','VERSION_CONFLICT','DB_ERROR','REDIS_ERROR','VALIDATION_ERROR','SEQUENCE_EXHAUSTED') NOT NULL,
stack_trace TEXT,
context_data JSON
);
```
---
## 6. Endpoints & API Definitions
| Endpoint | Method | Permission | Meaning |
| -------------------------------------------- | -------- | ------------------------ | ------------------------------------------ |
| `/document-numbering/preview` | POST | `correspondence.read` | Preview Formats |
| `/document-numbering/reserve` | POST | `correspondence.create` | Reserve Token & Logic Number (2PC) |
| `/document-numbering/confirm` | POST | `correspondence.create` | Confirm Reservation (2PC) |
| `/document-numbering/cancel` | POST | `correspondence.create` | Manual or System Cancel Reservation |
| `/admin/document-numbering/manual-override` | POST | `system.manage_settings` | Inject / Legacy specific number generation |
| `/admin/document-numbering/void-and-replace` | POST | `system.manage_settings` | Replace document marking old logic as VOID |
| `/admin/document-numbering/bulk-import` | POST | `system.manage_settings` | Batch Migration Numbers from legacy |
| `/admin/document-numbering/templates` | GET/POST | `system.manage_settings` | Setting Pattern Configurations |
---
## 7. Security, Error Handling & Concurrency Checklists
**Fallback Strategy for Database Lock Failures**:
1. System attempt to acquire `Redlock`.
2. Redis Down? → **Fallback DB-only Lock** Transaction Layer.
3. Redis Online but Timeout `>3 Times`? → Return HTTP 503 (Exponential Backoff).
4. Save Failed via TypeORM Version Confilct? → Retry Loop `2 Times`, otherwise Return 409 Conflict.
**Rate Limiting Profiles**:
* Single User Threshold: `10 requests/minute`.
* Specific Request IP: `50 requests/minute`.
**Authorization Policies**:
* `Super Admin` เท่านั้นที่บังคับสั่ง `Reset Counter` ให้เริ่มนับศูนย์ได้เมื่อฉุกเฉิน.
* กฎ Audit Log System ระบุชัดเจนว่าต้อง Retain Information ไม่ต่ำกว่า 7 ปี.
## 8. Monitoring / Observability (Prometheus + Grafana)
| Condition Event | Prometheus Counter Target | Severity Reaction |
| ---------------------- | -------------------------------- | ----------------------------------------------------------------- |
| Utilization `>95%` | `numbering_sequence_utilization` | 🔴 **CRITICAL** (PagerDuty/Slack). Limit Maximum sequence reached. |
| Redis Downtime `>1M` | Health System | 🔴 **CRITICAL** (PagerDuty/Slack) |
| Lock Latency p95 `>1s` | `numbering_lock_wait_seconds` | 🟡 **WARNING** (Slack). Redis connection struggling. |
| Error Rate Burst | `numbering_lock_failures_total` | 🟡 **WARNING** (Slack). Need investigation logs check |
---
## 9. Testing & Rollout Migration Strategies
### 9.1 Test Approach Requirements
* **Unit Tests**: Template Tokens Validations, Error handling retry, Optimistic locking checks.
* **Concurrency Integration Test**: Assert >1000 requests without double generating numbers simultaneously per `project_id`.
* **E2E Load Sequence Flow**: Mocking bulk API loads over 500 requests per seconds via CI/CD load pipeline.
### 9.2 Data Rollout Plan (Legacy Legacy Import)
1. Dump out existing Sequence numbers (Extracted Document Strings).
2. Write validation script for Sequence Max Counts.
3. Import to Table `document_number_counters` as Manual Override API Method context (`FR-DN-004`).
4. Re-Verify next sequence logic output `+1` count seamlessly integrates to `nextNumber()`.
---
**Best Practices Checklist**
-**DO**: Two-Phase Commit (`Reserve` + `Confirm`) ให้เป็น Routine System Concept.
-**DO**: DB Fallback เมื่อ Redis ดาวน์. ให้ Availability สูงสุด ไม่หยุดทำงาน.
-**DO**: ข้ามเลขที่ยกเลิกทั้งหมดห้ามมีการ Re-Use เด็ดขาด ไม่ว่าเจตนาใดๆ.
-**DON'T**: ไม่แก้ Sequence จาก DB Console ตรงๆ โดยเด็ดขาด.
-**DON'T**: ลืม Validate format หรือ Tokens แปลกๆ ในระบบ Template (ต้อง Check Grammar ตลอดไป).
-**DON'T**: ลืมเขียน Idempotency-Key สำหรับ Request.
---
**Document Version**: 1.8.0
**Created By**: Development Team
**End of Document**
---
## 10. Operations & Infrastructure Guidelines
### 1. Performance Requirements
### 1.1. Response Time Targets
| Metric | Target | Measurement |
| ---------------- | -------- | ------------------------ |
| 95th percentile | ≤ 2 วินาที | ตั้งแต่ request ถึง response |
| 99th percentile | ≤ 5 วินาที | ตั้งแต่ request ถึง response |
| Normal operation | ≤ 500ms | ไม่มี retry |
### 1.2. Throughput Targets
| Load Level | Target | Notes |
| -------------- | ----------- | ------------------------ |
| Normal load | ≥ 50 req/s | ใช้งานปกติ |
| Peak load | ≥ 100 req/s | ช่วงเร่งงาน |
| Burst capacity | ≥ 200 req/s | Short duration (< 1 min) |
### 1.3. Availability SLA
- **Uptime**: ≥ 99.5% (excluding planned maintenance)
- **Maximum downtime**: ≤ 3.6 ชั่วโมง/เดือน (~ 8.6 นาที/วัน)
- **Recovery Time Objective (RTO)**: ≤ 30 นาที
- **Recovery Point Objective (RPO)**: ≤ 5 นาที
### 2. Infrastructure Setup
### 2.1. Database Configuration
#### MariaDB Connection Pool
```typescript
// ormconfig.ts
{
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
extra: {
connectionLimit: 20, // Pool size
queueLimit: 0, // Unlimited queue
acquireTimeout: 10000, // 10s timeout
retryAttempts: 3,
retryDelay: 1000
}
}
```
#### High Availability Setup
```yaml
# docker-compose.yml
services:
mariadb-master:
image: mariadb:11.8
environment:
MYSQL_REPLICATION_MODE: master
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mariadb-master-data:/var/lib/mysql
networks:
- backend
mariadb-replica:
image: mariadb:11.8
environment:
MYSQL_REPLICATION_MODE: slave
MYSQL_MASTER_HOST: mariadb-master
MYSQL_MASTER_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mariadb-replica-data:/var/lib/mysql
networks:
- backend
```
### 2.2. Redis Configuration
#### Redis Sentinel for High Availability
```yaml
# docker-compose.yml
services:
redis-master:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis-master-data:/data
networks:
- backend
redis-replica:
image: redis:7-alpine
command: redis-server --replicaof redis-master 6379 --appendonly yes
volumes:
- redis-replica-data:/data
networks:
- backend
redis-sentinel:
image: redis:7-alpine
command: >
redis-sentinel /etc/redis/sentinel.conf
--sentinel monitor mymaster redis-master 6379 2
--sentinel down-after-milliseconds mymaster 5000
--sentinel failover-timeout mymaster 10000
networks:
- backend
```
#### Redis Connection Pool
```typescript
// redis.config.ts
import IORedis from 'ioredis';
export const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
poolSize: 10,
retryStrategy: (times: number) => {
if (times > 3) {
return null; // Stop retry
}
return Math.min(times * 100, 3000);
},
};
```
### 2.3. Load Balancing
#### Nginx Configuration
```nginx
# nginx.conf
upstream backend {
least_conn; # Least connections algorithm
server backend-1:3000 max_fails=3 fail_timeout=30s weight=1;
server backend-2:3000 max_fails=3 fail_timeout=30s weight=1;
server backend-3:3000 max_fails=3 fail_timeout=30s weight=1;
keepalive 32;
}
server {
listen 80;
server_name api.lcbp3.local;
location /api/v1/document-numbering/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_next_upstream error timeout;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}
```
#### Docker Compose Scaling
```yaml
# docker-compose.yml
services:
backend:
image: lcbp3-backend:latest
deploy:
replicas: 3
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
environment:
NODE_ENV: production
DB_POOL_SIZE: 20
networks:
- backend
```
### 4. Troubleshooting Runbooks
### 4.1. Scenario: Redis Unavailable
**Symptoms:**
- Alert: `RedisUnavailable`
- System falls back to DB-only locking
- Performance degraded 30-50%
**Action Steps:**
1. **Check Redis status:**
```bash
docker exec lcbp3-redis redis-cli ping
# Expected: PONG
```
2. **Check Redis logs:**
```bash
docker logs lcbp3-redis --tail=100
```
3. **Restart Redis (if needed):**
```bash
docker restart lcbp3-redis
```
4. **Verify failover (if using Sentinel):**
```bash
docker exec lcbp3-redis-sentinel redis-cli -p 26379 SENTINEL masters
```
5. **Monitor recovery:**
- Check metric: `docnum_redis_connection_status` returns to 1
- Check performance: P95 latency returns to normal (< 500ms)
### 4.2. Scenario: High Lock Failure Rate
**Symptoms:**
- Alert: `HighLockFailureRate` (> 10%)
- Users report "ระบบกำลังยุ่ง" errors
**Action Steps:**
1. **Check concurrent load:**
```bash
# Check current request rate
curl http://prometheus:9090/api/v1/query?query=rate(docnum_generation_duration_ms_count[1m])
```
2. **Check database connections:**
```sql
SHOW PROCESSLIST;
-- Look for waiting/locked queries
```
3. **Check Redis memory:**
```bash
docker exec lcbp3-redis redis-cli INFO memory
```
4. **Scale up if needed:**
```bash
# Increase backend replicas
docker-compose up -d --scale backend=5
```
5. **Check for deadlocks:**
```sql
SHOW ENGINE INNODB STATUS;
-- Look for LATEST DETECTED DEADLOCK section
```
### 4.3. Scenario: Slow Performance
**Symptoms:**
- Alert: `SlowDocumentNumberGeneration`
- P95 > 2 seconds
**Action Steps:**
1. **Check database query performance:**
```sql
SELECT * FROM document_number_counters USE INDEX (idx_counter_lookup)
WHERE project_id = 2 AND correspondence_type_id = 6 AND current_year = 2025;
-- Check execution plan
EXPLAIN SELECT ...;
```
2. **Check for missing indexes:**
```sql
SHOW INDEX FROM document_number_counters;
```
3. **Check Redis latency:**
```bash
docker exec lcbp3-redis redis-cli --latency
```
4. **Check network latency:**
```bash
ping mariadb-master
ping redis-master
```
5. **Review slow query log:**
```bash
docker exec lcbp3-mariadb-master cat /var/log/mysql/slow.log
```
### 4.4. Scenario: Version Conflicts
**Symptoms:**
- High retry count
- Users report "เลขที่เอกสารถูกเปลี่ยน" errors
**Action Steps:**
1. **Check concurrent requests to same counter:**
```sql
SELECT
project_id,
correspondence_type_id,
COUNT(*) as concurrent_requests
FROM document_number_audit
WHERE created_at > NOW() - INTERVAL 5 MINUTE
GROUP BY project_id, correspondence_type_id
HAVING COUNT(*) > 10
ORDER BY concurrent_requests DESC;
```
2. **Investigate specific counter:**
```sql
SELECT * FROM document_number_counters
WHERE project_id = X AND correspondence_type_id = Y;
-- Check audit trail
SELECT * FROM document_number_audit
WHERE counter_key LIKE '%project_id:X%'
ORDER BY created_at DESC
LIMIT 20;
```
3. **Check for application bugs:**
- Review error logs for stack traces
- Check if retry logic is working correctly
4. **Temporary mitigation:**
- Increase retry count in application config
- Consider manual counter adjustment (last resort)
### 5. Maintenance Procedures
### 5.1. Counter Reset (Manual)
**Requires:** SUPER_ADMIN role + 2-person approval
**Steps:**
1. **Request approval via API:**
```bash
POST /api/v1/document-numbering/configs/{configId}/reset-counter
{
"reason": "เหตุผลที่ชัดเจน อย่างน้อย 20 ตัวอักษร",
"approver_1": "user_id",
"approver_2": "user_id"
}
```
2. **Verify in audit log:**
```sql
SELECT * FROM document_number_config_history
WHERE config_id = X
ORDER BY changed_at DESC
LIMIT 1;
```
### 5.2. Template Update
**Best Practices:**
1. Always test template in staging first
2. Preview generated numbers before applying
3. Document reason for change
4. Template changes do NOT affect existing documents
**API Call:**
```bash
PUT /api/v1/document-numbering/configs/{configId}
{
"template": "{ORIGINATOR}-{RECIPIENT}-{SEQ:4}-{YEAR:B.E.}",
"change_reason": "เหตุผลในการเปลี่ยนแปลง"
}
```
### 5.3. Database Maintenance
**Weekly Tasks:**
- Check slow query log
- Optimize tables if needed:
```sql
OPTIMIZE TABLE document_number_counters;
OPTIMIZE TABLE document_number_audit;
```
**Monthly Tasks:**
- Review and archive old audit logs (> 2 years)
- Check index usage:
```sql
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'lcbp3_db';
```

View File

@@ -3,10 +3,10 @@
--- ---
title: 'UI/UX Requirements' title: 'UI/UX Requirements'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/02-architecture/data-model.md#correspondence - specs/02-architecture/data-model.md#correspondence

View File

@@ -3,10 +3,10 @@
--- ---
title: 'Non-Functional Requirements' title: 'Non-Functional Requirements'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/02-architecture/data-model.md#correspondence - specs/02-architecture/data-model.md#correspondence

View File

@@ -3,10 +3,10 @@
--- ---
title: 'Testing Requirements' title: 'Testing Requirements'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/02-architecture/data-model.md#correspondence - specs/02-architecture/data-model.md#correspondence

View File

@@ -1,75 +0,0 @@
# 📦Section 3: ข้อกำหนดด้านฟังก์ชันการทำงาน (Functional Requirements)
---
title: "Functional Requirements: Correspondence Management"
version: 1.5.0
status: first-draft
owner: Nattanin Peancharoen
last_updated: 2025-11-30
related:
- specs/01-requirements/01-objectives.md
- specs/01-requirements/02-architecture.md
- specs/01-requirements/03.1-project-management.md
- specs/01-requirements/03.2-correspondence.md
- specs/01-requirements/03.3-rfa.md
- specs/01-requirements/03.4-contract-drawing.md
- specs/01-requirements/03.5-shop-drawing.md
- specs/01-requirements/03.6-unified-workflow.md
- specs/01-requirements/03.7-transmittals.md
- specs/01-requirements/03.8-circulation-sheet.md
- specs/01-requirements/03.9-logs.md
- specs/01-requirements/03.10-file-handling.md
- specs/01-requirements/03.11-document-numbering.md
- specs/01-requirements/03.12-json-details.md
---
## 3.1 การจัดการโครงสร้างโครงการและองค์กร (Project Management)
[specs/01-requirements/03.1-project-management.md](01-03.1-project-management.md)
## 3.2 การจัดการเอกสารโครงการ (Correspondence)
[specs/01-requirements/03.2-correspondence.md](01-03.2-correspondence.md)
## 3.3 การจัดการเอกสารโครงการ (RFA)
[specs/01-requirements/03.3-rfa.md](01-03.3-rfa.md)
## 3.4 การจัดการแบบคู่สัญญา (Contract Drawing)
[specs/01-requirements/03.4-contract-drawing.md](01-03.4-contract-drawing.md)
## 3.5 การจัดกาแบบก่อสร้าง (Shop Drawing)
[specs/01-requirements/03.5-shop-drawing.md](01-03.5-shop-drawing.md)
## 3.6 การจัดการ Workflow (Unified Workflow)
[specs/01-requirements/03.6-unified-workflow.md](01-03.6-unified-workflow.md)
## 3.7 การจัดการเอกสารนำส่ง (Transmittals)
[specs/01-requirements/03.7-transmittals.md](01-03.7-transmittals.md)
## 3.8 การจัดการใบเวียนเอกสาร (Circulation Sheet)
[specs/01-requirements/03.8-circulation-sheet.md](01-03.8-circulation-sheet.md)
## 3.9 ประวัติการแก้ไข (logs)
[specs/01-requirements/03.9-logs.md](01-03.9-logs.md)
## 3.10 การจัดเก็บไฟล์ (File Handling)
[specs/01-requirements/03.10-file-handling.md](01-03.10-file-handling.md)
## 3.11 การจัดการเลขที่เอกสาร (Document Numbering)
[specs/01-requirements/03.11-document-numbering.md](01-03.11-document-numbering.md)
## 3.12 การจัดการ JSON Details (JSON & Performance - ปรับปรุง)
[specs/01-requirements/03.12-json-details.md](01-03.12-json-details.md)

View File

@@ -0,0 +1,43 @@
# 📦 01.2 ข้อกำหนดด้านฟังก์ชันการทำงานระดับโมดูล (Functional Requirements: Modules)
---
title: "Functional Requirements: Modules Index"
version: 1.8.0
status: active
owner: Nattanin Peancharoen
last_updated: 2026-02-23
---
หน้านี้รวบรวมข้อกำหนดการใช้งาน (Functional Requirements) ที่แบ่งตามระบบย่อย (Modules) ทั้งหมด
## 1. การจัดการโครงการและองค์กร (Project Management)
[01-02-01-project-management.md](01-02-01-project-management.md)
## 2. การจัดการจดหมายรับ-ส่ง (Correspondence)
[01-02-02-correspondence.md](01-02-02-correspondence.md)
## 3. การจัดการขออนุมัติวัสดุอุปกรณ์ (RFA)
[01-02-03-rfa.md](01-02-03-rfa.md)
## 4. การจัดการแบบคู่สัญญา (Contract Drawing)
[01-02-04-contract-drawing.md](01-02-04-contract-drawing.md)
## 5. การจัดการแบบก่อสร้าง (Shop Drawing)
[01-02-05-shop-drawing.md](01-02-05-shop-drawing.md)
## 6. กระบวนการอนุมัติ (Unified Workflow)
[01-02-06-unified-workflow.md](01-02-06-unified-workflow.md)
## 7. เอกสารนำส่ง (Transmittals)
[01-02-07-transmittals.md](01-02-07-transmittals.md)
## 8. ใบเวียนเอกสาร (Circulation Sheet)
[01-02-08-circulation-sheet.md](01-02-08-circulation-sheet.md)
## 9. ประวัติการแก้ไข (Logs / Revisions)
[01-02-09-logs.md](01-02-09-logs.md)
## 10. รายละเอียด JSON (JSON Details)
[01-02-10-json-details.md](01-02-10-json-details.md)

View File

@@ -3,15 +3,15 @@
--- ---
title: "Functional Requirements: Project Management" title: "Functional Requirements: Project Management"
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/01-requirements/01-objectives.md - specs/01-requirements/01-01-objectives.md
- specs/01-requirements/02-architecture.md - specs/02-architecture/README.md
- specs/01-requirements/03-functional-requirements.md - specs/01-requirements/01-03-modules/01-03-00-index.md
--- ---

View File

@@ -3,15 +3,15 @@
--- ---
title: 'Functional Requirements: Correspondence Management' title: 'Functional Requirements: Correspondence Management'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/01-requirements/01-objectives.md - specs/01-requirements/01-01-objectives.md
- specs/01-requirements/02-architecture.md - specs/02-architecture/README.md
- specs/01-requirements/03-functional-requirements.md - specs/01-requirements/01-03-modules/01-03-00-index.md
--- ---
@@ -27,7 +27,7 @@ related:
## 3.2.3. การสร้างเอกสาร (Correspondence): ## 3.2.3. การสร้างเอกสาร (Correspondence):
- ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารรอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น - ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารรอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น
- เมื่อกด "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล - เมื่อเอกสารเปลี่ยนสถานะเป็น "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล
## 3.2.4. การอ้างอิงและจัดกลุ่ม: ## 3.2.4. การอ้างอิงและจัดกลุ่ม:

View File

@@ -3,15 +3,15 @@
--- ---
title: 'Functional Requirements: RFA Management' title: 'Functional Requirements: RFA Management'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/01-requirements/01-objectives.md - specs/01-requirements/01-01-objectives.md
- specs/01-requirements/02-architecture.md - specs/02-architecture/README.md
- specs/01-requirements/03-functional-requirements.md - specs/01-requirements/01-03-modules/01-03-00-index.md
--- ---
@@ -28,7 +28,7 @@ related:
## 3.3.3. การสร้างเอกสาร: ## 3.3.3. การสร้างเอกสาร:
- ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารขออนุมัติ (RFA) รอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น - ผู้ใช้ที่มีสิทธิ์ (เช่น Document Control) สามารถสร้างเอกสารขออนุมัติ (RFA) รอไว้ในสถานะ ฉบับร่าง" (Draft) ได้ ซึ่งผู้ใช้งานต่างองค์กรจะมองไม่เห็น
- เมื่อกด "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล - เมื่อเอกสารเปลี่ยนสถานะเป็น "Submitted" แล้ว การแก้ไข, ถอนเอกสารกลับไปสถานะ Draft, หรือยกเลิก (Cancel) จะต้องทำโดยผู้ใช้ระดับ Admin ขึ้นไป พร้อมระบุเหตุผล
## 3.3.4. การอ้างอิงและจัดกลุ่ม: ## 3.3.4. การอ้างอิงและจัดกลุ่ม:

View File

@@ -3,15 +3,15 @@
--- ---
title: 'Functional Requirements: Contract Drawing Management' title: 'Functional Requirements: Contract Drawing Management'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/01-requirements/01-objectives.md - specs/01-requirements/01-01-objectives.md
- specs/01-requirements/02-architecture.md - specs/02-architecture/README.md
- specs/01-requirements/03-functional-requirements.md - specs/01-requirements/01-03-modules/01-03-00-index.md
--- ---

View File

@@ -3,15 +3,15 @@
--- ---
title: 'Functional Requirements: Shop Drawing Management' title: 'Functional Requirements: Shop Drawing Management'
version: 1.5.0 version: 1.8.0
status: first-draft status: first-draft
owner: Nattanin Peancharoen owner: Nattanin Peancharoen
last_updated: 2025-11-30 last_updated: 2026-02-23
related: related:
- specs/01-requirements/01-objectives.md - specs/01-requirements/01-01-objectives.md
- specs/01-requirements/02-architecture.md - specs/02-architecture/README.md
- specs/01-requirements/03-functional-requirements.md - specs/01-requirements/01-03-modules/01-03-00-index.md
--- ---

Some files were not shown because too many files have changed in this diff Show More