crud-api-express 1.2.5 → 2.0.0
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/dist/index.cjs +500 -100
- package/dist/index.d.ts +206 -13
- package/dist/index.mjs +497 -100
- package/package.json +27 -18
- package/readme.md +322 -198
- package/src/index.ts +950 -175
- package/dist/index.js +0 -212
package/src/index.ts
CHANGED
|
@@ -1,23 +1,260 @@
|
|
|
1
1
|
import { Request, Response, Router, NextFunction } from 'express';
|
|
2
|
-
import { Document, Model,
|
|
2
|
+
import { Document, Model, Types } from 'mongoose';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
// ─── Exported Type Aliases ───────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/** Express middleware function signature. */
|
|
7
|
+
export type MiddlewareFunction = (req: Request, res: Response, next: NextFunction) => void;
|
|
8
|
+
|
|
9
|
+
/** Success callback invoked after a successful operation. */
|
|
10
|
+
export type SuccessHandler<T> = (
|
|
11
|
+
res: Response,
|
|
12
|
+
method: string,
|
|
13
|
+
result: T | T[] | any,
|
|
14
|
+
meta?: PaginationMeta
|
|
15
|
+
) => void;
|
|
16
|
+
|
|
17
|
+
/** Error callback invoked when an operation fails. */
|
|
18
|
+
export type ErrorHandler = (res: Response, method: string, error: Error) => void;
|
|
19
|
+
|
|
20
|
+
/** Validation result returned by validate hooks. */
|
|
21
|
+
export interface ValidationResult {
|
|
22
|
+
valid: boolean;
|
|
23
|
+
errors?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pagination metadata included in list responses. */
|
|
27
|
+
export interface PaginationMeta {
|
|
28
|
+
total: number;
|
|
29
|
+
page: number;
|
|
30
|
+
limit: number;
|
|
31
|
+
pages: number;
|
|
32
|
+
hasNext: boolean;
|
|
33
|
+
hasPrev: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Allowed HTTP methods for the CRUD controller. */
|
|
37
|
+
export type HttpMethod = 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE';
|
|
38
|
+
|
|
39
|
+
// ─── CrudOptions Interface ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Configuration options for CrudController.
|
|
43
|
+
*
|
|
44
|
+
* Every option is optional — the controller works with zero configuration,
|
|
45
|
+
* but each option unlocks additional flexibility.
|
|
46
|
+
*/
|
|
47
|
+
export interface CrudOptions<T extends Document> {
|
|
48
|
+
// ── Core ──────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* HTTP methods to enable. Defaults to all five.
|
|
52
|
+
* @default ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']
|
|
53
|
+
*/
|
|
54
|
+
methods?: HttpMethod[];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Global middleware applied to **every** generated route.
|
|
58
|
+
* For per-operation middleware, use `routeMiddleware` instead.
|
|
59
|
+
*/
|
|
60
|
+
middleware?: MiddlewareFunction[];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Per-operation middleware — lets you apply different middleware
|
|
64
|
+
* to different CRUD operations (e.g. only admins can delete).
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* routeMiddleware: {
|
|
68
|
+
* delete: [requireAdmin],
|
|
69
|
+
* create: [validateBody],
|
|
70
|
+
* }
|
|
71
|
+
*/
|
|
72
|
+
routeMiddleware?: {
|
|
73
|
+
create?: MiddlewareFunction[];
|
|
74
|
+
read?: MiddlewareFunction[];
|
|
75
|
+
update?: MiddlewareFunction[];
|
|
76
|
+
delete?: MiddlewareFunction[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ── Response Callbacks ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Custom success response handler.
|
|
83
|
+
* The 4th argument `meta` is provided on paginated list endpoints.
|
|
84
|
+
*/
|
|
85
|
+
onSuccess?: SuccessHandler<T>;
|
|
86
|
+
|
|
87
|
+
/** Custom error response handler. */
|
|
88
|
+
onError?: ErrorHandler;
|
|
89
|
+
|
|
90
|
+
// ── Lifecycle Hooks ───────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lifecycle hooks that run before/after each operation.
|
|
94
|
+
* `before*` hooks can transform data by returning a modified object.
|
|
95
|
+
* `after*` hooks are for side-effects (logging, events, notifications).
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* hooks: {
|
|
99
|
+
* beforeCreate: async (req, data) => {
|
|
100
|
+
* data.createdBy = req.user.id;
|
|
101
|
+
* return data;
|
|
102
|
+
* },
|
|
103
|
+
* afterDelete: async (req, result) => {
|
|
104
|
+
* await auditLog('delete', result._id);
|
|
105
|
+
* },
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
hooks?: {
|
|
109
|
+
beforeCreate?: (req: Request, data: any) => Promise<any> | any;
|
|
110
|
+
afterCreate?: (req: Request, result: T) => Promise<void> | void;
|
|
111
|
+
beforeUpdate?: (req: Request, id: string, data: any) => Promise<any> | any;
|
|
112
|
+
afterUpdate?: (req: Request, result: T) => Promise<void> | void;
|
|
113
|
+
beforeDelete?: (req: Request, id: string) => Promise<void> | void;
|
|
114
|
+
afterDelete?: (req: Request, result: T) => Promise<void> | void;
|
|
115
|
+
beforeRead?: (req: Request, query: any) => Promise<any> | any;
|
|
116
|
+
afterRead?: (req: Request, result: T | T[]) => Promise<T | T[]> | (T | T[]);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── Validation ────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Validation hooks that run **before** Mongoose validation.
|
|
123
|
+
* Return `{ valid: false, errors: [...] }` to reject early with a 400.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* validate: {
|
|
127
|
+
* create: (data) => ({
|
|
128
|
+
* valid: !!data.email,
|
|
129
|
+
* errors: data.email ? [] : ['Email is required'],
|
|
130
|
+
* }),
|
|
131
|
+
* }
|
|
132
|
+
*/
|
|
133
|
+
validate?: {
|
|
134
|
+
create?: (data: any) => ValidationResult;
|
|
135
|
+
update?: (data: any) => ValidationResult;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// ── Query Features ────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Default fields to return (Mongoose select syntax).
|
|
142
|
+
* Can be overridden per-request via `?select=name,email`.
|
|
143
|
+
* @example "name email -password"
|
|
144
|
+
*/
|
|
145
|
+
select?: string;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Auto-populate references on read operations.
|
|
149
|
+
* Can be overridden per-request via `?populate=author,comments`.
|
|
150
|
+
* @example "author" or ["author", { path: "comments", select: "text" }]
|
|
151
|
+
*/
|
|
152
|
+
populate?: string | object | (string | object)[];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fields to search across when using the `/search` endpoint.
|
|
156
|
+
* Uses case-insensitive `$regex` matching.
|
|
157
|
+
* @example ['name', 'email', 'description']
|
|
158
|
+
*/
|
|
159
|
+
searchFields?: string[];
|
|
160
|
+
|
|
161
|
+
// ── Soft Delete ───────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* When `true`, DELETE operations set `deletedAt: new Date()` instead of
|
|
165
|
+
* removing the document. GET operations auto-exclude soft-deleted records
|
|
166
|
+
* unless `?includeDeleted=true` is passed.
|
|
167
|
+
*
|
|
168
|
+
* A restore endpoint `PATCH /endpoint/:id/restore` is also created.
|
|
169
|
+
* @default false
|
|
170
|
+
*/
|
|
171
|
+
softDelete?: boolean;
|
|
172
|
+
|
|
173
|
+
// ── Aggregation ───────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* MongoDB aggregation pipeline stages, or a function that receives the
|
|
177
|
+
* request and returns pipeline stages (for dynamic pipelines).
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* // Static pipeline
|
|
181
|
+
* aggregatePipeline: [{ $match: { status: 'Active' } }]
|
|
182
|
+
*
|
|
183
|
+
* // Dynamic pipeline
|
|
184
|
+
* aggregatePipeline: (req) => [
|
|
185
|
+
* { $match: { region: req.query.region } },
|
|
186
|
+
* ]
|
|
187
|
+
*/
|
|
188
|
+
aggregatePipeline?: object[] | ((req: Request) => object[]);
|
|
189
|
+
|
|
190
|
+
// ── Related Model ─────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Related Mongoose model for cascading operations. */
|
|
9
193
|
relatedModel?: Model<any>;
|
|
194
|
+
|
|
195
|
+
/** Field name linking the related model to this model. */
|
|
10
196
|
relatedField?: string;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
197
|
+
|
|
198
|
+
/** HTTP methods to cascade to the related model. */
|
|
199
|
+
relatedMethods?: HttpMethod[];
|
|
200
|
+
|
|
201
|
+
// ── Custom Routes ─────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Additional custom routes beyond standard CRUD.
|
|
205
|
+
* Custom routes are **always** registered regardless of `methods` filter.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* customRoutes: [{
|
|
209
|
+
* method: 'get',
|
|
210
|
+
* path: '/stats',
|
|
211
|
+
* handler: async (req, res) => {
|
|
212
|
+
* const count = await Model.countDocuments();
|
|
213
|
+
* res.json({ count });
|
|
214
|
+
* },
|
|
215
|
+
* }]
|
|
216
|
+
*/
|
|
217
|
+
customRoutes?: {
|
|
218
|
+
method: 'post' | 'get' | 'put' | 'patch' | 'delete';
|
|
219
|
+
path: string;
|
|
220
|
+
middleware?: MiddlewareFunction[];
|
|
221
|
+
handler: (req: Request, res: Response) => void;
|
|
222
|
+
}[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Route Info ─────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export interface RouteInfo {
|
|
228
|
+
method: string;
|
|
229
|
+
path: string;
|
|
230
|
+
params?: string[];
|
|
14
231
|
}
|
|
15
232
|
|
|
233
|
+
// ─── CrudController Class ───────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A powerful, flexible CRUD Controller for Express + Mongoose.
|
|
237
|
+
*
|
|
238
|
+
* Automatically generates RESTful endpoints for any Mongoose model with
|
|
239
|
+
* support for lifecycle hooks, validation, soft delete, bulk operations,
|
|
240
|
+
* search, field selection, population, and more.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* const ctrl = new CrudController(UserModel, 'users', {
|
|
244
|
+
* methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
|
245
|
+
* softDelete: true,
|
|
246
|
+
* searchFields: ['name', 'email'],
|
|
247
|
+
* hooks: {
|
|
248
|
+
* beforeCreate: (req, data) => ({ ...data, createdBy: req.user.id }),
|
|
249
|
+
* },
|
|
250
|
+
* });
|
|
251
|
+
* app.use('/api', ctrl.getRouter());
|
|
252
|
+
*/
|
|
16
253
|
class CrudController<T extends Document> {
|
|
17
254
|
private model: Model<T>;
|
|
18
255
|
private endpoint: string;
|
|
19
256
|
private router: Router;
|
|
20
|
-
private routes:
|
|
257
|
+
private routes: RouteInfo[];
|
|
21
258
|
|
|
22
259
|
constructor(model: Model<T>, endpoint: string, options: CrudOptions<T> = {}) {
|
|
23
260
|
this.model = model;
|
|
@@ -27,214 +264,752 @@ class CrudController<T extends Document> {
|
|
|
27
264
|
this.configureRoutes(options);
|
|
28
265
|
}
|
|
29
266
|
|
|
30
|
-
|
|
267
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Merges global middleware + per-operation middleware + handler into a single array.
|
|
271
|
+
*/
|
|
272
|
+
private buildMiddlewareChain(
|
|
273
|
+
handler: (req: Request, res: Response) => void,
|
|
274
|
+
globalMiddleware: MiddlewareFunction[],
|
|
275
|
+
operationMiddleware?: MiddlewareFunction[]
|
|
276
|
+
): (MiddlewareFunction | ((req: Request, res: Response) => void))[] {
|
|
277
|
+
return [...globalMiddleware, ...(operationMiddleware || []), handler];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Records a route in the internal registry. */
|
|
281
|
+
private registerRoute(method: string, path: string, params?: string[]): void {
|
|
282
|
+
this.routes.push({ method, path, params });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Parses a `?select=name,email` query param into Mongoose select syntax. */
|
|
286
|
+
private parseSelect(querySelect: string | undefined, defaultSelect?: string): string | undefined {
|
|
287
|
+
if (querySelect) return (querySelect as string).split(',').join(' ');
|
|
288
|
+
return defaultSelect;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Parses a `?populate=author,comments` query param. */
|
|
292
|
+
private parsePopulate(
|
|
293
|
+
queryPopulate: string | undefined,
|
|
294
|
+
defaultPopulate?: string | object | (string | object)[]
|
|
295
|
+
): any {
|
|
296
|
+
if (queryPopulate) {
|
|
297
|
+
return (queryPopulate as string).split(',').map((p) => p.trim());
|
|
298
|
+
}
|
|
299
|
+
return defaultPopulate;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Safely parses JSON from a query param, returns fallback on failure. */
|
|
303
|
+
private safeJsonParse(value: string | undefined, fallback: any = {}): any {
|
|
304
|
+
if (!value) return fallback;
|
|
305
|
+
try {
|
|
306
|
+
return JSON.parse(value);
|
|
307
|
+
} catch {
|
|
308
|
+
return fallback;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Builds the soft-delete filter to exclude deleted records. */
|
|
313
|
+
private softDeleteFilter(req: Request, softDelete: boolean): object {
|
|
314
|
+
if (!softDelete) return {};
|
|
315
|
+
const includeDeleted = req.query.includeDeleted === 'true';
|
|
316
|
+
return includeDeleted ? {} : { deletedAt: { $exists: false } };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Route Configuration ────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
private configureRoutes(options: CrudOptions<T>): void {
|
|
31
322
|
const {
|
|
32
323
|
middleware = [],
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
324
|
+
routeMiddleware = {},
|
|
325
|
+
onSuccess = (res, method, result, meta) => {
|
|
326
|
+
if (meta) {
|
|
327
|
+
res.status(200).json({ data: result, pagination: meta });
|
|
328
|
+
} else {
|
|
329
|
+
res.status(200).json(result);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
onError = (res, method, error) => res.status(400).json({ error: error.message }),
|
|
333
|
+
methods = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'] as HttpMethod[],
|
|
334
|
+
hooks = {},
|
|
335
|
+
validate = {},
|
|
336
|
+
softDelete = false,
|
|
337
|
+
select: defaultSelect,
|
|
338
|
+
populate: defaultPopulate,
|
|
339
|
+
searchFields = [],
|
|
36
340
|
} = options;
|
|
37
341
|
|
|
38
|
-
|
|
39
|
-
return [...middleware, routeHandler];
|
|
40
|
-
};
|
|
342
|
+
// ── CREATE ─ POST /endpoint ─────────────────────────────────────────
|
|
41
343
|
|
|
42
|
-
// Helper to register a route
|
|
43
|
-
const registerRoute = (method: string, path: string, params?: string[]) => {
|
|
44
|
-
this.routes.push({ method, path, params });
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
// Create
|
|
48
344
|
if (methods.includes('POST')) {
|
|
49
345
|
const path = `/${this.endpoint}`;
|
|
50
|
-
this.router.post(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
346
|
+
this.router.post(
|
|
347
|
+
path,
|
|
348
|
+
...this.buildMiddlewareChain(
|
|
349
|
+
async (req: Request, res: Response) => {
|
|
350
|
+
try {
|
|
351
|
+
// Validation hook
|
|
352
|
+
if (validate.create) {
|
|
353
|
+
const validation = validate.create(req.body);
|
|
354
|
+
if (!validation.valid) {
|
|
355
|
+
return res.status(400).json({ errors: validation.errors });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Before hook
|
|
359
|
+
let data = req.body;
|
|
360
|
+
if (hooks.beforeCreate) {
|
|
361
|
+
data = (await hooks.beforeCreate(req, data)) ?? data;
|
|
362
|
+
}
|
|
363
|
+
const result: any = await this.model.create(data);
|
|
364
|
+
// Related model cascade
|
|
365
|
+
if (options.relatedModel && options.relatedMethods?.includes('POST')) {
|
|
366
|
+
await options.relatedModel.create({
|
|
367
|
+
[options.relatedField!]: result._id,
|
|
368
|
+
...data,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
// After hook
|
|
372
|
+
if (hooks.afterCreate) {
|
|
373
|
+
await hooks.afterCreate(req, result);
|
|
374
|
+
}
|
|
375
|
+
onSuccess(res.status(201), 'POST', result);
|
|
376
|
+
} catch (error: any) {
|
|
377
|
+
onError(res, 'POST', error);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
middleware,
|
|
381
|
+
routeMiddleware.create
|
|
382
|
+
)
|
|
383
|
+
);
|
|
384
|
+
this.registerRoute('POST', path);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── BULK CREATE ─ POST /endpoint/bulk ───────────────────────────────
|
|
388
|
+
|
|
389
|
+
if (methods.includes('POST')) {
|
|
390
|
+
const path = `/${this.endpoint}/bulk`;
|
|
391
|
+
this.router.post(
|
|
392
|
+
path,
|
|
393
|
+
...this.buildMiddlewareChain(
|
|
394
|
+
async (req: Request, res: Response) => {
|
|
395
|
+
try {
|
|
396
|
+
const items = req.body;
|
|
397
|
+
if (!Array.isArray(items)) {
|
|
398
|
+
return res.status(400).json({ error: 'Request body must be an array' });
|
|
399
|
+
}
|
|
400
|
+
// Validate each item
|
|
401
|
+
if (validate.create) {
|
|
402
|
+
for (let i = 0; i < items.length; i++) {
|
|
403
|
+
const validation = validate.create(items[i]);
|
|
404
|
+
if (!validation.valid) {
|
|
405
|
+
return res.status(400).json({
|
|
406
|
+
error: `Validation failed for item at index ${i}`,
|
|
407
|
+
errors: validation.errors,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Before hooks
|
|
413
|
+
let processedItems = items;
|
|
414
|
+
if (hooks.beforeCreate) {
|
|
415
|
+
processedItems = [];
|
|
416
|
+
for (const item of items) {
|
|
417
|
+
const result = (await hooks.beforeCreate(req, item)) ?? item;
|
|
418
|
+
processedItems.push(result);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const results = await this.model.insertMany(processedItems);
|
|
422
|
+
onSuccess(res.status(201), 'POST (Bulk)', results);
|
|
423
|
+
} catch (error: any) {
|
|
424
|
+
onError(res, 'POST (Bulk)', error);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
middleware,
|
|
428
|
+
routeMiddleware.create
|
|
429
|
+
)
|
|
430
|
+
);
|
|
431
|
+
this.registerRoute('POST', path);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── SEARCH ─ GET /endpoint/search ───────────────────────────────────
|
|
435
|
+
|
|
436
|
+
if (methods.includes('GET') && searchFields.length > 0) {
|
|
437
|
+
const path = `/${this.endpoint}/search`;
|
|
438
|
+
this.router.get(
|
|
439
|
+
path,
|
|
440
|
+
...this.buildMiddlewareChain(
|
|
441
|
+
async (req: Request, res: Response) => {
|
|
442
|
+
try {
|
|
443
|
+
const q = req.query.q as string;
|
|
444
|
+
if (!q) {
|
|
445
|
+
return res.status(400).json({ error: 'Query parameter "q" is required' });
|
|
446
|
+
}
|
|
447
|
+
const fieldsParam = req.query.fields as string | undefined;
|
|
448
|
+
const fields = fieldsParam ? fieldsParam.split(',').map((f) => f.trim()) : searchFields;
|
|
449
|
+
|
|
450
|
+
const searchQuery: any = {
|
|
451
|
+
$or: fields.map((field) => ({
|
|
452
|
+
[field]: { $regex: q, $options: 'i' },
|
|
453
|
+
})),
|
|
454
|
+
...this.softDeleteFilter(req, softDelete),
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const pageNumber = parseInt(req.query.page as string, 10) || 1;
|
|
458
|
+
const pageSize = parseInt(req.query.limit as string, 10) || 10;
|
|
459
|
+
const skip = (pageNumber - 1) * pageSize;
|
|
460
|
+
|
|
461
|
+
const selectStr = this.parseSelect(req.query.select as string, defaultSelect);
|
|
462
|
+
const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate);
|
|
463
|
+
|
|
464
|
+
let query = this.model.find(searchQuery).skip(skip).limit(pageSize);
|
|
465
|
+
if (selectStr) query = query.select(selectStr);
|
|
466
|
+
if (populateOpt) query = query.populate(populateOpt);
|
|
467
|
+
|
|
468
|
+
const [items, total] = await Promise.all([
|
|
469
|
+
query.exec(),
|
|
470
|
+
this.model.countDocuments(searchQuery),
|
|
471
|
+
]);
|
|
472
|
+
|
|
473
|
+
const meta: PaginationMeta = {
|
|
474
|
+
total,
|
|
475
|
+
page: pageNumber,
|
|
476
|
+
limit: pageSize,
|
|
477
|
+
pages: Math.ceil(total / pageSize),
|
|
478
|
+
hasNext: pageNumber * pageSize < total,
|
|
479
|
+
hasPrev: pageNumber > 1,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
let result: any = items;
|
|
483
|
+
if (hooks.afterRead) {
|
|
484
|
+
result = (await hooks.afterRead(req, items as any)) ?? items;
|
|
485
|
+
}
|
|
486
|
+
onSuccess(res, 'GET (Search)', result, meta);
|
|
487
|
+
} catch (error: any) {
|
|
488
|
+
onError(res, 'GET (Search)', error);
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
middleware,
|
|
492
|
+
routeMiddleware.read
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
this.registerRoute('GET', path, ['q', 'fields', 'page', 'limit', 'select', 'populate']);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── COUNT ─ GET /endpoint/count ─────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
if (methods.includes('GET')) {
|
|
501
|
+
const path = `/${this.endpoint}/count`;
|
|
502
|
+
this.router.get(
|
|
503
|
+
path,
|
|
504
|
+
...this.buildMiddlewareChain(
|
|
505
|
+
async (req: Request, res: Response) => {
|
|
506
|
+
try {
|
|
507
|
+
const filter = {
|
|
508
|
+
...this.safeJsonParse(req.query.filter as string),
|
|
509
|
+
...this.softDeleteFilter(req, softDelete),
|
|
510
|
+
};
|
|
511
|
+
const count = await this.model.countDocuments(filter);
|
|
512
|
+
onSuccess(res, 'GET (Count)', { count });
|
|
513
|
+
} catch (error: any) {
|
|
514
|
+
onError(res, 'GET (Count)', error);
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
middleware,
|
|
518
|
+
routeMiddleware.read
|
|
519
|
+
)
|
|
520
|
+
);
|
|
521
|
+
this.registerRoute('GET', path, ['filter']);
|
|
63
522
|
}
|
|
64
523
|
|
|
65
|
-
//
|
|
524
|
+
// ── AGGREGATE ─ GET /endpoint/aggregate ─────────────────────────────
|
|
525
|
+
|
|
526
|
+
if (methods.includes('GET') && options.aggregatePipeline) {
|
|
527
|
+
const path = `/${this.endpoint}/aggregate`;
|
|
528
|
+
this.router.get(
|
|
529
|
+
path,
|
|
530
|
+
...this.buildMiddlewareChain(
|
|
531
|
+
async (req: Request, res: Response) => {
|
|
532
|
+
try {
|
|
533
|
+
const pipeline: any[] =
|
|
534
|
+
typeof options.aggregatePipeline === 'function'
|
|
535
|
+
? options.aggregatePipeline(req)
|
|
536
|
+
: options.aggregatePipeline || [];
|
|
537
|
+
const results = await this.model.aggregate(pipeline);
|
|
538
|
+
onSuccess(res, 'GET (Aggregate)', results);
|
|
539
|
+
} catch (error: any) {
|
|
540
|
+
onError(res, 'GET (Aggregate)', error);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
middleware,
|
|
544
|
+
routeMiddleware.read
|
|
545
|
+
)
|
|
546
|
+
);
|
|
547
|
+
this.registerRoute('GET', path);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── EXISTS ─ GET /endpoint/exists/:id ───────────────────────────────
|
|
551
|
+
|
|
552
|
+
if (methods.includes('GET')) {
|
|
553
|
+
const path = `/${this.endpoint}/exists/:id`;
|
|
554
|
+
this.router.get(
|
|
555
|
+
path,
|
|
556
|
+
...this.buildMiddlewareChain(
|
|
557
|
+
async (req: Request, res: Response) => {
|
|
558
|
+
try {
|
|
559
|
+
const filter: any = { _id: req.params.id };
|
|
560
|
+
if (softDelete) filter.deletedAt = { $exists: false };
|
|
561
|
+
const exists = await this.model.exists(filter);
|
|
562
|
+
onSuccess(res, 'GET (Exists)', { exists: !!exists });
|
|
563
|
+
} catch (error: any) {
|
|
564
|
+
onError(res, 'GET (Exists)', error);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
middleware,
|
|
568
|
+
routeMiddleware.read
|
|
569
|
+
)
|
|
570
|
+
);
|
|
571
|
+
this.registerRoute('GET', path, ['id']);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── READ ALL ─ GET /endpoint ────────────────────────────────────────
|
|
575
|
+
|
|
66
576
|
if (methods.includes('GET')) {
|
|
67
577
|
const path = `/${this.endpoint}`;
|
|
68
|
-
this.router.get(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
578
|
+
this.router.get(
|
|
579
|
+
path,
|
|
580
|
+
...this.buildMiddlewareChain(
|
|
581
|
+
async (req: Request, res: Response) => {
|
|
582
|
+
try {
|
|
583
|
+
let filter = {
|
|
584
|
+
...this.safeJsonParse(req.query.filter as string),
|
|
585
|
+
...this.softDeleteFilter(req, softDelete),
|
|
586
|
+
};
|
|
587
|
+
const sortOrder = this.safeJsonParse(req.query.sort as string);
|
|
588
|
+
const pageNumber = parseInt(req.query.page as string, 10) || 1;
|
|
589
|
+
const pageSize = parseInt(req.query.limit as string, 10) || 10;
|
|
590
|
+
const skip = (pageNumber - 1) * pageSize;
|
|
591
|
+
const selectStr = this.parseSelect(req.query.select as string, defaultSelect);
|
|
592
|
+
const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate);
|
|
593
|
+
|
|
594
|
+
// Before read hook
|
|
595
|
+
if (hooks.beforeRead) {
|
|
596
|
+
filter = (await hooks.beforeRead(req, filter)) ?? filter;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let items: T[];
|
|
600
|
+
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
|
|
601
|
+
items = await this.model.aggregate([
|
|
602
|
+
{ $match: filter },
|
|
603
|
+
{
|
|
604
|
+
$lookup: {
|
|
605
|
+
from: options.relatedModel.collection.name,
|
|
606
|
+
localField: options.relatedField!,
|
|
607
|
+
foreignField: '_id',
|
|
608
|
+
as: 'relatedData',
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
{ $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } },
|
|
612
|
+
{ $skip: skip },
|
|
613
|
+
{ $limit: pageSize },
|
|
614
|
+
]);
|
|
615
|
+
} else {
|
|
616
|
+
let query = this.model
|
|
617
|
+
.find(filter)
|
|
618
|
+
.sort(Object.keys(sortOrder).length ? sortOrder : undefined)
|
|
619
|
+
.skip(skip)
|
|
620
|
+
.limit(pageSize);
|
|
621
|
+
if (selectStr) query = query.select(selectStr);
|
|
622
|
+
if (populateOpt) query = query.populate(populateOpt);
|
|
623
|
+
items = await query.exec();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const total = await this.model.countDocuments(filter);
|
|
627
|
+
const meta: PaginationMeta = {
|
|
628
|
+
total,
|
|
629
|
+
page: pageNumber,
|
|
630
|
+
limit: pageSize,
|
|
631
|
+
pages: Math.ceil(total / pageSize),
|
|
632
|
+
hasNext: pageNumber * pageSize < total,
|
|
633
|
+
hasPrev: pageNumber > 1,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
let result: T | T[] = items;
|
|
637
|
+
if (hooks.afterRead) {
|
|
638
|
+
result = (await hooks.afterRead(req, items)) ?? items;
|
|
639
|
+
}
|
|
640
|
+
onSuccess(res, 'GET', result, meta);
|
|
641
|
+
} catch (error: any) {
|
|
642
|
+
onError(res, 'GET', error);
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
middleware,
|
|
646
|
+
routeMiddleware.read
|
|
647
|
+
)
|
|
648
|
+
);
|
|
649
|
+
this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']);
|
|
103
650
|
}
|
|
104
651
|
|
|
105
|
-
//
|
|
652
|
+
// ── READ ONE ─ GET /endpoint/:id ────────────────────────────────────
|
|
653
|
+
|
|
106
654
|
if (methods.includes('GET')) {
|
|
107
655
|
const path = `/${this.endpoint}/:id`;
|
|
108
|
-
this.router.get(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
656
|
+
this.router.get(
|
|
657
|
+
path,
|
|
658
|
+
...this.buildMiddlewareChain(
|
|
659
|
+
async (req: Request, res: Response) => {
|
|
660
|
+
try {
|
|
661
|
+
const selectStr = this.parseSelect(req.query.select as string, defaultSelect);
|
|
662
|
+
const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate);
|
|
663
|
+
|
|
664
|
+
let item: T | null;
|
|
665
|
+
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
|
|
666
|
+
const matchFilter: any = { _id: new Types.ObjectId(req.params.id) };
|
|
667
|
+
if (softDelete) matchFilter.deletedAt = { $exists: false };
|
|
668
|
+
const aggregateResult = await this.model.aggregate([
|
|
669
|
+
{ $match: matchFilter },
|
|
670
|
+
{
|
|
671
|
+
$lookup: {
|
|
672
|
+
from: options.relatedModel.collection.name,
|
|
673
|
+
localField: options.relatedField!,
|
|
674
|
+
foreignField: '_id',
|
|
675
|
+
as: 'relatedData',
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
]);
|
|
679
|
+
item = (aggregateResult[0] as T) || null;
|
|
680
|
+
} else {
|
|
681
|
+
const findFilter: any = { _id: req.params.id };
|
|
682
|
+
if (softDelete) findFilter.deletedAt = { $exists: false };
|
|
683
|
+
let query = this.model.findOne(findFilter);
|
|
684
|
+
if (selectStr) query = query.select(selectStr);
|
|
685
|
+
if (populateOpt) query = query.populate(populateOpt);
|
|
686
|
+
item = await query.exec();
|
|
122
687
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
688
|
+
|
|
689
|
+
if (!item) {
|
|
690
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
let result: T | T[] = item;
|
|
694
|
+
if (hooks.afterRead) {
|
|
695
|
+
result = (await hooks.afterRead(req, item)) ?? item;
|
|
696
|
+
}
|
|
697
|
+
onSuccess(res, 'GET', result);
|
|
698
|
+
} catch (error: any) {
|
|
699
|
+
onError(res, 'GET', error);
|
|
700
|
+
}
|
|
701
|
+
},
|
|
702
|
+
middleware,
|
|
703
|
+
routeMiddleware.read
|
|
704
|
+
)
|
|
705
|
+
);
|
|
706
|
+
this.registerRoute('GET', path, ['id', 'select', 'populate']);
|
|
137
707
|
}
|
|
138
708
|
|
|
139
|
-
//
|
|
709
|
+
// ── UPDATE (FULL) ─ PUT /endpoint/:id ───────────────────────────────
|
|
710
|
+
|
|
140
711
|
if (methods.includes('PUT')) {
|
|
141
712
|
const path = `/${this.endpoint}/:id`;
|
|
142
|
-
this.router.put(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
713
|
+
this.router.put(
|
|
714
|
+
path,
|
|
715
|
+
...this.buildMiddlewareChain(
|
|
716
|
+
async (req: Request, res: Response) => {
|
|
717
|
+
try {
|
|
718
|
+
if (validate.update) {
|
|
719
|
+
const validation = validate.update(req.body);
|
|
720
|
+
if (!validation.valid) {
|
|
721
|
+
return res.status(400).json({ errors: validation.errors });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
let data = req.body;
|
|
725
|
+
if (hooks.beforeUpdate) {
|
|
726
|
+
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
|
|
727
|
+
}
|
|
728
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, data, {
|
|
729
|
+
new: true,
|
|
730
|
+
runValidators: true,
|
|
731
|
+
});
|
|
732
|
+
if (!item) {
|
|
733
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
734
|
+
}
|
|
735
|
+
if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
|
|
736
|
+
await options.relatedModel.updateMany(
|
|
737
|
+
{ [options.relatedField!]: item._id },
|
|
738
|
+
data
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
if (hooks.afterUpdate) {
|
|
742
|
+
await hooks.afterUpdate(req, item);
|
|
743
|
+
}
|
|
744
|
+
onSuccess(res, 'PUT', item);
|
|
745
|
+
} catch (error: any) {
|
|
746
|
+
onError(res, 'PUT', error);
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
middleware,
|
|
750
|
+
routeMiddleware.update
|
|
751
|
+
)
|
|
752
|
+
);
|
|
753
|
+
this.registerRoute('PUT', path, ['id']);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ── UPDATE (PARTIAL) ─ PATCH /endpoint/:id ──────────────────────────
|
|
757
|
+
|
|
758
|
+
if (methods.includes('PATCH')) {
|
|
759
|
+
const path = `/${this.endpoint}/:id`;
|
|
760
|
+
this.router.patch(
|
|
761
|
+
path,
|
|
762
|
+
...this.buildMiddlewareChain(
|
|
763
|
+
async (req: Request, res: Response) => {
|
|
764
|
+
try {
|
|
765
|
+
if (validate.update) {
|
|
766
|
+
const validation = validate.update(req.body);
|
|
767
|
+
if (!validation.valid) {
|
|
768
|
+
return res.status(400).json({ errors: validation.errors });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
let data = req.body;
|
|
772
|
+
if (hooks.beforeUpdate) {
|
|
773
|
+
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
|
|
774
|
+
}
|
|
775
|
+
const item = await this.model.findByIdAndUpdate(
|
|
776
|
+
req.params.id,
|
|
777
|
+
{ $set: data },
|
|
778
|
+
{ new: true, runValidators: true }
|
|
779
|
+
);
|
|
780
|
+
if (!item) {
|
|
781
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
782
|
+
}
|
|
783
|
+
if (hooks.afterUpdate) {
|
|
784
|
+
await hooks.afterUpdate(req, item);
|
|
785
|
+
}
|
|
786
|
+
onSuccess(res, 'PATCH', item);
|
|
787
|
+
} catch (error: any) {
|
|
788
|
+
onError(res, 'PATCH', error);
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
middleware,
|
|
792
|
+
routeMiddleware.update
|
|
793
|
+
)
|
|
794
|
+
);
|
|
795
|
+
this.registerRoute('PATCH', path, ['id']);
|
|
158
796
|
}
|
|
159
797
|
|
|
160
|
-
//
|
|
798
|
+
// ── BULK UPDATE ─ PATCH /endpoint/bulk ──────────────────────────────
|
|
799
|
+
|
|
800
|
+
if (methods.includes('PATCH')) {
|
|
801
|
+
const path = `/${this.endpoint}/bulk`;
|
|
802
|
+
this.router.patch(
|
|
803
|
+
path,
|
|
804
|
+
...this.buildMiddlewareChain(
|
|
805
|
+
async (req: Request, res: Response) => {
|
|
806
|
+
try {
|
|
807
|
+
const { filter, update } = req.body;
|
|
808
|
+
if (!filter || !update) {
|
|
809
|
+
return res.status(400).json({
|
|
810
|
+
error: 'Request body must contain "filter" and "update" objects',
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
if (validate.update) {
|
|
814
|
+
const validation = validate.update(update);
|
|
815
|
+
if (!validation.valid) {
|
|
816
|
+
return res.status(400).json({ errors: validation.errors });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const result = await this.model.updateMany(filter, { $set: update }, { runValidators: true });
|
|
820
|
+
onSuccess(res, 'PATCH (Bulk)', result);
|
|
821
|
+
} catch (error: any) {
|
|
822
|
+
onError(res, 'PATCH (Bulk)', error);
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
middleware,
|
|
826
|
+
routeMiddleware.update
|
|
827
|
+
)
|
|
828
|
+
);
|
|
829
|
+
this.registerRoute('PATCH', path, ['filter', 'update']);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── SOFT DELETE RESTORE ─ PATCH /endpoint/:id/restore ───────────────
|
|
833
|
+
|
|
834
|
+
if (softDelete && (methods.includes('PATCH') || methods.includes('PUT'))) {
|
|
835
|
+
const path = `/${this.endpoint}/:id/restore`;
|
|
836
|
+
this.router.patch(
|
|
837
|
+
path,
|
|
838
|
+
...this.buildMiddlewareChain(
|
|
839
|
+
async (req: Request, res: Response) => {
|
|
840
|
+
try {
|
|
841
|
+
const item = await this.model.findByIdAndUpdate(
|
|
842
|
+
req.params.id,
|
|
843
|
+
{ $unset: { deletedAt: 1 } },
|
|
844
|
+
{ new: true }
|
|
845
|
+
);
|
|
846
|
+
if (!item) {
|
|
847
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
848
|
+
}
|
|
849
|
+
onSuccess(res, 'PATCH (Restore)', item);
|
|
850
|
+
} catch (error: any) {
|
|
851
|
+
onError(res, 'PATCH (Restore)', error);
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
middleware,
|
|
855
|
+
routeMiddleware.update
|
|
856
|
+
)
|
|
857
|
+
);
|
|
858
|
+
this.registerRoute('PATCH', path, ['id']);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── DELETE MULTIPLE ─ DELETE /endpoint ───────────────────────────────
|
|
862
|
+
|
|
161
863
|
if (methods.includes('DELETE')) {
|
|
162
864
|
const path = `/${this.endpoint}`;
|
|
163
|
-
this.router.delete(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
865
|
+
this.router.delete(
|
|
866
|
+
path,
|
|
867
|
+
...this.buildMiddlewareChain(
|
|
868
|
+
async (req: Request, res: Response) => {
|
|
869
|
+
try {
|
|
870
|
+
const query = this.safeJsonParse(req.query.filter as string);
|
|
871
|
+
if (Object.keys(query).length === 0) {
|
|
872
|
+
return res.status(400).json({
|
|
873
|
+
error: 'A filter is required for bulk delete to prevent accidental data loss',
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
let deleteResult: any;
|
|
877
|
+
if (softDelete) {
|
|
878
|
+
deleteResult = await this.model.updateMany(query, {
|
|
879
|
+
$set: { deletedAt: new Date() },
|
|
880
|
+
});
|
|
881
|
+
} else {
|
|
882
|
+
deleteResult = await this.model.deleteMany(query);
|
|
883
|
+
}
|
|
884
|
+
if ((deleteResult.deletedCount ?? deleteResult.modifiedCount) === 0) {
|
|
885
|
+
return res.status(404).json({ message: 'No matching items found to delete' });
|
|
886
|
+
}
|
|
887
|
+
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
|
|
888
|
+
const ids = (await this.model.find(query).select('_id')).map((d: any) => d._id);
|
|
889
|
+
await options.relatedModel.deleteMany({
|
|
890
|
+
[options.relatedField!]: { $in: ids },
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
onSuccess(res, 'DELETE', deleteResult);
|
|
894
|
+
} catch (error: any) {
|
|
895
|
+
onError(res, 'DELETE', error);
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
middleware,
|
|
899
|
+
routeMiddleware.delete
|
|
900
|
+
)
|
|
901
|
+
);
|
|
902
|
+
this.registerRoute('DELETE', path, ['filter']);
|
|
180
903
|
}
|
|
181
904
|
|
|
182
|
-
//
|
|
905
|
+
// ── DELETE ONE ─ DELETE /endpoint/:id ────────────────────────────────
|
|
906
|
+
|
|
183
907
|
if (methods.includes('DELETE')) {
|
|
184
908
|
const path = `/${this.endpoint}/:id`;
|
|
185
|
-
this.router.delete(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
909
|
+
this.router.delete(
|
|
910
|
+
path,
|
|
911
|
+
...this.buildMiddlewareChain(
|
|
912
|
+
async (req: Request, res: Response) => {
|
|
913
|
+
try {
|
|
914
|
+
if (hooks.beforeDelete) {
|
|
915
|
+
await hooks.beforeDelete(req, req.params.id);
|
|
916
|
+
}
|
|
917
|
+
let item: T | null;
|
|
918
|
+
if (softDelete) {
|
|
919
|
+
item = await this.model.findByIdAndUpdate(
|
|
920
|
+
req.params.id,
|
|
921
|
+
{ $set: { deletedAt: new Date() } as any },
|
|
922
|
+
{ new: true }
|
|
923
|
+
);
|
|
924
|
+
} else {
|
|
925
|
+
item = await this.model.findByIdAndDelete(req.params.id);
|
|
926
|
+
}
|
|
927
|
+
if (!item) {
|
|
928
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
929
|
+
}
|
|
930
|
+
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
|
|
931
|
+
await options.relatedModel.deleteMany({
|
|
932
|
+
[options.relatedField!]: item._id,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
if (hooks.afterDelete) {
|
|
936
|
+
await hooks.afterDelete(req, item);
|
|
937
|
+
}
|
|
938
|
+
onSuccess(res, 'DELETE', item);
|
|
939
|
+
} catch (error: any) {
|
|
940
|
+
onError(res, 'DELETE', error);
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
middleware,
|
|
944
|
+
routeMiddleware.delete
|
|
945
|
+
)
|
|
946
|
+
);
|
|
947
|
+
this.registerRoute('DELETE', path, ['id']);
|
|
201
948
|
}
|
|
202
949
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
950
|
+
// ── BULK DELETE ─ DELETE /endpoint/bulk ──────────────────────────────
|
|
951
|
+
|
|
952
|
+
if (methods.includes('DELETE')) {
|
|
953
|
+
const path = `/${this.endpoint}/bulk`;
|
|
954
|
+
this.router.delete(
|
|
955
|
+
path,
|
|
956
|
+
...this.buildMiddlewareChain(
|
|
957
|
+
async (req: Request, res: Response) => {
|
|
958
|
+
try {
|
|
959
|
+
const { ids } = req.body;
|
|
960
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
961
|
+
return res.status(400).json({
|
|
962
|
+
error: 'Request body must contain a non-empty "ids" array',
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
let deleteResult: any;
|
|
966
|
+
if (softDelete) {
|
|
967
|
+
deleteResult = await this.model.updateMany(
|
|
968
|
+
{ _id: { $in: ids } },
|
|
969
|
+
{ $set: { deletedAt: new Date() } }
|
|
970
|
+
);
|
|
971
|
+
} else {
|
|
972
|
+
deleteResult = await this.model.deleteMany({ _id: { $in: ids } });
|
|
973
|
+
}
|
|
974
|
+
onSuccess(res, 'DELETE (Bulk)', deleteResult);
|
|
975
|
+
} catch (error: any) {
|
|
976
|
+
onError(res, 'DELETE (Bulk)', error);
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
middleware,
|
|
980
|
+
routeMiddleware.delete
|
|
981
|
+
)
|
|
982
|
+
);
|
|
983
|
+
this.registerRoute('DELETE', path);
|
|
217
984
|
}
|
|
218
985
|
|
|
219
|
-
//
|
|
986
|
+
// ── CUSTOM ROUTES ───────────────────────────────────────────────────
|
|
987
|
+
|
|
220
988
|
if (options.customRoutes) {
|
|
221
|
-
options.customRoutes.forEach(route => {
|
|
222
|
-
const { method, path, handler } = route;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
989
|
+
options.customRoutes.forEach((route) => {
|
|
990
|
+
const { method, path, handler, middleware: routeMw } = route;
|
|
991
|
+
const fullPath = `/${this.endpoint}${path}`;
|
|
992
|
+
this.router[method](
|
|
993
|
+
fullPath,
|
|
994
|
+
...this.buildMiddlewareChain(handler, middleware, routeMw)
|
|
995
|
+
);
|
|
996
|
+
this.registerRoute(method.toUpperCase(), fullPath);
|
|
227
997
|
});
|
|
228
998
|
}
|
|
229
999
|
}
|
|
230
1000
|
|
|
1001
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
1002
|
+
|
|
1003
|
+
/** Returns the configured Express Router with all CRUD routes. */
|
|
231
1004
|
public getRouter(): Router {
|
|
232
1005
|
return this.router;
|
|
233
1006
|
}
|
|
234
1007
|
|
|
235
|
-
|
|
1008
|
+
/** Returns an array of all registered route definitions. */
|
|
1009
|
+
public getRoutes(): RouteInfo[] {
|
|
236
1010
|
return this.routes;
|
|
237
1011
|
}
|
|
238
1012
|
}
|
|
239
1013
|
|
|
1014
|
+
export { CrudController };
|
|
240
1015
|
export default CrudController;
|