@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.
- package/README.md +600 -151
- package/dist/controller/entity-crud.controller.d.ts +6 -0
- package/dist/controller/entity-crud.controller.js +78 -6
- package/dist/controller/entity-crud.controller.js.map +1 -1
- package/dist/service/entity-crud.service.d.ts +5 -1
- package/dist/service/entity-crud.service.js +116 -35
- package/dist/service/entity-crud.service.js.map +1 -1
- package/dist/service/extra-crud.service.js +2 -28
- package/dist/service/extra-crud.service.js.map +1 -1
- package/dist/service/relation-crud.service.js +0 -12
- package/dist/service/relation-crud.service.js.map +1 -1
- package/dist/shared/exceptions/global-exception.filter.js +32 -10
- package/dist/shared/exceptions/global-exception.filter.js.map +1 -1
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/tokens/crud-idempotency.token.d.ts +1 -0
- package/dist/shared/tokens/crud-idempotency.token.js +5 -0
- package/dist/shared/tokens/crud-idempotency.token.js.map +1 -0
- package/dist/shared/types/crud-idempotency-store.interface.d.ts +4 -0
- package/dist/shared/types/crud-idempotency-store.interface.js +3 -0
- package/dist/shared/types/crud-idempotency-store.interface.js.map +1 -0
- package/dist/shared/types/crud-option.type.d.ts +22 -2
- package/dist/shared/utils/collection-query-merge.d.ts +3 -0
- package/dist/shared/utils/collection-query-merge.js +19 -0
- package/dist/shared/utils/collection-query-merge.js.map +1 -0
- package/dist/shared/utils/crud-hooks.helper.d.ts +1 -1
- package/dist/shared/utils/crud-hooks.helper.js +22 -3
- package/dist/shared/utils/crud-hooks.helper.js.map +1 -1
- package/dist/shared/utils/in-memory-idempotency.store.d.ts +6 -0
- package/dist/shared/utils/in-memory-idempotency.store.js +24 -0
- package/dist/shared/utils/in-memory-idempotency.store.js.map +1 -0
- package/dist/shared/utils/index.d.ts +2 -0
- package/dist/shared/utils/index.js +2 -0
- package/dist/shared/utils/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# @zola_do/crud
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@zola_do/crud)
|
|
4
|
+
[](https://www.npmjs.com/package/@zola_do/crud)
|
|
5
|
+
[](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
|
-
|
|
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
|
-
```
|
|
27
|
-
|
|
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
|
|
38
|
+
### Optional Dependencies
|
|
37
39
|
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
+
```bash
|
|
41
|
+
# For RPC exception filter
|
|
42
|
+
npm install @nestjs/microservices
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
##
|
|
43
|
-
|
|
44
|
-
### Entity Setup
|
|
45
|
+
## Quick Start
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
### 1. Create an Entity
|
|
47
48
|
|
|
48
49
|
```typescript
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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:
|
|
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
|
-
###
|
|
66
|
-
|
|
67
|
-
Create a CRUD controller and service for your entity:
|
|
77
|
+
### 2. Create Controller
|
|
68
78
|
|
|
69
79
|
```typescript
|
|
70
|
-
import { Controller } from
|
|
71
|
-
import {
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
85
|
+
@Controller("products")
|
|
78
86
|
export class ProductsController extends EntityCrudController<Product>({
|
|
79
|
-
createPermission:
|
|
80
|
-
viewPermission:
|
|
81
|
-
updatePermission:
|
|
82
|
-
deletePermission:
|
|
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
|
|
92
|
-
import { TypeOrmModule } from
|
|
93
|
-
import { EntityCrudService } from
|
|
94
|
-
import { Product } from
|
|
95
|
-
import { ProductsController } from
|
|
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
|
-
|
|
115
|
+
## Available Endpoints
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
128
|
+
## EntityCrudOptions
|
|
114
129
|
|
|
115
|
-
|
|
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?.
|
|
131
|
-
|
|
132
|
-
createdBy: req?.session?.user?.id,
|
|
150
|
+
organizationId: req?.user?.organizationId,
|
|
151
|
+
createdBy: req?.user?.id,
|
|
133
152
|
}),
|
|
134
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
253
|
+
if (!userId || !orgId) {
|
|
254
|
+
throw new UnauthorizedException('User context required');
|
|
255
|
+
}
|
|
145
256
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
- `beforeRestore` / `afterRestore`
|
|
257
|
+
itemData.createdBy = userId;
|
|
258
|
+
itemData.organizationId = orgId;
|
|
259
|
+
itemData.status ??= 'pending';
|
|
260
|
+
},
|
|
261
|
+
```
|
|
152
262
|
|
|
153
|
-
|
|
263
|
+
#### Before FindAll - Filter by Tenant
|
|
154
264
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
272
|
+
#### Before Update - Validate and Modify
|
|
160
273
|
|
|
161
274
|
```typescript
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
318
|
+
if (!req.user) return false;
|
|
319
|
+
return true;
|
|
167
320
|
},
|
|
168
321
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
322
|
+
// Per-operation authorization
|
|
323
|
+
canCreate: async ({ service, req }) => {
|
|
324
|
+
return req.user.permissions.includes("product:create");
|
|
325
|
+
},
|
|
173
326
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
itemData.status ??= 'pending';
|
|
327
|
+
canFindAll: async ({ service, req }) => {
|
|
328
|
+
return true; // Public read
|
|
177
329
|
},
|
|
178
330
|
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
196
|
-
|
|
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
|
-
|
|
359
|
+
// false - Same as 'hide'
|
|
360
|
+
findOne: false,
|
|
204
361
|
|
|
205
|
-
|
|
362
|
+
// 'block' with string shorthand
|
|
363
|
+
update: 'block',
|
|
206
364
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
- `findAllArchived`
|
|
365
|
+
// All available keys
|
|
366
|
+
delete: 'hide',
|
|
367
|
+
restore: 'hide',
|
|
368
|
+
findAllArchived: 'hide',
|
|
369
|
+
}
|
|
370
|
+
```
|
|
214
371
|
|
|
215
|
-
|
|
372
|
+
### Operation Keys
|
|
216
373
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
384
|
+
## Request Context Mapping
|
|
223
385
|
|
|
224
|
-
|
|
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
|
-
###
|
|
388
|
+
### Method 1: Per-CRUD Options
|
|
228
389
|
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
406
|
+
} from "@zola_do/crud/request-context";
|
|
241
407
|
|
|
242
|
-
const
|
|
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:
|
|
420
|
+
useValue: resolver,
|
|
255
421
|
},
|
|
256
422
|
],
|
|
257
423
|
})
|
|
258
424
|
export class ProductsModule {}
|
|
259
425
|
```
|
|
260
426
|
|
|
261
|
-
###
|
|
427
|
+
### Method 3: Legacy Fallback
|
|
262
428
|
|
|
263
|
-
|
|
429
|
+
If no resolver is configured, the system falls back to `req.user.organization`:
|
|
264
430
|
|
|
265
431
|
```typescript
|
|
266
|
-
|
|
432
|
+
// Automatically uses req.user.organization.organizationId
|
|
433
|
+
// and req.user.organization.organizationName
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## Relation CRUD
|
|
267
437
|
|
|
268
|
-
|
|
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:
|
|
272
|
-
firstInclude:
|
|
273
|
-
secondEntityIdName:
|
|
274
|
-
secondInclude:
|
|
275
|
-
viewPermission:
|
|
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
|
-
|
|
464
|
+
### Relation CRUD Endpoints
|
|
286
465
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
473
|
+
## Extra CRUD
|
|
295
474
|
|
|
296
|
-
|
|
475
|
+
Extended CRUD with parent entity scoping:
|
|
297
476
|
|
|
298
477
|
```typescript
|
|
299
|
-
import {
|
|
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
|
-
|
|
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
|
|