# @zola_do/crud

[![npm version](https://img.shields.io/npm/v/@zola_do/crud.svg)](https://www.npmjs.com/package/@zola_do/crud)
[![npm downloads](https://img.shields.io/npm/dm/@zola_do/crud.svg)](https://www.npmjs.com/package/@zola_do/crud)
[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)

Generic CRUD controllers, services, and repositories for NestJS entities with built-in authorization, hooks, and pagination.

## Overview

`@zola_do/crud` provides reusable CRUD factories that generate type-safe controllers and services for any TypeORM entity. It includes:

- **Controller Factories** — `EntityCrudController`, `ExtraCrudController`, `RelationCrudController`
- **Service Classes** — Generic CRUD operations with hooks
- **Repository Pattern** — EntityCrudRepository for data access
- **Authorization Guards** — Permission-based access control
- **Operation Hooks** — before/after callbacks for each operation
- **Endpoint Control** — Hide or block specific operations
- **Request Context** — Automatic tenant/user context mapping
- **Global Exception Filter** — Safe JSON error responses

## Installation

```bash
# Install individually
npm install @zola_do/crud

# Or via meta package
npm install @zola_do/nestjs-shared
```

### Dependencies

```bash
npm install @zola_do/collection-query @zola_do/authorization
```

### Optional Dependencies

```bash
# For RPC exception filter
npm install @nestjs/microservices
```

## Quick Start

### 1. Create an Entity

```typescript
import { CommonEntity } from '@zola_do/nestjs-shared';
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  DeleteDateColumn,
} from 'typeorm';

@Entity('products')
export class Product extends CommonEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  price: number;

  @Column({ default: 'active' })
  status: string;

  @DeleteDateColumn()
  deletedAt?: Date;
}
```

### 2. Create Controller

```typescript
import { Controller } from '@nestjs/common';
import { EntityCrudController } from '@zola_do/crud';
import { EntityCrudService } from '@zola_do/crud/service';
import { Product } from './product.entity';

@Controller('products')
export class ProductsController extends EntityCrudController<Product>({
  createPermission: 'product:create',
  viewPermission: 'product:view',
  updatePermission: 'product:update',
  deletePermission: 'product:delete',
}) {
  constructor(service: EntityCrudService<Product>) {
    super(service);
  }
}
```

### 3. Create Module

```typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EntityCrudService } from '@zola_do/crud/service';
import { Product } from './product.entity';
import { ProductsController } from './products.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Product])],
  controllers: [ProductsController],
  providers: [EntityCrudService],
})
export class ProductsModule {}
```

## Available Endpoints

| Method   | Endpoint       | Description                                  |
| -------- | -------------- | -------------------------------------------- |
| `POST`   | `/`            | Create a new entity                          |
| `GET`    | `/`            | List all entities (with `?q=` query support) |
| `GET`    | `/:id`         | Get single entity by ID                      |
| `PUT`    | `/:id`         | Update entire entity                         |
| `PATCH`  | `/:id`         | Partial update                               |
| `DELETE` | `/:id`         | Soft delete                                  |
| `PATCH`  | `/restore/:id` | Restore soft-deleted entity                  |
| `GET`    | `/archived`    | List archived entities                       |

## EntityCrudOptions

Full configuration options for the CRUD controller:

```typescript
import { EntityCrudOptions } from '@zola_do/crud';

const options: EntityCrudOptions = {
  // DTOs for validation
  createDto: CreateProductDto,
  updateDto: UpdateProductDto,

  // Permissions for guards
  createPermission: 'product:create',
  viewPermission: 'product:view',
  updatePermission: 'product:update',
  deletePermission: 'product:delete',
  restorePermission: 'product:restore',
  viewArchivedPermission: 'product:view_archived',

  // Operation hooks (create-time context mapping)
  beforeCreate: async ({ req, itemData }) => {
    itemData.organizationId = req?.user?.organizationId;
    itemData.createdBy = req?.user?.id;
  },
  afterCreate: async ({ service, req, result }) => {
    await service.sendNotification(result.id);
  },

  // Write field restrictions
  allowedWriteFields: ['name', 'price'],
  blockedWriteFields: ['id', 'createdAt'],

  beforeFindAll: async ({ service, req, where }) => {
    where.organizationId = req.user.organizationId;
  },
  afterFindAll: async ({ service, req, result }) => {
    result.items = result.items.filter(i => i.isActive);
  },

  beforeFindOne: async ({ service, req, where, params }) => {
    where.id = params.id;
  },

  beforeUpdate: async ({ service, req, itemData, params }) => {
    itemData.updatedBy = req.user.id;
  },
  afterUpdate: async ({ service, req, result }) => {
    await service.invalidateCache(result.id);
  },

  beforeDelete: async ({ service, req, params }) => {
    await service.assertCanDelete(params.id);
  },

  beforeRestore: async ({ service, req, params }) => {
    await service.assertCanRestore(params.id);
  },

  // Authorization callbacks
  authorize: async ({ req, operation }) => {
    return req.user.isAdmin;
  },
  canCreate: async ({ service, req }) => /* boolean */,
  canFindAll: async ({ service, req }) => /* boolean */,
  canFindOne: async ({ service, req, params }) => /* boolean */,
  canUpdate: async ({ service, req, params }) => /* boolean */,
  canDelete: async ({ service, req, params }) => /* boolean */,
  canRestore: async ({ service, req, params }) => /* boolean */,

  // Delete behavior
  deleteMode: 'soft', // 'auto' | 'soft' | 'hard'

  // Endpoint control
  operations: {
    create: 'hide', // Hide from routes (404)
    findAll: { mode: 'block', reason: 'maintenance' }, // Block with 403
    findOne: false, // Shorthand for 'hide'
    update: 'block',
    delete: 'hide',
    restore: 'hide',
    findAllArchived: 'hide',
  },
};
```

## Operation Hooks

Hooks are callbacks that run before or after CRUD operations. They receive a context object and can modify the request data or perform side effects.

### Hook Types

```typescript
interface CrudHookContext {
  req?: any; // Express request object
  params?: any; // Route parameters
  query?: any; // Query parameters
  id?: string; // Entity ID (for single-entity operations)
  itemData?: any; // Create/Update payload (mutable)
  where?: any; // Query filter (mutable for reads)
  entity?: any; // Existing entity (for updates)
  result?: any; // Operation result
  operation: string; // 'create' | 'findAll' | 'findOne' | 'update' | 'delete' | 'restore'
  service?: any; // CRUD service instance
  repository?: any; // TypeORM repository
  setMeta?: (key: string, value: any) => void; // Cross-hook metadata
  getMeta?: <T = any>(key: string) => T | undefined;
}
```

### Hook Examples

#### Before Create - Add Context Data

```typescript
beforeCreate: async ({ service, req, itemData }) => {
  const userId = req?.user?.sub ?? req?.user?.id;
  const orgId = req?.user?.organizationId;

  if (!userId || !orgId) {
    throw new UnauthorizedException('User context required');
  }

  itemData.createdBy = userId;
  itemData.organizationId = orgId;
  itemData.status ??= 'pending';
},
```

#### Before FindAll - Filter by Tenant

```typescript
beforeFindAll: async ({ service, req, where }) => {
  where.organizationId = req.user.organizationId;
  where.deletedAt = undefined; // Exclude soft-deleted
},
```

#### Before Update - Validate and Modify

```typescript
beforeUpdate: async ({ service, req, itemData, params }) => {
  const existing = await service.findOne(params.id);

  if (existing.status === 'completed') {
    throw new BadRequestException('Cannot update completed items');
  }

  itemData.updatedBy = req.user.id;
  itemData.updatedAt = new Date();
},
```

#### After Create - Side Effects

```typescript
afterCreate: async ({ service, req, result }) => {
  await service.sendWebhook('item.created', result);
  await service.updateAnalytics(result);
},
```

#### Using Metadata for Cross-Hook Communication

```typescript
beforeFindAll: async ({ req, setMeta }) => {
  setMeta('userId', req.user.id);
  setMeta('orgId', req.user.organizationId);
},

afterFindAll: async ({ service, req, result, getMeta }) => {
  const userId = getMeta('userId');
  await service.trackSearch(userId, result.items.length);
},
```

## Authorization Callbacks

Operation-specific authorization allows fine-grained access control:

```typescript
const options: EntityCrudOptions = {
  // Global authorize (runs for all operations)
  authorize: async ({ req, operation }) => {
    if (!req.user) return false;
    return true;
  },

  // Per-operation authorization
  canCreate: async ({ service, req }) => {
    return req.user.permissions.includes('product:create');
  },

  canFindAll: async ({ service, req }) => {
    return true; // Public read
  },

  canUpdate: async ({ service, req, params }) => {
    const entity = await service.findOne(params.id);
    return entity.ownerId === req.user.id || req.user.isAdmin;
  },

  canDelete: async ({ service, req, params }) => {
    const entity = await service.findOne(params.id);
    return entity.ownerId === req.user.id;
  },

  canRestore: async ({ service, req, params }) => {
    return req.user.isAdmin;
  },
};
```


## CRUD Policy Layer

Use `policy` when authorization and row scoping should live in one reusable object instead of separate per-operation callbacks:

```typescript
EntityCrudController<Product>({
  policy: {
    canCreate: ({ req }) => req.user.permissions.includes('product:create'),
    canRead: ({ req }, entity) => !entity || entity.organizationId === req.user.organizationId,
    canUpdate: ({ req }, entity) => entity.ownerId === req.user.id,
    canDelete: ({ req }, entity) => req.user.isAdmin || entity.ownerId === req.user.id,
    scopeQuery: ({ req }) => ({ organizationId: req.user.organizationId }),
  },
});
```

Policy callbacks are optional and run after the existing `authorize` and `can*` hooks. Returning `false` throws `ForbiddenException`; returning `true` or `void` allows the operation. `scopeQuery` merges returned fields into the same `where` filter path used by `beforeFindAll` hooks.

`canRead` is used for both `findAll` and `findOne`. For `findOne`, the loaded entity is passed as the second argument so ownership checks can use the persisted row.
## Endpoint Disabling

Control which endpoints are available using the `operations` option:

```typescript
operations: {
  // 'hide' - Route not registered (404)
  create: 'hide',

  // 'block' - Route registered but denied (403)
  findAll: { mode: 'block', reason: 'Under maintenance' },

  // false - Same as 'hide'
  findOne: false,

  // 'block' with string shorthand
  update: 'block',

  // All available keys
  delete: 'hide',
  restore: 'hide',
  findAllArchived: 'hide',
}
```

### Operation Keys

| Key               | Endpoint             | Description       |
| ----------------- | -------------------- | ----------------- |
| `create`          | `POST /`             | Create new entity |
| `findAll`         | `GET /`              | List entities     |
| `findOne`         | `GET /:id`           | Get single entity |
| `update`          | `PUT /:id`           | Full update       |
| `delete`          | `DELETE /:id`        | Soft delete       |
| `restore`         | `PATCH /restore/:id` | Restore entity    |
| `findAllArchived` | `GET /archived`      | List deleted      |

## Request Transactions

Enable atomic mutating requests (create/update/delete hooks + DB writes commit or roll back together):

```typescript
import { InjectDataSource } from '@nestjs/typeorm';
import {
  bindCrudTransactionDataSource,
  CrudTransactionModule,
} from '@zola_do/crud';
import { DataSource } from 'typeorm';

@Module({
  imports: [TypeOrmModule.forRoot(dataSourceOptions), CrudTransactionModule],
})
export class AppModule {
  constructor(@InjectDataSource() dataSource: DataSource) {
    bindCrudTransactionDataSource(dataSource);
  }
}
```

```typescript
EntityCrudController<Product>({
  useTransaction: true,
  beforeCreate: async ({ itemData }) => {
    itemData.status = 'draft';
  },
  afterCreate: async ({ repository, result }) => {
    // DB work here uses the same transaction; throws roll back the insert too.
    await repository.insert({ ...relatedRow, productId: result.id });
  },
});
```

How it works:

1. `useTransaction: true` attaches `TransactionInterceptor` to mutating routes (`POST`, `PUT`, `DELETE`, restore, bulk).
2. The interceptor starts a TypeORM transaction and stores the entity manager on `req`.
3. CRUD services resolve repositories from that manager, so `beforeCreate`, inserts/updates/deletes, and `afterCreate` DB calls share one transaction.
4. If any step throws, the interceptor rolls back and the client receives the error.

Notes:

- Non-DB side effects in hooks (HTTP, email, queues) are **not** rolled back automatically.
- Use `context.repository` (or pass `context.req` into other service methods) so related DB work stays in the same transaction.
- Read endpoints (`GET`) are unchanged and do not start a transaction unless you add the interceptor manually.

### Postgres RLS / session context (consuming app)

Implement {@link CrudTransactionContextHook} in your app and **bind it in `AppModule`**
(the interceptor singleton is created before optional token injection resolves):

```typescript
import {
  bindCrudTransactionContextHook,
  bindCrudTransactionDataSource,
  CrudTransactionContextHook,
  CrudTransactionModule,
} from '@zola_do/crud';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Injectable } from '@nestjs/common';

@Injectable()
class PostgresRlsTransactionContextHook implements CrudTransactionContextHook {
  async apply({ req, queryRunner }) {
    const user = req?.user;
    await queryRunner.query(
      `SELECT set_config('app.user_id', $1, true), set_config('app.tenant_id', $2, true)`,
      [user?.id ?? '', user?.tenantId ?? ''],
    );
  }
}

@Module({
  imports: [TypeOrmModule.forRoot(dataSourceOptions), CrudTransactionModule],
  providers: [PostgresRlsTransactionContextHook],
})
export class AppModule {
  constructor(
    @InjectDataSource() dataSource: DataSource,
    rlsHook: PostgresRlsTransactionContextHook,
  ) {
    bindCrudTransactionDataSource(dataSource);
    bindCrudTransactionContextHook(rlsHook);
  }
}
```

The hook runs after `START TRANSACTION` and before CRUD handlers. Use transaction-local
`set_config(..., true)` so pooled connections do not leak context. For read routes (`GET`),
add a similar global interceptor if RLS must apply there too.

## Request Context Mapping

The CRUD package supports automatic context injection for create operations:

### Method 1: Per-CRUD `beforeCreate` Hook

```typescript
EntityCrudController<Product>({
  beforeCreate: async ({ req, itemData }) => {
    itemData.organizationId = req?.user?.organizationId;
    itemData.organizationName = req?.user?.organizationName;
    itemData.createdBy = req?.user?.id;
  },
});
```

When `beforeCreate` is configured, the legacy `req.user.organization` fallback does not overwrite client-supplied organization fields.

### Method 2: Module-Level Resolver

```typescript
import {
  CRUD_REQUEST_CONTEXT_RESOLVER,
  CrudRequestContextResolver,
} from '@zola_do/crud/request-context';

const resolver: CrudRequestContextResolver = {
  resolveCreateContext: ({ req }) => ({
    organizationId: req?.auth?.orgId,
    organizationName: req?.auth?.orgName,
    createdBy: req?.auth?.userId,
  }),
};

@Module({
  providers: [
    {
      provide: CRUD_REQUEST_CONTEXT_RESOLVER,
      useValue: resolver,
    },
  ],
})
export class ProductsModule {}
```

### Method 3: Legacy Fallback

If no resolver is configured, the system falls back to `req.user.organization`:

```typescript
// Automatically uses req.user.organization.organizationId
// and req.user.organization.organizationName
```

## Relation CRUD

For managing many-to-many relationships:

```typescript
import { RelationCrudController, RelationCrudService } from '@zola_do/crud';

@Controller('products/:productId/categories')
export class ProductCategoriesController extends RelationCrudController(
  {
    firstEntityIdName: 'productId',
    firstInclude: 'product',
    secondEntityIdName: 'categoryId',
    secondInclude: 'category',
    viewPermission: 'product:view',
    operations: {
      bulkSaveFirst: 'hide',
      findAllSecond: 'hide',
    },
  },
  ProductCategory,
) {
  constructor(service: RelationCrudService) {
    super(service);
  }
}
```

### Relation CRUD Endpoints

| Method | Endpoint            | Description                     |
| ------ | ------------------- | ------------------------------- |
| `POST` | `/bulk-save-first`  | Assign multiple first entities  |
| `POST` | `/bulk-save-second` | Assign multiple second entities |
| `GET`  | `/first-entities`   | List all first entities         |
| `GET`  | `/second-entities`  | List all second entities        |

## Extra CRUD

Extended CRUD with parent entity scoping:

```typescript
import { ExtraCrudController, ExtraCrudService } from '@zola_do/crud';

@Controller('events/:eventId/attendees')
export class EventAttendeesController extends ExtraCrudController(
  {
    entityIdName: 'eventId',
    createPermission: 'attendee:create',
    viewPermission: 'attendee:view',
  },
  Attendee,
) {
  constructor(service: ExtraCrudService<Attendee>) {
    super(service);
  }
}
```

## Global Exception Filter

The package includes a safe global exception filter:

```typescript
import { GlobalExceptionFilter } from '@zola_do/crud';

// In your main.ts
app.useGlobalFilters(new GlobalExceptionFilter());
```

### Response Format

```typescript
// Success response
{
  "statusCode": 200,
  "data": { ... }
}

// Error response (production)
{
  "statusCode": 400,
  "message": "Validation failed",
  "error": "Bad Request",
  "path": "/api/products",
  "timestamp": "2024-01-01T00:00:00.000Z"
}
```

**Note:** Raw exceptions and stack traces are never exposed to clients in production.

Standard error shape:

```json
{
  "statusCode": 403,
  "message": "crud_operation_forbidden",
  "error": "Forbidden",
  "path": "/products/1",
  "timestamp": "2026-06-27T00:00:00.000Z"
}
```

## Delete Modes

| Mode   | Behavior                       | Use Case                                   |
| ------ | ------------------------------ | ------------------------------------------ |
| `auto` | Uses entity metadata (default) | Standard entities with `@DeleteDateColumn` |
| `soft` | Sets `deletedAt` timestamp     | Audit trail required                       |
| `hard` | Physical delete                | Data that can be safely removed            |

```typescript
// Force hard delete
EntityCrudController<Product>({
  deleteMode: 'hard',
  beforeDelete: async ({ service, params }) => {
    await service.cleanupRelatedData(params.id);
  },
});
```

## Query String Support

List endpoints support collection query parameters via `?q=`:

```bash
GET /products?q=w=status$eq$active&t=20&o=createdAt$desc&i=category
```

See [@zola_do/collection-query](../collection-query) for full query syntax.

## API Reference

### Controllers

#### `EntityCrudController<T>(options: EntityCrudOptions)`

Generic CRUD controller factory.

```typescript
class EntityCrudController<T> {
  constructor(
    protected readonly service: EntityCrudService<T>,
  ) {}

  // POST /
  create(@Body() dto: any, @Request() req: any): Promise<T>

  // GET /
  findAll(
    @Query('q') query: string,
    @Request() req: any,
  ): Promise<CollectionResult<T>>

  // GET /:id
  findOne(@Param('id') id: string, @Request() req: any): Promise<T>

  // PUT /:id
  update(
    @Param('id') id: string,
    @Body() dto: any,
    @Request() req: any,
  ): Promise<T>

  // PATCH /:id
  patch(
    @Param('id') id: string,
    @Body() dto: any,
    @Request() req: any,
  ): Promise<T>

  // DELETE /:id
  delete(@Param('id') id: string, @Request() req: any): Promise<void>

  // PATCH /restore/:id
  restore(@Param('id') id: string, @Request() req: any): Promise<T>

  // GET /archived
  findAllArchived(@Request() req: any): Promise<T[]>
}
```

#### `ExtraCrudController<T>(options: ExtraCrudOptions, entity: new () => T)`

Extended CRUD with parent entity scope.

#### `RelationCrudController(options: RelationCrudOptions, entity: new () => any)`

Many-to-many relationship management.

### Services

#### `EntityCrudService<T>`

Generic CRUD service with query builder.

```typescript
class EntityCrudService<T> {
  constructor(protected readonly repository: Repository<T>) {}

  findAll(query: CollectionQuery): Promise<CollectionResult<T>>;
  findOne(id: string): Promise<T>;
  create(data: any): Promise<T>;
  update(id: string, data: any): Promise<T>;
  patch(id: string, data: any): Promise<T>;
  delete(id: string): Promise<void>;
  restore(id: string): Promise<T>;
}
```

### Repositories

#### `EntityCrudRepository<T>`

Repository with built-in query construction.

## Recommended Imports

Subpath imports are recommended for tree-shaking:

```typescript
import { EntityCrudController } from '@zola_do/crud/controller';
import { EntityCrudService } from '@zola_do/crud/service';
import { EntityCrudRepository } from '@zola_do/crud/repository';
import {
  CRUD_REQUEST_CONTEXT_RESOLVER,
  CrudRequestContextResolver,
} from '@zola_do/crud/request-context';
import { EntityCrudOptions, GlobalExceptionFilter } from '@zola_do/crud';
```

Root import is supported for backward compatibility:

```typescript
import {
  EntityCrudController,
  EntityCrudService,
  EntityCrudOptions,
} from '@zola_do/crud';
```

## Supply chain / Socket.dev

Dependency Alerts on npm reflect **NestJS, Express, TypeORM, and authorization transitives**, not malware in `@zola_do/crud` source. Use subpath imports (`./optional/swagger`, `./optional/rpc`) where possible to keep optional peer trees smaller.

See [docs/SUPPLY_CHAIN.md](../../docs/SUPPLY_CHAIN.md) for per-alert triage.

## Optional Features

### Swagger OpenAPI helpers

Common CRUD endpoint decorators are available from the optional Swagger subpath:

```typescript
import {
  ApiCrudBulkCreate,
  ApiCrudBulkDelete,
  ApiCrudCreate,
  ApiCrudDelete,
  ApiCrudGetOne,
  ApiCrudList,
  ApiCrudUpdate,
} from '@zola_do/crud/optional/swagger';
```

Use these only in apps that install `@nestjs/swagger`.

Paginated response decorators and data response DTOs require `@nestjs/swagger`:

```typescript
import {
  ApiPaginatedResponse,
  DataResponseFormat,
} from '@zola_do/crud/optional/swagger';
```

### RPC Exception Filter

For NestJS microservices:

```typescript
import { ExceptionFilter } from '@zola_do/crud/optional/rpc';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: ExceptionFilter,
    },
  ],
})
export class OrdersModule {}
```

Requires `@nestjs/microservices` peer dependency.

## Advanced features

### Bulk create and bulk delete

Enable batch endpoints on `EntityCrudController`:

```typescript
@EntityCrudController(
  {
    enableBulkCreate: true,
    enableBulkDelete: true,
    bulkCreatePermission: 'products:create',
    bulkDeletePermission: 'products:delete',
  },
  Product,
)
export class ProductController extends EntityCrudControllerHost {}
```

- `POST /products/bulk` — body `{ items: [...] }`
- `POST /products/bulk-delete` — body `{ ids: string[] }`
- Disable per operation with `operations.bulkCreate` / `operations.bulkDelete` (`hide` | `block`)

### Create idempotency

Register a `CrudIdempotencyStore` and enable on entity options:

```typescript
import {
  CRUD_IDEMPOTENCY_STORE,
  InMemoryCrudIdempotencyStore,
} from '@zola_do/crud';

@Module({
  providers: [
    {
      provide: CRUD_IDEMPOTENCY_STORE,
      useClass: InMemoryCrudIdempotencyStore, // swap for Redis in production
    },
  ],
})
export class AppModule {}

@EntityCrudController({ idempotency: { enabled: true, ttlMs: 86_400_000 } }, Order)
export class OrderController extends EntityCrudControllerHost {}
```

Clients send `Idempotency-Key` (configurable via `headerName`). Replays return the stored create response.

### Optimistic concurrency

```typescript
@EntityCrudController({ optimisticConcurrency: true }, Product)
export class ProductController extends EntityCrudControllerHost {}
```

Updates require a version field on the entity; conflicts return HTTP 409.

### Cursor pagination (plain query params)

CRUD list endpoints accept compact `?q=` (see `@zola_do/collection-query`) **or** plain params:

```
GET /products?cursor=<token>&limit=20&direction=next
GET /products?cursor=<token>&limit=20&direction=prev
```

Response may include cursor metadata alongside `total` / `items` when cursor mode is active.

### `collectionQueryDefaults`

Apply default column allowlists for every list on a controller:

```typescript
@EntityCrudController(
  {
    collectionQueryDefaults: {
      allowedFilterColumns: ['name', 'status', 'createdAt'],
      allowedSortColumns: ['name', 'createdAt'],
    },
  },
  Product,
)
export class ProductController extends EntityCrudControllerHost {}
```

Unset allowlists skip enforcement — set defaults for defense in depth.

## Troubleshooting

### Q: How do I add custom methods to the CRUD service?

Extend the generic service:

```typescript
@Injectable()
export class ProductService extends EntityCrudService<Product> {
  constructor(
    @InjectRepository(Product)
    repository: Repository<Product>,
  ) {
    super(repository);
  }

  async findActive(): Promise<Product[]> {
    return this.repository.find({ where: { status: 'active' } });
  }
}
```

### Q: How do I validate the DTO?

Use `class-validator` decorators:

```typescript
import { IsString, IsNumber, IsEnum, IsOptional } from 'class-validator';

export class CreateProductDto {
  @IsString()
  name: string;

  @IsNumber()
  price: number;

  @IsEnum(['draft', 'active', 'archived'])
  @IsOptional()
  status?: string;
}
```

### Q: How do I handle file uploads?

Combine with `@nestjs/platform-express`:

```typescript
// Add to your DTO
import { UploadedFile } from '@nestjs/common';

async create(
  @Body() dto: CreateProductDto,
  @UploadedFile() file: Express.Multer.File,
) {
  if (file) {
    dto.imageUrl = await this.uploadService.upload(file);
  }
  return this.productService.create(dto);
}
```

## Related Packages

- [@zola_do/collection-query](../collection-query) — Query decoding for list endpoints
- [@zola_do/authorization](../authorization) — Guards for protected CRUD
- [@zola_do/typeorm](../typeorm) — Entity configuration

## License

ISC

## Community

- [Contributing](../../CONTRIBUTING.md)
- [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [Security Policy](../../SECURITY.md)



