@foryourdev/nestjs-crud
v0.3.21
Published
Automatically generate CRUD Rest API based on NestJS and TypeOrm
Maintainers
Readme
nestjs-crud
A powerful library that automatically generates RESTful CRUD APIs based on NestJS and TypeORM.
📋 Table of Contents
- Features
- Installation
- Quick Start
- Basic CRUD Operations
- RESTful Query Parameters
- Advanced Configuration
- Soft Delete and Recovery
- Migration Guide
- API Documentation
- Examples
- License
✨ Features
🚀 Core Features
- Automatic CRUD Route Generation: Auto-generate APIs based on TypeORM entities
- RESTful Standard Compliance: API endpoints following industry standards
- Automatic Swagger Generation: Auto-generate and maintain API documentation
- Powerful Validation: Data validation through class-validator
- Full TypeScript Support: Type safety and IntelliSense support
🔍 Advanced Query Features
- Filtering: Support for 30+ filter operators
- Sorting: Multi-field sorting support
- Relation Inclusion: Relationship data loading with nested relation support
- Pagination: Support for Offset, Cursor, and Number-based pagination
- Search: Complex search condition support
🛠 Database Features
- Soft Delete: Mark data as deleted without actual deletion
- Recovery: Recover soft-deleted data
- Upsert: Update if exists, create if doesn't exist
- Lifecycle Hooks: Execute custom logic at each stage of CRUD operations
- Decorator Approach 🆕: Intuitive method decorators like
@BeforeCreate(),@AfterUpdate(),@BeforeDestroy(),@BeforeRecover() - Routes Configuration Approach: Legacy
routes.hooksconfiguration approach
- Decorator Approach 🆕: Intuitive method decorators like
- Full-Text Search 🆕: PostgreSQL FTS support with GIN indexes and
plainto_tsquery
🔒 Security and Control Features
- Filter Restrictions: Only columns specified in allowedFilters can be filtered
- Parameter Restrictions: Only columns specified in allowedParams can be used as request parameters
- Relation Inclusion Restrictions: Only relations specified in allowedIncludes can be included
- Default Block Policy: If not configured, all filtering/parameters/relation inclusion is blocked
- Field Exclusion 🆕: Global and route-specific field exclusion from responses
⚡ Performance Features (v0.2.7+)
- Built-in Caching: Memory, Redis, and multi-tier caching strategies
- Lazy Loading: Load relations only when needed
- Auto Relation Detection: Automatic N+1 query optimization
- Enhanced Services:
CacheableCrudServiceandEnhancedCrudServicefor advanced features
📦 Installation
npm install nestjs-crud
# or
yarn add nestjs-crudRequired Dependencies
npm install @nestjs/common @nestjs/core typeorm class-validator class-transformer🚀 Quick Start
1. Create Entity
// user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { IsString, IsEmail, IsOptional } from 'class-validator';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
@IsString()
name: string;
@Column({ unique: true })
@IsEmail()
email: string;
@Column({ nullable: true })
@IsOptional()
@IsString()
bio?: string;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
updatedAt: Date;
}2. Create Service
// user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CrudService } from 'nestjs-crud';
import { User } from './user.entity';
@Injectable()
export class UserService extends CrudService<User> {
constructor(
@InjectRepository(User)
repository: Repository<User>,
) {
super(repository);
}
}3. Create Controller
// user.controller.ts
import { Controller } from '@nestjs/common';
import { Crud, BeforeCreate } from 'nestjs-crud';
import { UserService } from './user.service';
import { User } from './user.entity';
import * as bcrypt from 'bcrypt';
@Controller('users')
@Crud({
entity: User,
})
export class UserController {
constructor(public readonly crudService: UserService) {}
// 🆕 NEW! Add logic easily with lifecycle hook decorators
@BeforeCreate()
async hashPassword(body: any, context: any) {
if (body.password) {
body.password = await bcrypt.hash(body.password, 10);
}
return body;
}
}4. Module Configuration
// user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { User } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UserController],
providers: [UserService],
})
export class UserModule {}🎯 Basic CRUD Operations
The above configuration automatically generates the following API endpoints:
| HTTP Method | Endpoint | Description | Method Name |
| ----------- | -------------------- | ----------------------- | ----------- |
| GET | /users | Get list of users | index |
| GET | /users/:id | Get specific user | show |
| POST | /users | Create new user | create |
| PUT | /users/:id | Update user information | update |
| DELETE | /users/:id | Delete user | destroy |
| POST | /users/upsert | Create or update user | upsert |
| POST | /users/:id/recover | Recover deleted user | recover |
📊 Unified Response Structure
All CRUD operations provide a consistent response structure with metadata:
GET /users (index) - Pagination Response
{
"data": [
{ "id": 1, "name": "John Doe", "email": "[email protected]" },
{ "id": 2, "name": "Jane Smith", "email": "[email protected]" },
{ "id": 3, "name": "Bob Johnson", "email": "[email protected]" }
],
"metadata": {
"operation": "index",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 3,
"includedRelations": ["department", "posts"],
"pagination": {
"type": "offset",
"total": 150,
"page": 1,
"pages": 15,
"offset": 10,
"nextCursor": "eyJpZCI6M30="
}
}
}GET /users (cursor pagination)
{
"data": [
{ "id": 4, "name": "Alice Brown", "email": "[email protected]" },
{ "id": 5, "name": "Charlie Wilson", "email": "[email protected]" }
],
"metadata": {
"operation": "index",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 2,
"pagination": {
"type": "cursor",
"total": 150,
"limit": 2,
"totalPages": 75,
"nextCursor": "eyJpZCI6NX0="
}
}
}GET /users/:id (show)
{
"data": {
"id": 1,
"name": "John Doe",
"email": "[email protected]",
"createdAt": "2024-01-15T10:30:00.000Z"
},
"metadata": {
"operation": "show",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 1,
"includedRelations": ["department"],
"excludedFields": ["password"]
}
}POST /users (create)
{
"data": {
"id": 1,
"name": "John Doe",
"email": "[email protected]",
"createdAt": "2024-01-15T10:30:00.000Z"
},
"metadata": {
"operation": "create",
"timestamp": "2024-01-15T10:30:00.000Z",
"affectedCount": 1
}
}PUT /users/:id (update)
{
"data": {
"id": 1,
"name": "John Doe Updated",
"email": "[email protected]",
"updatedAt": "2024-01-15T11:00:00.000Z"
},
"metadata": {
"operation": "update",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 1
}
}POST /users/upsert (upsert)
{
"data": {
"id": 1,
"name": "John Doe Upsert",
"email": "[email protected]"
},
"metadata": {
"operation": "upsert",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 1,
"isNew": false // true: newly created, false: existing data updated
}
}DELETE /users/:id (destroy)
{
"data": {
"id": 1,
"name": "John Doe",
"email": "[email protected]",
"deletedAt": "2024-01-15T11:00:00.000Z"
},
"metadata": {
"operation": "destroy",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 1,
"wasSoftDeleted": true // true: soft delete, false: hard delete
}
}POST /users/:id/recover (recover)
{
"data": {
"id": 1,
"name": "John Doe",
"email": "[email protected]",
"deletedAt": null
},
"metadata": {
"operation": "recover",
"timestamp": "2024-01-15T11:00:00.000Z",
"affectedCount": 1,
"wasSoftDeleted": true // Whether it was soft deleted before recovery
}
}Multiple Creation (POST /users - array submission)
{
"data": [
{ "id": 1, "name": "John Doe", "email": "[email protected]" },
{ "id": 2, "name": "Jane Smith", "email": "[email protected]" }
],
"metadata": {
"operation": "create",
"timestamp": "2024-01-15T10:30:00.000Z",
"affectedCount": 2
}
}📝 Bulk Operations Support
| Operation | Single | Bulk (Multiple) | Description | |-----------|--------|-----------------|-------------| | CREATE | ✅ Supported | ✅ Supported | Submit single object or array of objects | | UPDATE | ✅ Supported | ✅ Supported | Submit array with ID for each item | | UPSERT | ✅ Supported | ✅ Supported | Submit array of objects for bulk upsert | | DELETE | ✅ Supported | ✅ Supported | Submit array of IDs or conditions | | RECOVER | ✅ Supported | ✅ Supported | Submit array of IDs for bulk recovery |
Bulk Create Example
# Single creation
POST /users
{
"name": "John Doe",
"email": "[email protected]"
}
# Bulk creation (array submission)
POST /users
[
{ "name": "John Doe", "email": "[email protected]" },
{ "name": "Jane Smith", "email": "[email protected]" },
{ "name": "Bob Johnson", "email": "[email protected]" }
]Bulk Update Example (NEW! 🆕)
# Single update
PUT /users/1
{
"name": "John Updated",
"email": "[email protected]"
}
# Bulk update (array submission with IDs)
PUT /users
[
{ "id": 1, "name": "John Updated", "email": "[email protected]" },
{ "id": 2, "name": "Jane Updated", "email": "[email protected]" },
{ "id": 3, "name": "Bob Updated", "email": "[email protected]" }
]Bulk Upsert Example (NEW! 🆕)
# Single upsert
POST /users/upsert
{
"id": 1,
"name": "John Doe",
"email": "[email protected]"
}
# Bulk upsert (array submission)
POST /users/upsert
[
{ "id": 1, "name": "John Doe", "email": "[email protected]" }, // Update if exists
{ "name": "New User", "email": "[email protected]" }, // Create if not exists
{ "id": 99, "name": "Another User", "email": "[email protected]" }
]Bulk Delete Example (NEW! 🆕)
# Single delete
DELETE /users/1
# Bulk delete (array of IDs in query params)
DELETE /users?ids=1,2,3
# Or via request body
DELETE /users
{
"ids": [1, 2, 3]
}Bulk Recover Example (NEW! 🆕)
# Single recover
POST /users/1/recover
# Bulk recover (array of IDs)
POST /users/recover
{
"ids": [1, 2, 3]
}Note: All bulk operations support lifecycle hooks and will execute them for each item in the batch.
🔍 RESTful Query Parameters
📋 Filtering
⚠️ Important: Query Parameter Format
nestjs-crud uses underscore separator format. MongoDB-style $ operators are not supported.
# ✅ Correct format (underscore separator)
GET /users?filter[email_eq][email protected]
GET /users?filter[age_gte]=18
GET /users?filter[name_like]=%John%
# ❌ Unsupported format (MongoDB style)
GET /users?filter[email][$eq][email protected] # Won't work
GET /users?filter[age][$gte]=18 # Won't work
GET /users?filter[name][$like]=%John% # Won't workParsing method:
filter[field_operator]=value→ ✅ Works correctlyfilter[field][$operator]=value→ ❌ Filter is ignored
Basic Comparison Operators
# Equals
GET /users?filter[name_eq]=John Doe
GET /users?filter[age_eq]=25
# Not equals
GET /users?filter[status_ne]=inactive
GET /users?filter[role_ne]=adminSize Comparison Operators
# Greater than/Greater than or equal
GET /users?filter[age_gt]=18
GET /users?filter[age_gte]=18
# Less than/Less than or equal
GET /users?filter[age_lt]=65
GET /users?filter[age_lte]=65
# Range
GET /users?filter[age_between]=18,65
GET /users?filter[salary_between]=30000,80000String Pattern Operators
# LIKE pattern (case sensitive)
GET /users?filter[name_like]=%John%
GET /users?filter[email_like]=%@gmail.com
# ILIKE pattern (case insensitive)
GET /users?filter[name_ilike]=%JOHN%
GET /users?filter[email_ilike]=%GMAIL%
# Start/End patterns
GET /users?filter[name_start]=John
GET /users?filter[email_end]=.com
# Contains
GET /users?filter[bio_contains]=developerArray/List Operators
# Include (IN)
GET /users?filter[id_in]=1,2,3,4,5
GET /users?filter[role_in]=admin,manager,user
# Exclude (NOT IN)
GET /users?filter[status_not_in]=deleted,banned
GET /users?filter[role_not_in]=guestNULL/Existence Check Operators
# NULL check
GET /users?filter[deleted_at_null]=true
GET /users?filter[last_login_null]=true
# NOT NULL check
GET /users?filter[avatar_not_null]=true
GET /users?filter[email_verified_at_not_null]=true
# Existence check (not null and not empty string)
GET /users?filter[bio_present]=true
# Blank check (null or empty string)
GET /users?filter[middle_name_blank]=trueRelation Filtering
# Nested relation filtering
GET /posts?filter[author.name_like]=%John%
GET /posts?filter[author.department.name_eq]=Development
GET /comments?filter[post.author.role_eq]=admin🔄 Sorting
# Single field sorting
GET /users?sort=name # Name ascending
GET /users?sort=-created_at # Creation date descending
# Multi-field sorting
GET /users?sort=role,name,-created_at # Role>Name>Creation date order
# Relation field sorting
GET /posts?sort=author.name,-created_at
GET /users?sort=department.name,name🔗 Including Relations
⚠️ Important Changes:
- The
routes.relationsoption has been deprecated. - Now use
allowedIncludesconfiguration andincludequery parameter together. - Enhanced Security: If allowedIncludes is not configured, all relation inclusion is blocked.
# Single relation (only relations allowed in allowedIncludes)
GET /users?include=department
GET /posts?include=author
# Multiple relations
GET /users?include=department,posts
GET /posts?include=author,comments
# Nested relations
GET /posts?include=author,comments.author
GET /users?include=department.company,posts.comments
GET /orders?include=customer.address,items.product.categoryBefore and After Comparison
// ❌ Previous approach (deprecated)
@Crud({
entity: User,
routes: {
index: {
relations: ['department', 'posts'], // Relations included by default
}
}
})
// ✅ New approach (enhanced security)
@Crud({
entity: User,
allowedIncludes: ['department', 'posts'], // Explicitly specify allowed relations
routes: {
index: {
allowedIncludes: ['department', 'posts', 'posts.comments'], // Additional method-specific allowances
}
}
})
// When relations are needed, explicitly request via query parameter
GET /users?include=department,postsSecurity Policy
// 1. allowedIncludes not configured → All relations blocked
@Crud({
entity: User,
// No allowedIncludes → All includes ignored
})
// 2. Global configuration
@Crud({
entity: User,
allowedIncludes: ['department'], // Only department allowed
})
// 3. Method-specific configuration (higher priority)
@Crud({
entity: User,
allowedIncludes: ['department'], // Global: department only
routes: {
index: {
allowedIncludes: ['department', 'posts'], // INDEX allows posts additionally
},
show: {
// No allowedIncludes → Uses global configuration: department only
},
},
})Benefits
- Enhanced Security: Only explicitly allowed relations can be included
- Explicit Requests: Selectively load only necessary relations
- Performance Optimization: Prevent unnecessary relation loading
- N+1 Problem Prevention: Handle necessary relations with JOINs only
- Fine-grained Control: Apply different relation inclusion policies per method
📄 Pagination
Page Number Method
GET /users?page[number]=1&page[size]=10 # Page 1, 10 items per page
GET /users?page[number]=3&page[size]=20 # Page 3, 20 items per pageOffset Method
GET /users?page[offset]=0&page[limit]=10 # First 10 items
GET /users?page[offset]=20&page[limit]=10 # 10 items starting from 20thCursor Method
GET /users?page[cursor]=eyJpZCI6MTB9&page[size]=10📊 Pagination Response Structure
Offset/Number Pagination Response
{
"data": [
{ "id": 1, "name": "John Doe", "email": "[email protected]" },
{ "id": 2, "name": "Jane Smith", "email": "[email protected]" }
],
"metadata": {
"page": 1, // Current page number
"pages": 10, // Total pages ✅
"total": 95, // Total data count
"offset": 10, // Next offset
"nextCursor": "..." // Next page token
}
}Cursor Pagination Response
{
"data": [
{ "id": 1, "name": "John Doe", "email": "[email protected]" },
{ "id": 2, "name": "Jane Smith", "email": "[email protected]" }
],
"metadata": {
"total": 95, // Total data count
"totalPages": 10, // Total pages ✅
"limit": 10, // Page size
"nextCursor": "..." // Next page token
}
}🔍 Complex Query Examples
Check out complex query usage through real-world use cases:
User Search Example
# Get 10 active adult users sorted by recent registration
GET /users?filter[status_eq]=active&
filter[age_gte]=18&
sort=-created_at&
page[number]=1&page[size]=10Post Search Example
# Get published posts by specific author with author information
GET /posts?filter[author.name_like]=%John%&
filter[status_eq]=published&
filter[created_at_gte]=2024-01-01&
include=author,comments&
sort=-created_at,title&
page[number]=1&page[size]=20Order Search Example
# Get completed orders with customer and order item information
GET /orders?filter[status_eq]=completed&
filter[total_amount_gte]=50000&
filter[created_at_between]=2024-01-01,2024-12-31&
include=customer.address,items.product&
sort=-created_at&
page[offset]=0&page[limit]=50⚙️ Advanced Configuration
🔒 Security Control Settings
Filter Restrictions - allowedFilters
@Controller('users')
@Crud({
entity: User,
allowedFilters: ['name', 'email', 'status'], // Global: only these columns allowed for filtering
routes: {
index: {
allowedFilters: ['name', 'email', 'status', 'createdAt'], // INDEX allows more columns
},
show: {
allowedFilters: ['name'], // SHOW allows only name
},
},
})
export class UserController {
constructor(public readonly crudService: UserService) {}
}Operation examples:
# ✅ Allowed columns - works normally
GET /users?filter[name_like]=%John%
GET /users?filter[email_eq][email protected]
# ❌ Disallowed columns - filter ignored
GET /users?filter[password_eq]=secret # Ignored if password not in allowedFiltersParameter Restrictions - allowedParams
@Controller('users')
@Crud({
entity: User,
allowedParams: ['name', 'email'], // Global: only these columns allowed as request parameters
routes: {
create: {
allowedParams: ['name', 'email', 'status'], // CREATE allows additional status
},
update: {
allowedParams: ['name'], // UPDATE allows only name
},
upsert: {
// No allowedParams -> uses global configuration: name, email only
},
},
})
export class UserController {
constructor(public readonly crudService: UserService) {}
}Operation examples:
// Configuration: allowedParams: ['name', 'email']
// ✅ Only allowed parameters are processed
POST /users
{
"name": "John Doe", // ✅ Processed
"email": "[email protected]", // ✅ Processed
"password": "secret", // ❌ Removed (not in allowedParams)
"internal_id": 123 // ❌ Removed (not in allowedParams)
}
// Actually processed data:
{
"name": "John Doe",
"email": "[email protected]"
}Relation Inclusion Restrictions - allowedIncludes
@Controller('posts')
@Crud({
entity: Post,
allowedIncludes: ['author'], // Global: only author relation allowed
routes: {
index: {
allowedIncludes: ['author', 'comments', 'tags'], // INDEX allows more relations
},
show: {
allowedIncludes: ['author', 'comments.author'], // SHOW allows nested relations
},
},
})
export class PostController {
constructor(public readonly crudService: PostService) {}
}Operation examples:
# ✅ Only allowed relations are included
GET /posts?include=author # ✅ Included
GET /posts?include=comments # ✅ Included (in INDEX)
GET /posts?include=author,comments # ✅ Both included
# ❌ Disallowed relations are ignored
GET /posts?include=author,likes,comments # ✅ Only author,comments included (likes ignored)
GET /posts?include=profile # ❌ All relations ignored (profile not allowed)🎛️ CRUD Options Configuration
@Controller('users')
@Crud({
entity: User,
only: ['index', 'show', 'create', 'update'], // Enable only specific methods
allowedFilters: ['name', 'email', 'status'], // Allowed filter columns
allowedParams: ['name', 'email', 'bio'], // Allowed request parameters
allowedIncludes: ['department', 'posts'], // Allowed relation inclusions
routes: {
index: {
paginationType: PaginationType.OFFSET,
numberOfTake: 20,
sort: Sort.DESC,
softDelete: false,
allowedFilters: ['name', 'email', 'status', 'createdAt'], // Method-specific filter settings
allowedIncludes: ['department', 'posts', 'posts.comments'], // Method-specific relation settings
},
show: {
softDelete: true,
allowedFilters: ['name', 'email'], // SHOW has restrictive filtering
allowedIncludes: ['department'], // SHOW has basic relations only
},
create: {
hooks: {
assignBefore: async (body, context) => {
// Email normalization
if (body.email) {
body.email = body.email.toLowerCase().trim();
}
return body;
},
saveAfter: async (entity, context) => {
// Send user creation event
await eventBus.publish('user.created', entity);
return entity;
},
},
},
update: {
hooks: {
assignBefore: async (body, context) => {
body.updatedAt = new Date();
return body;
},
},
},
destroy: {
softDelete: true,
},
},
})
export class UserController {
constructor(public readonly crudService: UserService) {}
}🔄 Lifecycle Hooks
Execute custom logic at each stage of CRUD operations through lifecycle hooks.
Hook Types
| Hook | Execution Point | Purpose | Supported Routes |
| --------------- | -------------------------- | -------------------------------- | ----------------------------- |
| assignBefore | Before data assignment | Input validation, transformation | create, update, upsert, show* |
| assignAfter | After data assignment | Entity post-processing | create, update, upsert, show* |
| saveBefore | Before saving | Final validation, business logic | create, update, upsert |
| saveAfter | After saving | Notifications, event generation | create, update, upsert |
| destroyBefore | Before entity deletion | Permission check, cleanup prep | destroy |
| destroyAfter | After entity deletion | Audit logs, external sync | destroy |
| recoverBefore | Before entity recovery | Permission check, recovery prep | recover |
| recoverAfter | After entity recovery | Audit logs, notifications | recover |
Note: For show operation, assignBefore processes query parameters before entity lookup, and assignAfter processes the retrieved entity before returning it.
🎯 Method 1: Decorator Approach (NEW! 🆕 Recommended)
Use decorators on class methods for intuitive usage.
Available Decorators
Basic decorators:
@BeforeCreate() // Execute before CREATE (assignBefore)
@AfterCreate() // Execute after CREATE (saveAfter)
@BeforeUpdate() // Execute before UPDATE (assignBefore)
@AfterUpdate() // Execute after UPDATE (saveAfter)
@BeforeUpsert() // Execute before UPSERT (assignBefore)
@AfterUpsert() // Execute after UPSERT (saveAfter)
@BeforeDestroy() // Execute before DESTROY (destroyBefore)
@AfterDestroy() // Execute after DESTROY (destroyAfter)
@BeforeRecover() // Execute before RECOVER (recoverBefore)
@AfterRecover() // Execute after RECOVER (recoverAfter)
@BeforeShow() // Execute before SHOW (assignBefore for show)
@AfterShow() // Execute after SHOW (assignAfter for show)Consistent fine-grained control decorators:
@BeforeAssign('create' | 'update' | 'upsert' | 'show') // Before assignment
@AfterAssign('create' | 'update' | 'upsert' | 'show') // After assignment
@BeforeSave('create' | 'update' | 'upsert') // Before saving
@AfterSave('create' | 'update' | 'upsert') // After saving
@BeforeDestroy('destroy') // Before entity deletion
@AfterDestroy('destroy') // After entity deletion
@BeforeRecover('recover') // Before entity recovery
@AfterRecover('recover') // After entity recovery🆕 New 4-stage detailed decorators (clearer control):
// === ASSIGN stage (data assignment to entity) ===
@BeforeAssignCreate() @BeforeAssignUpdate() @BeforeAssignUpsert() @BeforeAssignShow() // Before assignment
@AfterAssignCreate() @AfterAssignUpdate() @AfterAssignUpsert() @AfterAssignShow() // After assignment
// === SAVE stage (database saving) ===
@BeforeSaveCreate() @BeforeSaveUpdate() @BeforeSaveUpsert() // Before saving
@AfterSaveCreate() @AfterSaveUpdate() @AfterSaveUpsert() // After saving
// === DESTROY stage (entity deletion) ===
@BeforeDestroyDestroy() // Before entity deletion
@AfterDestroyDestroy() // After entity deletion
// === RECOVER stage (entity recovery) ===
@BeforeRecoverRecover() // Before entity recovery
@AfterRecoverRecover() // After entity recoveryReal Usage Examples
Using basic decorators:
import { Controller, Post, Put } from '@nestjs/common';
import { Crud, BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate } from 'nestjs-crud';
import { User } from './user.entity';
import { UserService } from './user.service';
import * as bcrypt from 'bcrypt';
@Crud({
entity: User,
allowedParams: ['name', 'email', 'password', 'phone'],
})
@Controller('users')
export class UserController {
constructor(public readonly crudService: UserService) {}
// 🔐 Hash password before CREATE
@BeforeCreate()
async hashPasswordOnCreate(body: any, context: any) {
if (body.password) {
console.log('CREATE: Hashing password...');
body.password = await bcrypt.hash(body.password, 10);
}
// Set default values
body.provider = body.provider || 'local';
body.role = body.role || 'user';
return body;
}
// 📧 Send welcome email after CREATE
@AfterCreate()
async sendWelcomeEmail(entity: User, context: any) {
console.log(`New user created: ${entity.email} (ID: ${entity.id})`);
// Welcome email sending logic
// await this.emailService.sendWelcomeEmail(entity);
return entity;
}
// 🔐 Hash password before UPDATE too
@BeforeUpdate()
async hashPasswordOnUpdate(body: any, context: any) {
if (body.password) {
console.log('UPDATE: Hashing password...');
body.password = await bcrypt.hash(body.password, 10);
}
// Auto-set update time
body.updatedAt = new Date();
return body;
}
// 📝 Log user update after UPDATE
@AfterUpdate()
async logUserUpdate(entity: User, context: any) {
console.log(`User update completed: ${entity.email} (ID: ${entity.id})`);
// Record update log
// await this.auditService.logUserUpdate(entity, context.request?.user);
return entity;
}
// 🗑️ Permission check and cleanup preparation before DELETE
@BeforeDestroy()
async beforeDeleteUser(entity: User, context: any) {
console.log(`DELETE permission check for: ${entity.email} (ID: ${entity.id})`);
// Check delete permission
const userId = context.request?.user?.id;
if (entity.id !== userId) {
const userRole = context.request?.user?.role;
if (userRole !== 'admin') {
throw new Error('Permission denied: Cannot delete other users');
}
}
// Prevent deletion of locked users
if (entity.status === 'locked') {
throw new Error('Cannot delete locked user');
}
// Set deletion metadata
entity.deletedBy = userId;
entity.deletedAt = new Date();
return entity;
}
// 🧹 Cleanup and notification after DELETE
@AfterDestroy()
async afterDeleteUser(entity: User, context: any) {
console.log(`User deleted successfully: ${entity.email} (ID: ${entity.id})`);
// Cleanup related data
// await this.sessionService.revokeUserSessions(entity.id);
// await this.tokenService.revokeUserTokens(entity.id);
// Send deletion notification
// await this.notificationService.notifyUserDeletion(entity);
// Log deletion for audit
// await this.auditService.logUserDeletion(entity, context.request?.user);
return entity;
}
// 🚀 NEW! Permission check and preparation before RECOVER
@BeforeRecover()
async beforeRecoverUser(entity: User, context: HookContext<User>) {
console.log(`RECOVER permission check for: ${entity.email} (ID: ${entity.id})`);
// Check recover permission
const userId = context.request?.user?.id;
if (entity.deletedBy !== userId) {
const userRole = context.request?.user?.role;
if (userRole !== 'admin') {
throw new Error('Permission denied: Cannot recover other users data');
}
}
// Prevent recovery of certain users
if (entity.status === 'banned') {
throw new Error('Cannot recover banned user');
}
// Set recovery metadata
entity.recoveredBy = userId;
entity.recoveredAt = new Date();
return entity;
}
// 🚀 NEW! Cleanup and notification after RECOVER
@AfterRecover()
async afterRecoverUser(entity: User, context: HookContext<User>) {
console.log(`User recovered successfully: ${entity.email} (ID: ${entity.id})`);
// Restore related data
// await this.sessionService.enableUserSessions(entity.id);
// await this.tokenService.reissueUserTokens(entity.id);
// Send recovery notification
// await this.notificationService.notifyUserRecovery(entity);
// Log recovery for audit
// await this.auditService.logUserRecovery(entity, context.request?.user);
return entity;
}
}Execution Order and Parameters
Hooks during Create process:
@BeforeCreate() // = @BeforeAssign('create')
async beforeCreate(body: any, context: HookContext) {
// body: request data
// context: { operation: 'create', params: {}, currentEntity: undefined }
return body; // Return modified body
}
@AfterCreate() // = @AfterSave('create')
async afterCreate(entity: User, context: HookContext) {
// entity: saved entity
// context: { operation: 'create', params: {}, currentEntity: undefined }
return entity; // Return modified entity
}Hooks during Update process:
@BeforeUpdate() // = @BeforeAssign('update')
async beforeUpdate(entity: User, context: HookContext) {
// 🚀 NEW: entity-based (full entity with ID)
// entity: entity to update (includes all fields)
// context: { operation: 'update', params: { id: 5 }, currentEntity: User }
return entity;
}
@AfterUpdate() // = @AfterSave('update')
async afterUpdate(entity: User, context: HookContext) {
// entity: updated entity
// context: { operation: 'update', params: { id: 5 }, currentEntity: User }
return entity;
}🆕 NEW! Hooks during Destroy process:
@BeforeDestroy() // = @BeforeDestroy('destroy')
async beforeDestroy(entity: User, context: HookContext) {
// 🚀 Entity-based (full entity with ID)
// entity: entity to delete (includes all fields)
// context: { operation: 'destroy', params: { id: 5 }, currentEntity: User }
// Perfect for permission checks and cleanup preparation
if (entity.status === 'locked') {
throw new Error('Cannot delete locked user');
}
return entity;
}
@AfterDestroy() // = @AfterDestroy('destroy')
async afterDestroy(entity: User, context: HookContext) {
// entity: deleted entity (for cleanup and logging)
// context: { operation: 'destroy', params: { id: 5 }, currentEntity: User }
// Perfect for cleanup, notifications, and audit logs
await this.cleanupUserData(entity.id);
return entity;
}🆕 NEW! Hooks during Recover process:
@BeforeRecover() // = @BeforeRecover('recover')
async beforeRecover(entity: User, context: HookContext) {
// 🚀 Entity-based (full soft-deleted entity with ID)
// entity: entity to recover (includes all fields)
// context: { operation: 'recover', params: { id: 5 }, currentEntity: User }
// Perfect for permission checks and recovery preparation
if (entity.status === 'banned') {
throw new Error('Cannot recover banned user');
}
// Set recovery metadata
entity.recoveredBy = context.request?.user?.id;
entity.recoveredAt = new Date();
return entity;
}
@AfterRecover() // = @AfterRecover('recover')
async afterRecover(entity: User, context: HookContext) {
// entity: recovered entity (for restoration and logging)
// context: { operation: 'recover', params: { id: 5 }, currentEntity: User }
// Perfect for data restoration, notifications, and audit logs
await this.restoreUserData(entity.id);
return entity;
}
@BeforeShow() // = @BeforeAssign('show')
async beforeShow(params: any, context: HookContext) {
// params: request parameters for entity lookup
// context: { operation: 'show', params: { id: 5 } }
// Transform parameters before entity lookup
if (typeof params.id === 'string') {
params.id = parseInt(params.id, 10);
}
return params;
}
@AfterShow() // = @AfterAssign('show')
async afterShow(entity: User, context: HookContext) {
// entity: retrieved entity
// context: { operation: 'show', params: { id: 5 } }
// Perfect for masking sensitive data, adding computed fields
if (entity.password) {
entity.password = '***HIDDEN***';
}
// Add view tracking
await this.trackView(entity.id);
return entity;
}Advanced Usage Examples
@Crud({
entity: Post,
allowedParams: ['title', 'content', 'status'],
})
@Controller('posts')
export class PostController {
constructor(public readonly crudService: PostService) {}
@BeforeCreate()
async beforeCreatePost(body: any, context: any) {
// Auto-set user ID
const userId = context.request?.user?.id;
if (userId) {
body.userId = userId;
}
// Auto-generate slug
if (body.title && !body.slug) {
body.slug = body.title
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
return body;
}
@BeforeSave('create')
async validateBeforeSave(entity: Post, context: any) {
// Check and resolve slug duplication
const existingPost = await this.crudService.findBySlug(entity.slug);
if (existingPost) {
entity.slug = `${entity.slug}-${Date.now()}`;
}
return entity;
}
@AfterCreate()
async afterCreatePost(entity: Post, context: any) {
// Update search index
// await this.searchService.indexPost(entity);
// Notify about published post
if (entity.status === 'published') {
// await this.notificationService.notifyNewPost(entity);
console.log(`New post published: ${entity.title}`);
}
return entity;
}
// Multiple hooks can be used together
@BeforeUpdate()
@BeforeUpsert()
async beforeModify(data: any, context: any) {
// Common logic for both UPDATE and UPSERT
data.updatedAt = new Date();
if (context.operation === 'upsert' && !context.currentEntity) {
data.createdAt = new Date();
}
return data;
}
// 🆕 NEW! DESTROY hook example
@BeforeDestroy()
async beforeDelete(entity: Post, context: any) {
console.log(`Preparing to delete post: ${entity.title} (ID: ${entity.id})`);
// Check if user has permission to delete this post
const userId = context.request?.user?.id;
if (entity.userId !== userId) {
const userRole = context.request?.user?.role;
if (userRole !== 'admin' && userRole !== 'moderator') {
throw new Error('Permission denied: Cannot delete other users posts');
}
}
// Prevent deletion of published posts by regular users
if (entity.status === 'published' && context.request?.user?.role !== 'admin') {
throw new Error('Cannot delete published posts');
}
return entity;
}
@AfterDestroy()
async afterDelete(entity: Post, context: any) {
console.log(`Post deleted successfully: ${entity.title} (ID: ${entity.id})`);
// Clean up related data
// await this.commentService.deletePostComments(entity.id);
// await this.tagService.removePostTags(entity.id);
// Update search index
// await this.searchService.removeFromIndex(entity.id);
// Send notification
// await this.notificationService.notifyPostDeletion(entity);
return entity;
}
}🆕 Using new 4-stage detailed decorators:
import { Controller } from '@nestjs/common';
import {
Crud,
BeforeAssignCreate, // Before assignment
AfterAssignCreate, // After assignment
BeforeSaveCreate, // Before saving
AfterSaveCreate, // After saving
BeforeAssignUpdate,
AfterSaveUpdate,
} from 'nestjs-crud';
import { User } from './user.entity';
import { UserService } from './user.service';
import * as bcrypt from 'bcrypt';
@Crud({
entity: User,
allowedParams: ['name', 'email', 'password', 'phone'],
})
@Controller('users')
export class UserController {
constructor(public readonly crudService: UserService) {}
// 🔐 CREATE: Stage 1 - Before assignment (data validation and transformation)
@BeforeAssignCreate()
async validateAndTransformCreate(body: any, context: any) {
console.log('1️⃣ CREATE before assignment: Data validation and transformation');
if (body.password) {
body.password = await bcrypt.hash(body.password, 10);
}
// Set default values
body.provider = body.provider || 'local';
body.role = body.role || 'user';
return body;
}
// 🔧 CREATE: Stage 2 - After assignment (entity post-processing)
@AfterAssignCreate()
async postProcessCreate(entity: User, context: any) {
console.log('2️⃣ CREATE after assignment: Entity post-processing');
// Additional entity processing logic
if (!entity.displayName) {
entity.displayName = entity.name;
}
return entity;
}
// 🔍 CREATE: Stage 3 - Before saving (final validation)
@BeforeSaveCreate()
async finalValidateCreate(entity: User, context: any) {
console.log('3️⃣ CREATE before saving: Final validation');
// Duplicate email check
const existing = await this.crudService.findOne({ where: { email: entity.email } });
if (existing) {
throw new Error('Email already exists');
}
return entity;
}
// 📧 CREATE: Stage 4 - After saving (follow-up processing)
@AfterSaveCreate()
async postSaveCreate(entity: User, context: any) {
console.log('4️⃣ CREATE after saving: Follow-up processing');
// Send welcome email
// await this.emailService.sendWelcomeEmail(entity);
// Send analytics event
// await this.analyticsService.trackUserCreated(entity);
return entity;
}
// 🔐 UPDATE: Before assignment
@BeforeAssignUpdate()
async beforeUpdateAssign(entity: User, context: any) {
console.log('🔄 UPDATE before assignment: Process update entity');
// 🚀 NEW: Now receives full entity with ID
if (entity.password) {
entity.password = await bcrypt.hash(entity.password, 10);
}
entity.updatedAt = new Date();
return entity;
}
// 📝 UPDATE: After saving
@AfterSaveUpdate()
async afterUpdateSave(entity: User, context: any) {
console.log('📝 UPDATE after saving: Handle update completion');
// Record update log
// await this.auditService.logUserUpdate(entity, context.request?.user);
return entity;
}
}Using consistent fine-grained control decorators:
@Controller('posts')
export class PostController {
constructor(public readonly crudService: PostService) {}
// Execute before assignment stage for all methods (create, update, upsert)
@BeforeAssign('create')
@BeforeAssign('update')
@BeforeAssign('upsert')
async commonPreProcess(body: any, context: any) {
console.log(`🔧 ${context.operation.toUpperCase()} before assignment common processing`);
// Common pre-processing logic
body.updatedAt = new Date();
if (context.operation === 'create') {
body.createdAt = new Date();
}
return body;
}
// Execute after saving stage for all methods
@AfterSave('create')
@AfterSave('update')
@AfterSave('upsert')
async commonPostProcess(entity: any, context: any) {
console.log(`✅ ${context.operation.toUpperCase()} after saving common processing`);
// Common post-processing logic (search index update, cache refresh, etc.)
// await this.searchService.updateIndex(entity);
// await this.cacheService.invalidate(`post:${entity.id}`);
return entity;
}
// 🆕 NEW! DESTROY-specific hooks
@BeforeDestroy()
async beforePostDelete(entity: any, context: any) {
console.log(`🗑️ DESTROY before deletion: Preparing to delete ${entity.title}`);
// Check deletion permissions
const userId = context.request?.user?.id;
if (entity.userId !== userId && context.request?.user?.role !== 'admin') {
throw new Error('Permission denied');
}
// Set deletion metadata
entity.deletedBy = userId;
entity.deletedAt = new Date();
return entity;
}
@AfterDestroy()
async afterPostDelete(entity: any, context: any) {
console.log(`🧹 DESTROY after deletion: Cleanup for ${entity.title}`);
// Common cleanup logic
// await this.searchService.removeFromIndex(entity.id);
// await this.cacheService.invalidate(`post:${entity.id}`);
// await this.notificationService.notifyDeletion(entity);
return entity;
}
}🔗 Chain Execution of Multiple Hooks
When using the same decorator on multiple methods, they execute in definition order as a chain:
@Crud({
entity: User,
allowedParams: ['name', 'email', 'password'],
})
@Controller('users')
export class UserController {
constructor(public readonly crudService: UserService) {}
// 🔗 First CREATE hook
@BeforeCreate()
async validateData(body: any, context: any) {
console.log('1️⃣ Validating data...');
if (!body.email) {
throw new Error('Email is required');
}
body.step1 = 'validated';
return body; // ✅ Modified body passed to next hook
}
// 🔗 Second CREATE hook (receives result from first hook)
@BeforeCreate()
async hashPassword(body: any, context: any) {
console.log('2️⃣ Hashing password...');
console.log('Previous step result:', body.step1); // ✅ Outputs 'validated'
if (body.password) {
body.password = await bcrypt.hash(body.password, 10);
}
body.step2 = 'encrypted';
return body; // ✅ Return final modified body
}
// 🔗 Third CREATE hook (receives result from second hook)
@BeforeCreate()
async setDefaults(body: any, context: any) {
console.log('3️⃣ Setting defaults...');
console.log('Previous steps results:', body.step1, body.step2); // ✅ Outputs 'validated', 'encrypted'
body.provider = body.provider || 'local';
body.role = body.role || 'user';
body.step3 = 'completed';
return body; // ✅ Final completed body
}
}Execution order:
POST /users
{
"name": "John Doe",
"email": "[email protected]",
"password": "mypassword"
}
# Console output:
# 1️⃣ Validating data...
# 2️⃣ Hashing password...
# Previous step result: validated
# 3️⃣ Setting defaults...
# Previous steps results: validated encrypted
# Final saved data:
{
"name": "John Doe",
"email": "[email protected]",
"password": "$2b$10$...", // ✅ Encrypted
"provider": "local", // ✅ Default set
"role": "user", // ✅ Default set
"step1": "validated", // ✅ Passed through chain
"step2": "encrypted", // ✅ Passed through chain
"step3": "completed" // ✅ Final processing
}⚡ Simple Test Example
// Minimal example for simple testing
@Crud({
entity: User,
allowedParams: ['name', 'email', 'password'],
})
@Controller('users')
export class UserController {
constructor(public readonly crudService: UserService) {}
@BeforeCreate()
async step1(body: any, context: any) {
body.step1 = 'first';
console.log('Step 1:', body);
return body;
}
@BeforeCreate()
async step2(body: any, context: any) {
body.step2 = 'second';
console.log('Step 2:', body); // Check if step1 exists
return body;
}
}
// POST /users { "name": "test" }
// Console output:
// Step 1: { name: "test", step1: "first" }
// Step 2: { name: "test", step1: "first", step2: "second" }Advantages
- 🎯 Intuitive: Clear role from method names
- 🧹 Clean Code: Routes configuration not complex
- 🔗 Chain Execution: Multiple hooks execute sequentially with automatic data passing
- 🔄 Reusability: Common hooks can be implemented through inheritance
- 🛡️ Type Safety: TypeScript type checking support
- ✨ IntelliSense: IDE auto-completion support
- 🚀 Entity-based UPDATE/DESTROY/RECOVER: Full entity access with ID for advanced operations
🛠️ Method 2: Routes Configuration Approach (Legacy)
Basic Usage
@Controller('users')
@Crud({
entity: User,
routes: {
create: {
hooks: {
assignBefore: async (body, context) => {
// Convert email to lowercase
if (body.email) {
body.email = body.email.toLowerCase();
}
return body;
},
assignAfter: async (entity, body, context) => {
// Set default role
if (!entity.role) {
entity.role = 'user';
}
return entity;
},
saveBefore: async (entity, context) => {
// Check duplicate email
const existing = await userService.findByEmail(entity.email);
if (existing) {
throw new Error('Email already exists');
}
return entity;
},
saveAfter: async (entity, context) => {
// Send welcome email
await emailService.sendWelcomeEmail(entity.email);
return entity;
},
},
},
update: {
hooks: {
assignBefore: async (entity, context) => {
// 🚀 NEW: Now receives full entity
// Auto-set update time
entity.updatedAt = new Date();
// Prevent modification of certain fields
const originalId = entity.id;
const originalCreatedAt = entity.createdAt;
// Restore protected fields if they were modified
entity.id = originalId;
entity.createdAt = originalCreatedAt;
return entity;
},
saveBefore: async (entity, context) => {
// Check permissions
const userId = context.request?.user?.id;
if (entity.id !== userId) {
throw new Error('Permission denied');
}
return entity;
},
},
},
destroy: {
hooks: {
destroyBefore: async (entity, context) => {
// 🆕 NEW! DESTROY before hook
console.log(`Preparing to delete user: ${entity.name} (ID: ${entity.id})`);
// Check delete permission
const userId = context.request?.user?.id;
if (entity.id !== userId && context.request?.user?.role !== 'admin') {
throw new Error('Permission denied: Cannot delete other users');
}
// Prevent deletion of locked accounts
if (entity.status === 'locked') {
throw new Error('Cannot delete locked user account');
}
// Set deletion metadata
entity.deletedBy = userId;
entity.deletedAt = new Date();
return entity;
},
destroyAfter: async (entity, context) => {
// 🆕 NEW! DESTROY after hook
console.log(`User deleted successfully: ${entity.name} (ID: ${entity.id})`);
// Cleanup related data
// await sessionService.revokeUserSessions(entity.id);
// await tokenService.revokeUserTokens(entity.id);
// Send deletion notification
// await emailService.notifyUserDeletion(entity);
// Log for audit
// await auditService.logUserDeletion(entity, context.request?.user);
return entity;
},
},
},
recover: {
hooks: {
recoverBefore: async (entity, context) => {
// 🆕 NEW! RECOVER before hook
console.log(`Preparing to recover user: ${entity.name} (ID: ${entity.id})`);
// Check recover permission
const userId = context.request?.user?.id;
if (entity.deletedBy !== userId && context.request?.user?.role !== 'admin') {
throw new Error('Permission denied: Cannot recover other users data');
}
// Prevent recovery of banned accounts
if (entity.status === 'banned') {
throw new Error('Cannot recover banned user account');
}
// Set recovery metadata
entity.recoveredBy = userId;
entity.recoveredAt = new Date();
return entity;
},
recoverAfter: async (entity, context) => {
// 🆕 NEW! RECOVER after hook
console.log(`User recovered successfully: ${entity.name} (ID: ${entity.id})`);
// Restore related data
// await sessionService.enableUserSessions(entity.id);
// await tokenService.reissueUserTokens(entity.id);
// Send recovery notification
// await emailService.notifyUserRecovery(entity);
// Log for audit
// await auditService.logUserRecovery(entity, context.request?.user);
return entity;
},
},
},
},
})
export class UserController {
constructor(public readonly crudService: UserService) {}
}Advanced Usage Example
@Controller('posts')
@Crud({
entity: Post,
routes: {
create: {
hooks: {
assignBefore: async (body, context) => {
// Auto-set user ID
const userId = context.request?.user?.id;
if (userId) {
body.userId = userId;
}
// Auto-generate slug
if (body.title && !body.slug) {
body.slug = slugify(body.title);
}
return body;
},
assignAfter: async (entity, body, context) => {
// Set default post status
if (!entity.status) {
entity.status = 'draft';
}
// Set publication date when publishing
if (entity.status === 'published' && !entity.publishedAt) {
entity.publishedAt = new Date();
}
return entity;
},
saveBefore: async (entity, context) => {
// Validate required fields
if (!entity.title?.trim()) {
throw new Error('Title is required');
}
// Check and resolve slug duplication
const existingPost = await postService.findBySlug(entity.slug);
if (existingPost) {
entity.slug = `${entity.slug}-${Date.now()}`;
}
return entity;
},
saveAfter: async (entity, context) => {
// Update search index
await searchService.indexPost(entity);
// Process tags
if (entity.tags?.length) {
await tagService.processPostTags(entity.id, entity.tags);
}
// Notify about published post
if (entity.status === 'published') {
await notificationService.notifyNewPost(entity);
}
return entity;
},
},
},
upsert: {
hooks: {
assignBefore: async (body, context) => {
const now = new Date();
body.updatedAt = now;
// Set creation date only for new data
if (!context.currentEntity) {
body.createdAt = now;
}
return body;
},
saveAfter: async (entity, context) => {
// Differentiate between newly created and updated cases
const isNew = !context.currentEntity;
if (isNew) {
await analyticsService.trackPostCreated(entity);
} else {
await analyticsService.trackPostUpdated(entity);
}
return entity;
},
},
},
},
})
export class PostController {
constructor(public readonly crudService: PostService) {}
}HookContext Usage
// HookContext provides the following information
interface HookContext<T> {
operation: 'create' | 'update' | 'upsert' | 'destroy' | 'recover' | 'show'; // Operation type
params?: Record<string, any>; // URL parameters
currentEntity?: T; // Current entity (update, upsert, destroy, recover)
request?: any; // Express Request object
}
// Context usage example
const hooks = {
assignBefore: async (body, context) => {
console.log(`Operation type: ${context.operation}`);
// Use requester information
if (context.request?.user) {
body.lastModifiedBy = context.request.user.id;
}
// Use URL parameters
if (context.params?.parentId) {
body.parentId = context.params.parentId;
}
// Use existing entity information (update, upsert only)
if (context.currentEntity) {
console.log('Existing data:', context.currentEntity);
}
return body;
},
};Show Operation Hooks (NEW! 🆕)
The show operation now supports assignBefore and assignAfter hooks for read-time data processing:
assignBefore: Process query parameters before entity lookup (security filtering, parameter transformation)assignAfter: Process retrieved entity before returning it (data masking, calculated fields)
Show Operation Hook Examples
@Controller('users')
@Crud({
entity: User,
routes: {
show: {
hooks: {
// Process parameters before entity lookup
assignBefore: async (params, context) => {
// Security: Validate user has permission to view this ID
const requesterId = context.request?.user?.id;
const targetId = params.id;
if (requesterId !== targetId && context.request?.user?.role !== 'admin') {
throw new ForbiddenException('Cannot view other users');
}
// Parameter transformation
if (typeof params.id === 'string') {
params.id = parseInt(params.id, 10);
}
return params;
},
// Process entity after retrieval
assignAfter: async (entity, _, context) => {
// Mask sensitive information
if (entity.email) {
entity.email = entity.email.replace(/(.{2}).*(@.*)/, '$1***$2');
}
// Hide sensitive fields based on viewer's role
if (context.request?.user?.role !== 'admin') {
delete entity.salary;
delete entity.ssn;
}
// Add calculated fields
(entity as any).displayName = `${entity.firstName} ${entity.lastName}`;
(entity as any).age = calculateAge(entity.birthDate);
// Track view statistics (without saving to DB)
await analyticsService.trackView(entity.id, context.request?.user?.id);
return entity;
}
}
}
}
})
export class UserController {
constructor(public readonly crudService: UserService) {}
}Common Use Cases for Show Hooks
- Data Masking & Security:
assignAfter: async (entity, _, context) => {
// Mask PII based on viewer permissions
const isOwner = entity.id === context.request?.user?.id;
const isAdmin = context.request?.user?.role === 'admin';
if (!isOwner && !isAdmin) {
entity.phone = entity.phone?.replace(/\d(?=\d{4})/g, '*');
entity.email = entity.email?.replace(/(.{2}).*(@.*)/, '$1***$2');
delete entity.dateOfBirth;
}
return entity;
}- Enriching Response with Calculated Data:
assignAfter: async (entity, _, context) => {
// Add computed fields without modifying database
(entity as any).fullAddress = `${entity.street}, ${entity.city}, ${entity.country}`;
(entity as any).accountAge = daysSince(entity.createdAt);
(entity as any).isPremium = entity.subscriptionLevel === 'premium';
// Fetch additional data from external services
(entity as any).creditScore = await creditService.getScore(entity.id);
return entity;
}- Parameter Validation & Transformation:
assignBefore: async (params, context) => {
// Validate and transform ID formats
if (params.uuid && !isValidUUID(params.uuid)) {
throw new BadRequestException('Invalid UUID format');
}
// Apply tenant isolation
if (context.request?.tenant) {
params.tenantId = context.request.tenant.id;
}
return params;
}- Analytics & Monitoring:
assignAfter: async (entity, _, context) => {
// Track access patterns without affecting response
await Promise.all([
auditLog.record({
action: 'VIEW',
entityType: 'User',
entityId: entity.id,
viewerId: context.request?.user?.id,
timestamp: new Date()
}),
metricsService.increment('user.profile.views'),
cacheService.warm(entity.id, entity) // Pre-cache for future requests
]);
return entity;
}Reusing Common Hook Functions
// Define 