@zola_do/crud 0.2.5 → 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 +599 -155
- 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,149 +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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
318
|
+
if (!req.user) return false;
|
|
319
|
+
return true;
|
|
169
320
|
},
|
|
170
321
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
196
|
-
extends `EntityCrudService<T>` (or `ExtraCrudService<T>`).
|
|
347
|
+
## Endpoint Disabling
|
|
197
348
|
|
|
198
|
-
|
|
349
|
+
Control which endpoints are available using the `operations` option:
|
|
199
350
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
-
|
|
203
|
-
|
|
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
|
-
|
|
356
|
+
// 'block' - Route registered but denied (403)
|
|
357
|
+
findAll: { mode: 'block', reason: 'Under maintenance' },
|
|
209
358
|
|
|
210
|
-
|
|
359
|
+
// false - Same as 'hide'
|
|
360
|
+
findOne: false,
|
|
211
361
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
- `findOne`
|
|
215
|
-
- `update`
|
|
216
|
-
- `delete` (soft-delete)
|
|
217
|
-
- `restore`
|
|
218
|
-
- `findAllArchived`
|
|
362
|
+
// 'block' with string shorthand
|
|
363
|
+
update: 'block',
|
|
219
364
|
|
|
220
|
-
|
|
365
|
+
// All available keys
|
|
366
|
+
delete: 'hide',
|
|
367
|
+
restore: 'hide',
|
|
368
|
+
findAllArchived: 'hide',
|
|
369
|
+
}
|
|
370
|
+
```
|
|
221
371
|
|
|
222
|
-
|
|
223
|
-
- `bulkSaveSecond`
|
|
224
|
-
- `findAllFirst`
|
|
225
|
-
- `findAllSecond`
|
|
372
|
+
### Operation Keys
|
|
226
373
|
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
- `'block'` — route is registered but denied with a consistent `403`
|
|
384
|
+
## Request Context Mapping
|
|
231
385
|
|
|
232
|
-
|
|
386
|
+
The CRUD package supports automatic context injection for create operations:
|
|
233
387
|
|
|
234
|
-
|
|
388
|
+
### Method 1: Per-CRUD Options
|
|
235
389
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
406
|
+
} from "@zola_do/crud/request-context";
|
|
246
407
|
|
|
247
|
-
const
|
|
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:
|
|
420
|
+
useValue: resolver,
|
|
260
421
|
},
|
|
261
422
|
],
|
|
262
423
|
})
|
|
263
424
|
export class ProductsModule {}
|
|
264
425
|
```
|
|
265
426
|
|
|
266
|
-
###
|
|
427
|
+
### Method 3: Legacy Fallback
|
|
267
428
|
|
|
268
|
-
|
|
429
|
+
If no resolver is configured, the system falls back to `req.user.organization`:
|
|
269
430
|
|
|
270
431
|
```typescript
|
|
271
|
-
|
|
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
|
-
|
|
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:
|
|
277
|
-
firstInclude:
|
|
278
|
-
secondEntityIdName:
|
|
279
|
-
secondInclude:
|
|
280
|
-
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
|
+
},
|
|
281
455
|
},
|
|
282
456
|
ProductCategory,
|
|
283
457
|
) {
|
|
@@ -287,29 +461,299 @@ export class ProductCategoriesController extends RelationCrudController(
|
|
|
287
461
|
}
|
|
288
462
|
```
|
|
289
463
|
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
667
|
+
### RPC Exception Filter
|
|
300
668
|
|
|
301
|
-
|
|
669
|
+
For NestJS microservices:
|
|
302
670
|
|
|
303
671
|
```typescript
|
|
304
|
-
import { ExceptionFilter } from
|
|
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
|
-
|
|
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
|
|