@zola_do/crud 0.2.5 → 0.2.7

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 +599 -155
  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,149 +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.
160
- `service` is the injected CRUD service instance (for example, your subclass of
161
- `EntityCrudService`), so hook code can call custom service methods directly:
272
+ #### Before Update - Validate and Modify
162
273
 
163
274
  ```typescript
164
- const options: EntityCrudOptions = {
165
- 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
+ }
166
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)
167
317
  authorize: async ({ req, operation }) => {
168
- if (!req?.user && operation !== 'findAll') return false;
318
+ if (!req.user) return false;
319
+ return true;
169
320
  },
170
321
 
171
- beforeCreate: async ({ service, req, params, itemData }) => {
172
- const userId = req?.user?.sub ?? req?.user?.id;
173
- const eventId = params?.eventId;
174
- if (!userId || !eventId) throw new UnauthorizedException();
175
-
176
- await service.assertCanEditEvent(eventId, userId);
177
- itemData.eventId = eventId;
178
- itemData.status ??= 'pending';
322
+ // Per-operation authorization
323
+ canCreate: async ({ service, req }) => {
324
+ return req.user.permissions.includes("product:create");
179
325
  },
180
326
 
181
- beforeFindAll: async ({ service, req, params, where }) => {
182
- const userId = req?.user?.sub ?? req?.user?.id;
183
- await service.assertCanAccessEvent(params?.eventId, userId);
184
- where.eventId = params?.eventId;
327
+ canFindAll: async ({ service, req }) => {
328
+ return true; // Public read
185
329
  },
186
330
 
187
331
  canUpdate: async ({ service, req, params }) => {
188
- const userId = req?.user?.sub ?? req?.user?.id;
189
- await service.assertCanEditEvent(params?.eventId, userId);
190
- return true;
332
+ const entity = await service.findOne(params.id);
333
+ return entity.ownerId === req.user.id || req.user.isAdmin;
334
+ },
335
+
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;
191
343
  },
192
344
  };
193
345
  ```
194
346
 
195
- When using this pattern, define those methods in your app service class that
196
- extends `EntityCrudService<T>` (or `ExtraCrudService<T>`).
347
+ ## Endpoint Disabling
197
348
 
198
- Hook context includes:
349
+ Control which endpoints are available using the `operations` option:
199
350
 
200
- - `req`, `params`, `query`
201
- - `operation`, `id`
202
- - `itemData` (mutable create/update payload)
203
- - `where` (mutable filter scope for read/list operations)
204
- - `service`, `repository`
205
- - `entity`, `result` (when available)
206
- - `setMeta(key, value)` / `getMeta(key)` for cross-hook metadata
351
+ ```typescript
352
+ operations: {
353
+ // 'hide' - Route not registered (404)
354
+ create: 'hide',
207
355
 
208
- ### Endpoint Disabling (`operations`)
356
+ // 'block' - Route registered but denied (403)
357
+ findAll: { mode: 'block', reason: 'Under maintenance' },
209
358
 
210
- For `EntityCrudController` and `ExtraCrudController`, `operations` supports these keys:
359
+ // false - Same as 'hide'
360
+ findOne: false,
211
361
 
212
- - `create`
213
- - `findAll`
214
- - `findOne`
215
- - `update`
216
- - `delete` (soft-delete)
217
- - `restore`
218
- - `findAllArchived`
362
+ // 'block' with string shorthand
363
+ update: 'block',
219
364
 
220
- For `RelationCrudController`, `operations` supports these keys:
365
+ // All available keys
366
+ delete: 'hide',
367
+ restore: 'hide',
368
+ findAllArchived: 'hide',
369
+ }
370
+ ```
221
371
 
222
- - `bulkSaveFirst`
223
- - `bulkSaveSecond`
224
- - `findAllFirst`
225
- - `findAllSecond`
372
+ ### Operation Keys
226
373
 
227
- Each operation can be configured as:
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 |
228
383
 
229
- - `'hide'` route is not registered (typically `404`)
230
- - `'block'` — route is registered but denied with a consistent `403`
384
+ ## Request Context Mapping
231
385
 
232
- ### Request Context Mapping
386
+ The CRUD package supports automatic context injection for create operations:
233
387
 
234
- `@zola_do/crud` supports three create-time mapping levels:
388
+ ### Method 1: Per-CRUD Options
235
389
 
236
- 1. `mapCreateContext` in `EntityCrudOptions` / `ExtraCrudOptions` (highest priority)
237
- 2. module-level resolver provider (`CRUD_REQUEST_CONTEXT_RESOLVER`)
238
- 3. legacy fallback to `req.user.organization` (for backward compatibility)
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
+ ```
399
+
400
+ ### Method 2: Module-Level Resolver
239
401
 
240
402
  ```typescript
241
- import { Module } from '@nestjs/common';
242
403
  import {
243
404
  CRUD_REQUEST_CONTEXT_RESOLVER,
244
405
  CrudRequestContextResolver,
245
- } from '@zola_do/crud';
406
+ } from "@zola_do/crud/request-context";
246
407
 
247
- const crudRequestContextResolver: CrudRequestContextResolver = {
408
+ const resolver: CrudRequestContextResolver = {
248
409
  resolveCreateContext: ({ req }) => ({
249
410
  organizationId: req?.auth?.orgId,
250
411
  organizationName: req?.auth?.orgName,
@@ -256,28 +417,41 @@ const crudRequestContextResolver: CrudRequestContextResolver = {
256
417
  providers: [
257
418
  {
258
419
  provide: CRUD_REQUEST_CONTEXT_RESOLVER,
259
- useValue: crudRequestContextResolver,
420
+ useValue: resolver,
260
421
  },
261
422
  ],
262
423
  })
263
424
  export class ProductsModule {}
264
425
  ```
265
426
 
266
- ### Relation CRUD
427
+ ### Method 3: Legacy Fallback
267
428
 
268
- For many-to-many or relation management:
429
+ If no resolver is configured, the system falls back to `req.user.organization`:
269
430
 
270
431
  ```typescript
271
- 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
437
+
438
+ For managing many-to-many relationships:
272
439
 
273
- @Controller('products/:productId/categories')
440
+ ```typescript
441
+ import { RelationCrudController, RelationCrudService } from "@zola_do/crud";
442
+
443
+ @Controller("products/:productId/categories")
274
444
  export class ProductCategoriesController extends RelationCrudController(
275
445
  {
276
- firstEntityIdName: 'productId',
277
- firstInclude: 'product',
278
- secondEntityIdName: 'categoryId',
279
- secondInclude: 'category',
280
- 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
+ },
281
455
  },
282
456
  ProductCategory,
283
457
  ) {
@@ -287,29 +461,299 @@ export class ProductCategoriesController extends RelationCrudController(
287
461
  }
288
462
  ```
289
463
 
290
- ## Exports
464
+ ### Relation CRUD Endpoints
465
+
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 |
472
+
473
+ ## Extra CRUD
474
+
475
+ Extended CRUD with parent entity scoping:
476
+
477
+ ```typescript
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
+ }
493
+ ```
494
+
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
+ ```
291
664
 
292
- - **Controllers:** `EntityCrudController`, `RelationCrudController`, `ExtraCrudController`
293
- - **Services:** `EntityCrudService`, `RelationCrudService`, `ExtraCrudService`
294
- - **Repositories:** `EntityCrudRepository`, `RelationCrudRepository`, `ExtraCrudRepository`
295
- - **Context Mapping:** `CRUD_REQUEST_CONTEXT_RESOLVER`, `CrudRequestContextResolver`
296
- - **Subpath entrypoints:** `@zola_do/crud/controller`, `@zola_do/crud/service`, `@zola_do/crud/repository`, `@zola_do/crud/request-context`
297
- - **Root entrypoint:** `@zola_do/crud` (supported for backward compatibility)
665
+ ## Optional Features
298
666
 
299
- ### Optional Features
667
+ ### RPC Exception Filter
300
668
 
301
- `ExceptionFilter` for Nest microservices transport is available as an optional export:
669
+ For NestJS microservices:
302
670
 
303
671
  ```typescript
304
- import { ExceptionFilter } from '@zola_do/crud/optional/rpc';
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 {}
305
683
  ```
306
684
 
307
- When using this filter, install `@nestjs/microservices` in your app.
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
+ ```
308
747
 
309
748
  ## Related Packages
310
749
 
311
750
  - [@zola_do/collection-query](../collection-query) — Query decoding for list endpoints
312
751
  - [@zola_do/authorization](../authorization) — Guards for protected CRUD
752
+ - [@zola_do/typeorm](../typeorm) — Entity configuration
753
+
754
+ ## License
755
+
756
+ ISC
313
757
 
314
758
  ## Community
315
759