@zola_do/crud 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +600 -151
  2. package/dist/controller/entity-crud.controller.d.ts +6 -0
  3. package/dist/controller/entity-crud.controller.js +78 -6
  4. package/dist/controller/entity-crud.controller.js.map +1 -1
  5. package/dist/service/entity-crud.service.d.ts +5 -1
  6. package/dist/service/entity-crud.service.js +116 -35
  7. package/dist/service/entity-crud.service.js.map +1 -1
  8. package/dist/service/extra-crud.service.js +2 -28
  9. package/dist/service/extra-crud.service.js.map +1 -1
  10. package/dist/service/relation-crud.service.js +0 -12
  11. package/dist/service/relation-crud.service.js.map +1 -1
  12. package/dist/shared/exceptions/global-exception.filter.js +32 -10
  13. package/dist/shared/exceptions/global-exception.filter.js.map +1 -1
  14. package/dist/shared/index.d.ts +2 -0
  15. package/dist/shared/index.js +2 -0
  16. package/dist/shared/index.js.map +1 -1
  17. package/dist/shared/tokens/crud-idempotency.token.d.ts +1 -0
  18. package/dist/shared/tokens/crud-idempotency.token.js +5 -0
  19. package/dist/shared/tokens/crud-idempotency.token.js.map +1 -0
  20. package/dist/shared/types/crud-idempotency-store.interface.d.ts +4 -0
  21. package/dist/shared/types/crud-idempotency-store.interface.js +3 -0
  22. package/dist/shared/types/crud-idempotency-store.interface.js.map +1 -0
  23. package/dist/shared/types/crud-option.type.d.ts +22 -2
  24. package/dist/shared/utils/collection-query-merge.d.ts +3 -0
  25. package/dist/shared/utils/collection-query-merge.js +19 -0
  26. package/dist/shared/utils/collection-query-merge.js.map +1 -0
  27. package/dist/shared/utils/crud-hooks.helper.d.ts +1 -1
  28. package/dist/shared/utils/crud-hooks.helper.js +22 -3
  29. package/dist/shared/utils/crud-hooks.helper.js.map +1 -1
  30. package/dist/shared/utils/in-memory-idempotency.store.d.ts +6 -0
  31. package/dist/shared/utils/in-memory-idempotency.store.js +24 -0
  32. package/dist/shared/utils/in-memory-idempotency.store.js.map +1 -0
  33. package/dist/shared/utils/index.d.ts +2 -0
  34. package/dist/shared/utils/index.js +2 -0
  35. package/dist/shared/utils/index.js.map +1 -1
  36. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # @zola_do/crud
2
2
 
3
- Generic CRUD controllers, services, and repositories for NestJS entities.
3
+ [![npm version](https://img.shields.io/npm/v/@zola_do/crud.svg)](https://www.npmjs.com/package/@zola_do/crud)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@zola_do/crud.svg)](https://www.npmjs.com/package/@zola_do/crud)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)
6
+
7
+ Generic CRUD controllers, services, and repositories for NestJS entities with built-in authorization, hooks, and pagination.
8
+
9
+ ## Overview
10
+
11
+ `@zola_do/crud` provides reusable CRUD factories that generate type-safe controllers and services for any TypeORM entity. It includes:
12
+
13
+ - **Controller Factories** — `EntityCrudController`, `ExtraCrudController`, `RelationCrudController`
14
+ - **Service Classes** — Generic CRUD operations with hooks
15
+ - **Repository Pattern** — EntityCrudRepository for data access
16
+ - **Authorization Guards** — Permission-based access control
17
+ - **Operation Hooks** — before/after callbacks for each operation
18
+ - **Endpoint Control** — Hide or block specific operations
19
+ - **Request Context** — Automatic tenant/user context mapping
20
+ - **Global Exception Filter** — Safe JSON error responses
4
21
 
5
22
  ## Installation
6
23
 
@@ -12,74 +29,65 @@ npm install @zola_do/crud
12
29
  npm install @zola_do/nestjs-shared
13
30
  ```
14
31
 
15
- ## Dependencies
16
-
17
- - `@zola_do/collection-query`
18
- - `@zola_do/authorization`
19
- - Optional (only for RPC exception filter): `@nestjs/microservices`
20
-
21
- ## Recommended Imports
22
-
23
- `@zola_do/crud` root imports remain fully supported for backward compatibility.
24
- For new code, prefer focused subpath imports:
32
+ ### Dependencies
25
33
 
26
- ```typescript
27
- import { EntityCrudController } from '@zola_do/crud/controller';
28
- import { EntityCrudService } from '@zola_do/crud/service';
29
- import { EntityCrudRepository } from '@zola_do/crud/repository';
30
- import {
31
- CRUD_REQUEST_CONTEXT_RESOLVER,
32
- CrudRequestContextResolver,
33
- } from '@zola_do/crud/request-context';
34
+ ```bash
35
+ npm install @zola_do/collection-query @zola_do/authorization
34
36
  ```
35
37
 
36
- Optional RPC exception filter stays available at:
38
+ ### Optional Dependencies
37
39
 
38
- ```typescript
39
- import { ExceptionFilter } from '@zola_do/crud/optional/rpc';
40
+ ```bash
41
+ # For RPC exception filter
42
+ npm install @nestjs/microservices
40
43
  ```
41
44
 
42
- ## Usage
43
-
44
- ### Entity Setup
45
+ ## Quick Start
45
46
 
46
- Extend `CommonEntity` (or `Audit` for tenant-aware entities) and use with the CRUD factories:
47
+ ### 1. Create an Entity
47
48
 
48
49
  ```typescript
49
- import { BaseAudit } from './base-audit';
50
- import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
51
-
52
- @Entity('products')
53
- export class Product extends BaseAudit {
54
- @PrimaryGeneratedColumn('uuid')
50
+ import { CommonEntity } from "@zola_do/nestjs-shared";
51
+ import {
52
+ Entity,
53
+ Column,
54
+ PrimaryGeneratedColumn,
55
+ DeleteDateColumn,
56
+ } from "typeorm";
57
+
58
+ @Entity("products")
59
+ export class Product extends CommonEntity {
60
+ @PrimaryGeneratedColumn("uuid")
55
61
  id: string;
56
62
 
57
63
  @Column()
58
64
  name: string;
59
65
 
60
- @Column({ type: 'decimal', precision: 10, scale: 2 })
66
+ @Column({ type: "decimal", precision: 10, scale: 2 })
61
67
  price: number;
68
+
69
+ @Column({ default: "active" })
70
+ status: string;
71
+
72
+ @DeleteDateColumn()
73
+ deletedAt?: Date;
62
74
  }
63
75
  ```
64
76
 
65
- ### Controller and Service
66
-
67
- Create a CRUD controller and service for your entity:
77
+ ### 2. Create Controller
68
78
 
69
79
  ```typescript
70
- import { Controller } from '@nestjs/common';
71
- import {
72
- EntityCrudController,
73
- EntityCrudService,
74
- } from '@zola_do/crud';
75
- import { Product } from './product.entity';
80
+ import { Controller } from "@nestjs/common";
81
+ import { EntityCrudController } from "@zola_do/crud";
82
+ import { EntityCrudService } from "@zola_do/crud/service";
83
+ import { Product } from "./product.entity";
76
84
 
77
- @Controller('products')
85
+ @Controller("products")
78
86
  export class ProductsController extends EntityCrudController<Product>({
79
- createPermission: 'product:create',
80
- viewPermission: 'product:view',
81
- updatePermission: 'product:update',
82
- deletePermission: 'product:delete',
87
+ createPermission: "product:create",
88
+ viewPermission: "product:view",
89
+ updatePermission: "product:update",
90
+ deletePermission: "product:delete",
83
91
  }) {
84
92
  constructor(service: EntityCrudService<Product>) {
85
93
  super(service);
@@ -87,12 +95,14 @@ export class ProductsController extends EntityCrudController<Product>({
87
95
  }
88
96
  ```
89
97
 
98
+ ### 3. Create Module
99
+
90
100
  ```typescript
91
- import { Module } from '@nestjs/common';
92
- import { TypeOrmModule } from '@nestjs/typeorm';
93
- import { EntityCrudService } from '@zola_do/crud';
94
- import { Product } from './product.entity';
95
- import { ProductsController } from './products.controller';
101
+ import { Module } from "@nestjs/common";
102
+ import { TypeOrmModule } from "@nestjs/typeorm";
103
+ import { EntityCrudService } from "@zola_do/crud/service";
104
+ import { Product } from "./product.entity";
105
+ import { ProductsController } from "./products.controller";
96
106
 
97
107
  @Module({
98
108
  imports: [TypeOrmModule.forFeature([Product])],
@@ -102,144 +112,300 @@ import { ProductsController } from './products.controller';
102
112
  export class ProductsModule {}
103
113
  ```
104
114
 
105
- ### Available Endpoints
115
+ ## Available Endpoints
106
116
 
107
- - `POST /` — Create
108
- - `GET /` Find all (supports `?q=` collection query)
109
- - `GET /:id` Find one
110
- - `PUT /:id` Update
111
- - `DELETE /:id` Soft delete
117
+ | Method | Endpoint | Description |
118
+ | -------- | -------------- | -------------------------------------------- |
119
+ | `POST` | `/` | Create a new entity |
120
+ | `GET` | `/` | List all entities (with `?q=` query support) |
121
+ | `GET` | `/:id` | Get single entity by ID |
122
+ | `PUT` | `/:id` | Update entire entity |
123
+ | `PATCH` | `/:id` | Partial update |
124
+ | `DELETE` | `/:id` | Soft delete |
125
+ | `PATCH` | `/restore/:id` | Restore soft-deleted entity |
126
+ | `GET` | `/archived` | List archived entities |
112
127
 
113
- You can disable specific endpoints via the `operations` option (supports `hide` and `block`).
128
+ ## EntityCrudOptions
114
129
 
115
- ### EntityCrudOptions
130
+ Full configuration options for the CRUD controller:
116
131
 
117
132
  ```typescript
118
133
  import { EntityCrudOptions } from '@zola_do/crud';
119
134
 
120
135
  const options: EntityCrudOptions = {
136
+ // DTOs for validation
121
137
  createDto: CreateProductDto,
122
138
  updateDto: UpdateProductDto,
139
+
140
+ // Permissions for guards
123
141
  createPermission: 'product:create',
124
142
  viewPermission: 'product:view',
125
143
  updatePermission: 'product:update',
126
144
  deletePermission: 'product:delete',
127
145
  restorePermission: 'product:restore',
128
146
  viewArchivedPermission: 'product:view_archived',
147
+
148
+ // Context mapping (deprecated)
129
149
  mapCreateContext: ({ req, itemData }) => ({
130
- organizationId: req?.session?.org?.id,
131
- organizationName: req?.session?.org?.name,
132
- createdBy: req?.session?.user?.id,
150
+ organizationId: req?.user?.organizationId,
151
+ createdBy: req?.user?.id,
133
152
  }),
134
- // Optional per-endpoint disabling
153
+
154
+ // Write field restrictions
155
+ allowedWriteFields: ['name', 'price'],
156
+ blockedWriteFields: ['id', 'createdAt'],
157
+
158
+ // Operation hooks
159
+ beforeCreate: async ({ service, req, itemData }) => {
160
+ itemData.createdBy = req.user.id;
161
+ itemData.status ??= 'draft';
162
+ },
163
+ afterCreate: async ({ service, req, result }) => {
164
+ await service.sendNotification(result.id);
165
+ },
166
+
167
+ beforeFindAll: async ({ service, req, where }) => {
168
+ where.organizationId = req.user.organizationId;
169
+ },
170
+ afterFindAll: async ({ service, req, result }) => {
171
+ result.items = result.items.filter(i => i.isActive);
172
+ },
173
+
174
+ beforeFindOne: async ({ service, req, where, params }) => {
175
+ where.id = params.id;
176
+ },
177
+
178
+ beforeUpdate: async ({ service, req, itemData, params }) => {
179
+ itemData.updatedBy = req.user.id;
180
+ },
181
+ afterUpdate: async ({ service, req, result }) => {
182
+ await service.invalidateCache(result.id);
183
+ },
184
+
185
+ beforeDelete: async ({ service, req, params }) => {
186
+ await service.assertCanDelete(params.id);
187
+ },
188
+
189
+ beforeRestore: async ({ service, req, params }) => {
190
+ await service.assertCanRestore(params.id);
191
+ },
192
+
193
+ // Authorization callbacks
194
+ authorize: async ({ req, operation }) => {
195
+ return req.user.isAdmin;
196
+ },
197
+ canCreate: async ({ service, req }) => /* boolean */,
198
+ canFindAll: async ({ service, req }) => /* boolean */,
199
+ canFindOne: async ({ service, req, params }) => /* boolean */,
200
+ canUpdate: async ({ service, req, params }) => /* boolean */,
201
+ canDelete: async ({ service, req, params }) => /* boolean */,
202
+ canRestore: async ({ service, req, params }) => /* boolean */,
203
+
204
+ // Delete behavior
205
+ deleteMode: 'soft', // 'auto' | 'soft' | 'hard'
206
+
207
+ // Endpoint control
135
208
  operations: {
136
- create: 'hide',
137
- findAll: { mode: 'block', reason: 'listing is temporarily disabled' },
209
+ create: 'hide', // Hide from routes (404)
210
+ findAll: { mode: 'block', reason: 'maintenance' }, // Block with 403
211
+ findOne: false, // Shorthand for 'hide'
212
+ update: 'block',
213
+ delete: 'hide',
214
+ restore: 'hide',
215
+ findAllArchived: 'hide',
138
216
  },
139
217
  };
140
218
  ```
141
219
 
142
- ### Operation Hooks and Policy Callbacks
220
+ ## Operation Hooks
221
+
222
+ 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.
223
+
224
+ ### Hook Types
225
+
226
+ ```typescript
227
+ interface CrudHookContext {
228
+ req?: any; // Express request object
229
+ params?: any; // Route parameters
230
+ query?: any; // Query parameters
231
+ id?: string; // Entity ID (for single-entity operations)
232
+ itemData?: any; // Create/Update payload (mutable)
233
+ where?: any; // Query filter (mutable for reads)
234
+ entity?: any; // Existing entity (for updates)
235
+ result?: any; // Operation result
236
+ operation: string; // 'create' | 'findAll' | 'findOne' | 'update' | 'delete' | 'restore'
237
+ service?: any; // CRUD service instance
238
+ repository?: any; // TypeORM repository
239
+ setMeta?: (key: string, value: any) => void; // Cross-hook metadata
240
+ getMeta?: <T = any>(key: string) => T | undefined;
241
+ }
242
+ ```
243
+
244
+ ### Hook Examples
245
+
246
+ #### Before Create - Add Context Data
247
+
248
+ ```typescript
249
+ beforeCreate: async ({ service, req, itemData }) => {
250
+ const userId = req?.user?.sub ?? req?.user?.id;
251
+ const orgId = req?.user?.organizationId;
143
252
 
144
- `EntityCrudOptions` and `ExtraCrudOptions` support reusable per-operation hooks:
253
+ if (!userId || !orgId) {
254
+ throw new UnauthorizedException('User context required');
255
+ }
145
256
 
146
- - `beforeCreate` / `afterCreate`
147
- - `beforeFindAll` / `afterFindAll`
148
- - `beforeFindOne` / `afterFindOne`
149
- - `beforeUpdate` / `afterUpdate`
150
- - `beforeDelete` / `afterDelete` (alias hooks)
151
- - `beforeRestore` / `afterRestore`
257
+ itemData.createdBy = userId;
258
+ itemData.organizationId = orgId;
259
+ itemData.status ??= 'pending';
260
+ },
261
+ ```
152
262
 
153
- Delete hook compatibility is preserved:
263
+ #### Before FindAll - Filter by Tenant
154
264
 
155
- - Legacy: `beforeSoftDelete` / `afterSoftDelete`
156
- - New alias: `beforeDelete` / `afterDelete`
157
- - If both are configured, both run (legacy first, then alias)
265
+ ```typescript
266
+ beforeFindAll: async ({ service, req, where }) => {
267
+ where.organizationId = req.user.organizationId;
268
+ where.deletedAt = undefined; // Exclude soft-deleted
269
+ },
270
+ ```
158
271
 
159
- Hooks receive operation context with request and mutable payload/filter data:
272
+ #### Before Update - Validate and Modify
160
273
 
161
274
  ```typescript
162
- const options: EntityCrudOptions = {
163
- blockedWriteFields: ['eventId'],
275
+ beforeUpdate: async ({ service, req, itemData, params }) => {
276
+ const existing = await service.findOne(params.id);
277
+
278
+ if (existing.status === 'completed') {
279
+ throw new BadRequestException('Cannot update completed items');
280
+ }
164
281
 
282
+ itemData.updatedBy = req.user.id;
283
+ itemData.updatedAt = new Date();
284
+ },
285
+ ```
286
+
287
+ #### After Create - Side Effects
288
+
289
+ ```typescript
290
+ afterCreate: async ({ service, req, result }) => {
291
+ await service.sendWebhook('item.created', result);
292
+ await service.updateAnalytics(result);
293
+ },
294
+ ```
295
+
296
+ #### Using Metadata for Cross-Hook Communication
297
+
298
+ ```typescript
299
+ beforeFindAll: async ({ req, setMeta }) => {
300
+ setMeta('userId', req.user.id);
301
+ setMeta('orgId', req.user.organizationId);
302
+ },
303
+
304
+ afterFindAll: async ({ service, req, result, getMeta }) => {
305
+ const userId = getMeta('userId');
306
+ await service.trackSearch(userId, result.items.length);
307
+ },
308
+ ```
309
+
310
+ ## Authorization Callbacks
311
+
312
+ Operation-specific authorization allows fine-grained access control:
313
+
314
+ ```typescript
315
+ const options: EntityCrudOptions = {
316
+ // Global authorize (runs for all operations)
165
317
  authorize: async ({ req, operation }) => {
166
- if (!req?.user && operation !== 'findAll') return false;
318
+ if (!req.user) return false;
319
+ return true;
167
320
  },
168
321
 
169
- beforeCreate: async ({ req, params, itemData }) => {
170
- const userId = req?.user?.sub ?? req?.user?.id;
171
- const eventId = params?.eventId;
172
- if (!userId || !eventId) throw new UnauthorizedException();
322
+ // Per-operation authorization
323
+ canCreate: async ({ service, req }) => {
324
+ return req.user.permissions.includes("product:create");
325
+ },
173
326
 
174
- await policies.assertCanEditEvent(eventId, userId);
175
- itemData.eventId = eventId;
176
- itemData.status ??= 'pending';
327
+ canFindAll: async ({ service, req }) => {
328
+ return true; // Public read
177
329
  },
178
330
 
179
- beforeFindAll: async ({ req, params, where }) => {
180
- const userId = req?.user?.sub ?? req?.user?.id;
181
- await policies.assertCanAccessEvent(params?.eventId, userId);
182
- where.eventId = params?.eventId;
331
+ canUpdate: async ({ service, req, params }) => {
332
+ const entity = await service.findOne(params.id);
333
+ return entity.ownerId === req.user.id || req.user.isAdmin;
183
334
  },
184
335
 
185
- canUpdate: async ({ req, params }) => {
186
- const userId = req?.user?.sub ?? req?.user?.id;
187
- await policies.assertCanEditEvent(params?.eventId, userId);
188
- return true;
336
+ canDelete: async ({ service, req, params }) => {
337
+ const entity = await service.findOne(params.id);
338
+ return entity.ownerId === req.user.id;
339
+ },
340
+
341
+ canRestore: async ({ service, req, params }) => {
342
+ return req.user.isAdmin;
189
343
  },
190
344
  };
191
345
  ```
192
346
 
193
- Hook context includes:
347
+ ## Endpoint Disabling
348
+
349
+ Control which endpoints are available using the `operations` option:
350
+
351
+ ```typescript
352
+ operations: {
353
+ // 'hide' - Route not registered (404)
354
+ create: 'hide',
194
355
 
195
- - `req`, `params`, `query`
196
- - `operation`, `id`
197
- - `itemData` (mutable create/update payload)
198
- - `where` (mutable filter scope for read/list operations)
199
- - `service`, `repository`
200
- - `entity`, `result` (when available)
201
- - `setMeta(key, value)` / `getMeta(key)` for cross-hook metadata
356
+ // 'block' - Route registered but denied (403)
357
+ findAll: { mode: 'block', reason: 'Under maintenance' },
202
358
 
203
- ### Endpoint Disabling (`operations`)
359
+ // false - Same as 'hide'
360
+ findOne: false,
204
361
 
205
- For `EntityCrudController` and `ExtraCrudController`, `operations` supports these keys:
362
+ // 'block' with string shorthand
363
+ update: 'block',
206
364
 
207
- - `create`
208
- - `findAll`
209
- - `findOne`
210
- - `update`
211
- - `delete` (soft-delete)
212
- - `restore`
213
- - `findAllArchived`
365
+ // All available keys
366
+ delete: 'hide',
367
+ restore: 'hide',
368
+ findAllArchived: 'hide',
369
+ }
370
+ ```
214
371
 
215
- For `RelationCrudController`, `operations` supports these keys:
372
+ ### Operation Keys
216
373
 
217
- - `bulkSaveFirst`
218
- - `bulkSaveSecond`
219
- - `findAllFirst`
220
- - `findAllSecond`
374
+ | Key | Endpoint | Description |
375
+ | ----------------- | -------------------- | ----------------- |
376
+ | `create` | `POST /` | Create new entity |
377
+ | `findAll` | `GET /` | List entities |
378
+ | `findOne` | `GET /:id` | Get single entity |
379
+ | `update` | `PUT /:id` | Full update |
380
+ | `delete` | `DELETE /:id` | Soft delete |
381
+ | `restore` | `PATCH /restore/:id` | Restore entity |
382
+ | `findAllArchived` | `GET /archived` | List deleted |
221
383
 
222
- Each operation can be configured as:
384
+ ## Request Context Mapping
223
385
 
224
- - `'hide'` route is not registered (typically `404`)
225
- - `'block'` — route is registered but denied with a consistent `403`
386
+ The CRUD package supports automatic context injection for create operations:
226
387
 
227
- ### Request Context Mapping
388
+ ### Method 1: Per-CRUD Options
228
389
 
229
- `@zola_do/crud` supports three create-time mapping levels:
390
+ ```typescript
391
+ EntityCrudController<Product>({
392
+ mapCreateContext: ({ req, itemData }) => ({
393
+ organizationId: req?.user?.organizationId,
394
+ organizationName: req?.user?.organizationName,
395
+ createdBy: req?.user?.id,
396
+ }),
397
+ });
398
+ ```
230
399
 
231
- 1. `mapCreateContext` in `EntityCrudOptions` / `ExtraCrudOptions` (highest priority)
232
- 2. module-level resolver provider (`CRUD_REQUEST_CONTEXT_RESOLVER`)
233
- 3. legacy fallback to `req.user.organization` (for backward compatibility)
400
+ ### Method 2: Module-Level Resolver
234
401
 
235
402
  ```typescript
236
- import { Module } from '@nestjs/common';
237
403
  import {
238
404
  CRUD_REQUEST_CONTEXT_RESOLVER,
239
405
  CrudRequestContextResolver,
240
- } from '@zola_do/crud';
406
+ } from "@zola_do/crud/request-context";
241
407
 
242
- const crudRequestContextResolver: CrudRequestContextResolver = {
408
+ const resolver: CrudRequestContextResolver = {
243
409
  resolveCreateContext: ({ req }) => ({
244
410
  organizationId: req?.auth?.orgId,
245
411
  organizationName: req?.auth?.orgName,
@@ -251,28 +417,41 @@ const crudRequestContextResolver: CrudRequestContextResolver = {
251
417
  providers: [
252
418
  {
253
419
  provide: CRUD_REQUEST_CONTEXT_RESOLVER,
254
- useValue: crudRequestContextResolver,
420
+ useValue: resolver,
255
421
  },
256
422
  ],
257
423
  })
258
424
  export class ProductsModule {}
259
425
  ```
260
426
 
261
- ### Relation CRUD
427
+ ### Method 3: Legacy Fallback
262
428
 
263
- For many-to-many or relation management:
429
+ If no resolver is configured, the system falls back to `req.user.organization`:
264
430
 
265
431
  ```typescript
266
- import { RelationCrudController, RelationCrudService } from '@zola_do/crud';
432
+ // Automatically uses req.user.organization.organizationId
433
+ // and req.user.organization.organizationName
434
+ ```
435
+
436
+ ## Relation CRUD
267
437
 
268
- @Controller('products/:productId/categories')
438
+ For managing many-to-many relationships:
439
+
440
+ ```typescript
441
+ import { RelationCrudController, RelationCrudService } from "@zola_do/crud";
442
+
443
+ @Controller("products/:productId/categories")
269
444
  export class ProductCategoriesController extends RelationCrudController(
270
445
  {
271
- firstEntityIdName: 'productId',
272
- firstInclude: 'product',
273
- secondEntityIdName: 'categoryId',
274
- secondInclude: 'category',
275
- viewPermission: 'product:view',
446
+ firstEntityIdName: "productId",
447
+ firstInclude: "product",
448
+ secondEntityIdName: "categoryId",
449
+ secondInclude: "category",
450
+ viewPermission: "product:view",
451
+ operations: {
452
+ bulkSaveFirst: "hide",
453
+ findAllSecond: "hide",
454
+ },
276
455
  },
277
456
  ProductCategory,
278
457
  ) {
@@ -282,29 +461,299 @@ export class ProductCategoriesController extends RelationCrudController(
282
461
  }
283
462
  ```
284
463
 
285
- ## Exports
464
+ ### Relation CRUD Endpoints
286
465
 
287
- - **Controllers:** `EntityCrudController`, `RelationCrudController`, `ExtraCrudController`
288
- - **Services:** `EntityCrudService`, `RelationCrudService`, `ExtraCrudService`
289
- - **Repositories:** `EntityCrudRepository`, `RelationCrudRepository`, `ExtraCrudRepository`
290
- - **Context Mapping:** `CRUD_REQUEST_CONTEXT_RESOLVER`, `CrudRequestContextResolver`
291
- - **Subpath entrypoints:** `@zola_do/crud/controller`, `@zola_do/crud/service`, `@zola_do/crud/repository`, `@zola_do/crud/request-context`
292
- - **Root entrypoint:** `@zola_do/crud` (supported for backward compatibility)
466
+ | Method | Endpoint | Description |
467
+ | ------ | ------------------- | ------------------------------- |
468
+ | `POST` | `/bulk-save-first` | Assign multiple first entities |
469
+ | `POST` | `/bulk-save-second` | Assign multiple second entities |
470
+ | `GET` | `/first-entities` | List all first entities |
471
+ | `GET` | `/second-entities` | List all second entities |
293
472
 
294
- ### Optional Features
473
+ ## Extra CRUD
295
474
 
296
- `ExceptionFilter` for Nest microservices transport is available as an optional export:
475
+ Extended CRUD with parent entity scoping:
297
476
 
298
477
  ```typescript
299
- import { ExceptionFilter } from '@zola_do/crud/optional/rpc';
478
+ import { ExtraCrudController, ExtraCrudService } from "@zola_do/crud";
479
+
480
+ @Controller("events/:eventId/attendees")
481
+ export class EventAttendeesController extends ExtraCrudController(
482
+ {
483
+ entityIdName: "eventId",
484
+ createPermission: "attendee:create",
485
+ viewPermission: "attendee:view",
486
+ },
487
+ Attendee,
488
+ ) {
489
+ constructor(service: ExtraCrudService<Attendee>) {
490
+ super(service);
491
+ }
492
+ }
300
493
  ```
301
494
 
302
- When using this filter, install `@nestjs/microservices` in your app.
495
+ ## Global Exception Filter
496
+
497
+ The package includes a safe global exception filter:
498
+
499
+ ```typescript
500
+ import { GlobalExceptionFilter } from "@zola_do/crud";
501
+
502
+ // In your main.ts
503
+ app.useGlobalFilters(new GlobalExceptionFilter());
504
+ ```
505
+
506
+ ### Response Format
507
+
508
+ ```typescript
509
+ // Success response
510
+ {
511
+ "statusCode": 200,
512
+ "data": { ... }
513
+ }
514
+
515
+ // Error response (production)
516
+ {
517
+ "statusCode": 400,
518
+ "message": "Validation failed",
519
+ "error": "Bad Request",
520
+ "path": "/api/products",
521
+ "timestamp": "2024-01-01T00:00:00.000Z"
522
+ }
523
+ ```
524
+
525
+ **Note:** Raw exceptions and stack traces are never exposed to clients in production.
526
+
527
+ ## Delete Modes
528
+
529
+ | Mode | Behavior | Use Case |
530
+ | ------ | ------------------------------ | ------------------------------------------ |
531
+ | `auto` | Uses entity metadata (default) | Standard entities with `@DeleteDateColumn` |
532
+ | `soft` | Sets `deletedAt` timestamp | Audit trail required |
533
+ | `hard` | Physical delete | Data that can be safely removed |
534
+
535
+ ```typescript
536
+ // Force hard delete
537
+ EntityCrudController<Product>({
538
+ deleteMode: "hard",
539
+ beforeDelete: async ({ service, params }) => {
540
+ await service.cleanupRelatedData(params.id);
541
+ },
542
+ });
543
+ ```
544
+
545
+ ## Query String Support
546
+
547
+ List endpoints support collection query parameters via `?q=`:
548
+
549
+ ```bash
550
+ GET /products?q=w=status$eq$active&t=20&o=createdAt$desc&i=category
551
+ ```
552
+
553
+ See [@zola_do/collection-query](../collection-query) for full query syntax.
554
+
555
+ ## API Reference
556
+
557
+ ### Controllers
558
+
559
+ #### `EntityCrudController<T>(options: EntityCrudOptions)`
560
+
561
+ Generic CRUD controller factory.
562
+
563
+ ```typescript
564
+ class EntityCrudController<T> {
565
+ constructor(
566
+ protected readonly service: EntityCrudService<T>,
567
+ ) {}
568
+
569
+ // POST /
570
+ create(@Body() dto: any, @Request() req: any): Promise<T>
571
+
572
+ // GET /
573
+ findAll(
574
+ @Query('q') query: string,
575
+ @Request() req: any,
576
+ ): Promise<CollectionResult<T>>
577
+
578
+ // GET /:id
579
+ findOne(@Param('id') id: string, @Request() req: any): Promise<T>
580
+
581
+ // PUT /:id
582
+ update(
583
+ @Param('id') id: string,
584
+ @Body() dto: any,
585
+ @Request() req: any,
586
+ ): Promise<T>
587
+
588
+ // PATCH /:id
589
+ patch(
590
+ @Param('id') id: string,
591
+ @Body() dto: any,
592
+ @Request() req: any,
593
+ ): Promise<T>
594
+
595
+ // DELETE /:id
596
+ delete(@Param('id') id: string, @Request() req: any): Promise<void>
597
+
598
+ // PATCH /restore/:id
599
+ restore(@Param('id') id: string, @Request() req: any): Promise<T>
600
+
601
+ // GET /archived
602
+ findAllArchived(@Request() req: any): Promise<T[]>
603
+ }
604
+ ```
605
+
606
+ #### `ExtraCrudController<T>(options: ExtraCrudOptions, entity: new () => T)`
607
+
608
+ Extended CRUD with parent entity scope.
609
+
610
+ #### `RelationCrudController(options: RelationCrudOptions, entity: new () => any)`
611
+
612
+ Many-to-many relationship management.
613
+
614
+ ### Services
615
+
616
+ #### `EntityCrudService<T>`
617
+
618
+ Generic CRUD service with query builder.
619
+
620
+ ```typescript
621
+ class EntityCrudService<T> {
622
+ constructor(protected readonly repository: Repository<T>) {}
623
+
624
+ findAll(query: CollectionQuery): Promise<CollectionResult<T>>;
625
+ findOne(id: string): Promise<T>;
626
+ create(data: any): Promise<T>;
627
+ update(id: string, data: any): Promise<T>;
628
+ patch(id: string, data: any): Promise<T>;
629
+ delete(id: string): Promise<void>;
630
+ restore(id: string): Promise<T>;
631
+ }
632
+ ```
633
+
634
+ ### Repositories
635
+
636
+ #### `EntityCrudRepository<T>`
637
+
638
+ Repository with built-in query construction.
639
+
640
+ ## Recommended Imports
641
+
642
+ Subpath imports are recommended for tree-shaking:
643
+
644
+ ```typescript
645
+ import { EntityCrudController } from "@zola_do/crud/controller";
646
+ import { EntityCrudService } from "@zola_do/crud/service";
647
+ import { EntityCrudRepository } from "@zola_do/crud/repository";
648
+ import {
649
+ CRUD_REQUEST_CONTEXT_RESOLVER,
650
+ CrudRequestContextResolver,
651
+ } from "@zola_do/crud/request-context";
652
+ import { EntityCrudOptions, GlobalExceptionFilter } from "@zola_do/crud";
653
+ ```
654
+
655
+ Root import is supported for backward compatibility:
656
+
657
+ ```typescript
658
+ import {
659
+ EntityCrudController,
660
+ EntityCrudService,
661
+ EntityCrudOptions,
662
+ } from "@zola_do/crud";
663
+ ```
664
+
665
+ ## Optional Features
666
+
667
+ ### RPC Exception Filter
668
+
669
+ For NestJS microservices:
670
+
671
+ ```typescript
672
+ import { ExceptionFilter } from "@zola_do/crud/optional/rpc";
673
+
674
+ @Module({
675
+ providers: [
676
+ {
677
+ provide: APP_FILTER,
678
+ useClass: ExceptionFilter,
679
+ },
680
+ ],
681
+ })
682
+ export class OrdersModule {}
683
+ ```
684
+
685
+ Requires `@nestjs/microservices` peer dependency.
686
+
687
+ ## Troubleshooting
688
+
689
+ ### Q: How do I add custom methods to the CRUD service?
690
+
691
+ Extend the generic service:
692
+
693
+ ```typescript
694
+ @Injectable()
695
+ export class ProductService extends EntityCrudService<Product> {
696
+ constructor(
697
+ @InjectRepository(Product)
698
+ repository: Repository<Product>,
699
+ ) {
700
+ super(repository);
701
+ }
702
+
703
+ async findActive(): Promise<Product[]> {
704
+ return this.repository.find({ where: { status: "active" } });
705
+ }
706
+ }
707
+ ```
708
+
709
+ ### Q: How do I validate the DTO?
710
+
711
+ Use `class-validator` decorators:
712
+
713
+ ```typescript
714
+ import { IsString, IsNumber, IsEnum, IsOptional } from "class-validator";
715
+
716
+ export class CreateProductDto {
717
+ @IsString()
718
+ name: string;
719
+
720
+ @IsNumber()
721
+ price: number;
722
+
723
+ @IsEnum(["draft", "active", "archived"])
724
+ @IsOptional()
725
+ status?: string;
726
+ }
727
+ ```
728
+
729
+ ### Q: How do I handle file uploads?
730
+
731
+ Combine with `@nestjs/platform-express`:
732
+
733
+ ```typescript
734
+ // Add to your DTO
735
+ import { UploadedFile } from '@nestjs/common';
736
+
737
+ async create(
738
+ @Body() dto: CreateProductDto,
739
+ @UploadedFile() file: Express.Multer.File,
740
+ ) {
741
+ if (file) {
742
+ dto.imageUrl = await this.uploadService.upload(file);
743
+ }
744
+ return this.productService.create(dto);
745
+ }
746
+ ```
303
747
 
304
748
  ## Related Packages
305
749
 
306
750
  - [@zola_do/collection-query](../collection-query) — Query decoding for list endpoints
307
751
  - [@zola_do/authorization](../authorization) — Guards for protected CRUD
752
+ - [@zola_do/typeorm](../typeorm) — Entity configuration
753
+
754
+ ## License
755
+
756
+ ISC
308
757
 
309
758
  ## Community
310
759