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/dist/index.cjs
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
3
5
|
var express = require('express');
|
|
6
|
+
var mongoose = require('mongoose');
|
|
4
7
|
|
|
8
|
+
// ─── CrudController Class ───────────────────────────────────────────────────
|
|
9
|
+
/**
|
|
10
|
+
* A powerful, flexible CRUD Controller for Express + Mongoose.
|
|
11
|
+
*
|
|
12
|
+
* Automatically generates RESTful endpoints for any Mongoose model with
|
|
13
|
+
* support for lifecycle hooks, validation, soft delete, bulk operations,
|
|
14
|
+
* search, field selection, population, and more.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const ctrl = new CrudController(UserModel, 'users', {
|
|
18
|
+
* methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
|
19
|
+
* softDelete: true,
|
|
20
|
+
* searchFields: ['name', 'email'],
|
|
21
|
+
* hooks: {
|
|
22
|
+
* beforeCreate: (req, data) => ({ ...data, createdBy: req.user.id }),
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* app.use('/api', ctrl.getRouter());
|
|
26
|
+
*/
|
|
5
27
|
class CrudController {
|
|
6
28
|
constructor(model, endpoint, options = {}) {
|
|
7
29
|
this.model = model;
|
|
@@ -10,205 +32,583 @@ class CrudController {
|
|
|
10
32
|
this.routes = [];
|
|
11
33
|
this.configureRoutes(options);
|
|
12
34
|
}
|
|
35
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Merges global middleware + per-operation middleware + handler into a single array.
|
|
38
|
+
*/
|
|
39
|
+
buildMiddlewareChain(handler, globalMiddleware, operationMiddleware) {
|
|
40
|
+
return [...globalMiddleware, ...(operationMiddleware || []), handler];
|
|
41
|
+
}
|
|
42
|
+
/** Records a route in the internal registry. */
|
|
43
|
+
registerRoute(method, path, params) {
|
|
44
|
+
this.routes.push({ method, path, params });
|
|
45
|
+
}
|
|
46
|
+
/** Parses a `?select=name,email` query param into Mongoose select syntax. */
|
|
47
|
+
parseSelect(querySelect, defaultSelect) {
|
|
48
|
+
if (querySelect)
|
|
49
|
+
return querySelect.split(',').join(' ');
|
|
50
|
+
return defaultSelect;
|
|
51
|
+
}
|
|
52
|
+
/** Parses a `?populate=author,comments` query param. */
|
|
53
|
+
parsePopulate(queryPopulate, defaultPopulate) {
|
|
54
|
+
if (queryPopulate) {
|
|
55
|
+
return queryPopulate.split(',').map((p) => p.trim());
|
|
56
|
+
}
|
|
57
|
+
return defaultPopulate;
|
|
58
|
+
}
|
|
59
|
+
/** Safely parses JSON from a query param, returns fallback on failure. */
|
|
60
|
+
safeJsonParse(value, fallback = {}) {
|
|
61
|
+
if (!value)
|
|
62
|
+
return fallback;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(value);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Builds the soft-delete filter to exclude deleted records. */
|
|
71
|
+
softDeleteFilter(req, softDelete) {
|
|
72
|
+
if (!softDelete)
|
|
73
|
+
return {};
|
|
74
|
+
const includeDeleted = req.query.includeDeleted === 'true';
|
|
75
|
+
return includeDeleted ? {} : { deletedAt: { $exists: false } };
|
|
76
|
+
}
|
|
77
|
+
// ── Route Configuration ────────────────────────────────────────────────
|
|
13
78
|
configureRoutes(options) {
|
|
14
|
-
const { middleware = [],
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
};
|
|
22
|
-
//
|
|
79
|
+
const { middleware = [], routeMiddleware = {}, onSuccess = (res, method, result, meta) => {
|
|
80
|
+
if (meta) {
|
|
81
|
+
res.status(200).json({ data: result, pagination: meta });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
res.status(200).json(result);
|
|
85
|
+
}
|
|
86
|
+
}, onError = (res, method, error) => res.status(400).json({ error: error.message }), methods = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'], hooks = {}, validate = {}, softDelete = false, select: defaultSelect, populate: defaultPopulate, searchFields = [], } = options;
|
|
87
|
+
// ── CREATE ─ POST /endpoint ─────────────────────────────────────────
|
|
23
88
|
if (methods.includes('POST')) {
|
|
24
89
|
const path = `/${this.endpoint}`;
|
|
25
|
-
this.router.post(path,
|
|
26
|
-
const method = 'POST';
|
|
90
|
+
this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
27
91
|
try {
|
|
28
|
-
|
|
92
|
+
// Validation hook
|
|
93
|
+
if (validate.create) {
|
|
94
|
+
const validation = validate.create(req.body);
|
|
95
|
+
if (!validation.valid) {
|
|
96
|
+
return res.status(400).json({ errors: validation.errors });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Before hook
|
|
100
|
+
let data = req.body;
|
|
101
|
+
if (hooks.beforeCreate) {
|
|
102
|
+
data = (await hooks.beforeCreate(req, data)) ?? data;
|
|
103
|
+
}
|
|
104
|
+
const result = await this.model.create(data);
|
|
105
|
+
// Related model cascade
|
|
29
106
|
if (options.relatedModel && options.relatedMethods?.includes('POST')) {
|
|
30
|
-
await options.relatedModel.create({
|
|
107
|
+
await options.relatedModel.create({
|
|
108
|
+
[options.relatedField]: result._id,
|
|
109
|
+
...data,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// After hook
|
|
113
|
+
if (hooks.afterCreate) {
|
|
114
|
+
await hooks.afterCreate(req, result);
|
|
115
|
+
}
|
|
116
|
+
onSuccess(res.status(201), 'POST', result);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
onError(res, 'POST', error);
|
|
120
|
+
}
|
|
121
|
+
}, middleware, routeMiddleware.create));
|
|
122
|
+
this.registerRoute('POST', path);
|
|
123
|
+
}
|
|
124
|
+
// ── BULK CREATE ─ POST /endpoint/bulk ───────────────────────────────
|
|
125
|
+
if (methods.includes('POST')) {
|
|
126
|
+
const path = `/${this.endpoint}/bulk`;
|
|
127
|
+
this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const items = req.body;
|
|
130
|
+
if (!Array.isArray(items)) {
|
|
131
|
+
return res.status(400).json({ error: 'Request body must be an array' });
|
|
132
|
+
}
|
|
133
|
+
// Validate each item
|
|
134
|
+
if (validate.create) {
|
|
135
|
+
for (let i = 0; i < items.length; i++) {
|
|
136
|
+
const validation = validate.create(items[i]);
|
|
137
|
+
if (!validation.valid) {
|
|
138
|
+
return res.status(400).json({
|
|
139
|
+
error: `Validation failed for item at index ${i}`,
|
|
140
|
+
errors: validation.errors,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Before hooks
|
|
146
|
+
let processedItems = items;
|
|
147
|
+
if (hooks.beforeCreate) {
|
|
148
|
+
processedItems = [];
|
|
149
|
+
for (const item of items) {
|
|
150
|
+
const result = (await hooks.beforeCreate(req, item)) ?? item;
|
|
151
|
+
processedItems.push(result);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const results = await this.model.insertMany(processedItems);
|
|
155
|
+
onSuccess(res.status(201), 'POST (Bulk)', results);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
onError(res, 'POST (Bulk)', error);
|
|
159
|
+
}
|
|
160
|
+
}, middleware, routeMiddleware.create));
|
|
161
|
+
this.registerRoute('POST', path);
|
|
162
|
+
}
|
|
163
|
+
// ── SEARCH ─ GET /endpoint/search ───────────────────────────────────
|
|
164
|
+
if (methods.includes('GET') && searchFields.length > 0) {
|
|
165
|
+
const path = `/${this.endpoint}/search`;
|
|
166
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const q = req.query.q;
|
|
169
|
+
if (!q) {
|
|
170
|
+
return res.status(400).json({ error: 'Query parameter "q" is required' });
|
|
31
171
|
}
|
|
32
|
-
|
|
172
|
+
const fieldsParam = req.query.fields;
|
|
173
|
+
const fields = fieldsParam ? fieldsParam.split(',').map((f) => f.trim()) : searchFields;
|
|
174
|
+
const searchQuery = {
|
|
175
|
+
$or: fields.map((field) => ({
|
|
176
|
+
[field]: { $regex: q, $options: 'i' },
|
|
177
|
+
})),
|
|
178
|
+
...this.softDeleteFilter(req, softDelete),
|
|
179
|
+
};
|
|
180
|
+
const pageNumber = parseInt(req.query.page, 10) || 1;
|
|
181
|
+
const pageSize = parseInt(req.query.limit, 10) || 10;
|
|
182
|
+
const skip = (pageNumber - 1) * pageSize;
|
|
183
|
+
const selectStr = this.parseSelect(req.query.select, defaultSelect);
|
|
184
|
+
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
|
|
185
|
+
let query = this.model.find(searchQuery).skip(skip).limit(pageSize);
|
|
186
|
+
if (selectStr)
|
|
187
|
+
query = query.select(selectStr);
|
|
188
|
+
if (populateOpt)
|
|
189
|
+
query = query.populate(populateOpt);
|
|
190
|
+
const [items, total] = await Promise.all([
|
|
191
|
+
query.exec(),
|
|
192
|
+
this.model.countDocuments(searchQuery),
|
|
193
|
+
]);
|
|
194
|
+
const meta = {
|
|
195
|
+
total,
|
|
196
|
+
page: pageNumber,
|
|
197
|
+
limit: pageSize,
|
|
198
|
+
pages: Math.ceil(total / pageSize),
|
|
199
|
+
hasNext: pageNumber * pageSize < total,
|
|
200
|
+
hasPrev: pageNumber > 1,
|
|
201
|
+
};
|
|
202
|
+
let result = items;
|
|
203
|
+
if (hooks.afterRead) {
|
|
204
|
+
result = (await hooks.afterRead(req, items)) ?? items;
|
|
205
|
+
}
|
|
206
|
+
onSuccess(res, 'GET (Search)', result, meta);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
onError(res, 'GET (Search)', error);
|
|
210
|
+
}
|
|
211
|
+
}, middleware, routeMiddleware.read));
|
|
212
|
+
this.registerRoute('GET', path, ['q', 'fields', 'page', 'limit', 'select', 'populate']);
|
|
213
|
+
}
|
|
214
|
+
// ── COUNT ─ GET /endpoint/count ─────────────────────────────────────
|
|
215
|
+
if (methods.includes('GET')) {
|
|
216
|
+
const path = `/${this.endpoint}/count`;
|
|
217
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
218
|
+
try {
|
|
219
|
+
const filter = {
|
|
220
|
+
...this.safeJsonParse(req.query.filter),
|
|
221
|
+
...this.softDeleteFilter(req, softDelete),
|
|
222
|
+
};
|
|
223
|
+
const count = await this.model.countDocuments(filter);
|
|
224
|
+
onSuccess(res, 'GET (Count)', { count });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
onError(res, 'GET (Count)', error);
|
|
228
|
+
}
|
|
229
|
+
}, middleware, routeMiddleware.read));
|
|
230
|
+
this.registerRoute('GET', path, ['filter']);
|
|
231
|
+
}
|
|
232
|
+
// ── AGGREGATE ─ GET /endpoint/aggregate ─────────────────────────────
|
|
233
|
+
if (methods.includes('GET') && options.aggregatePipeline) {
|
|
234
|
+
const path = `/${this.endpoint}/aggregate`;
|
|
235
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
236
|
+
try {
|
|
237
|
+
const pipeline = typeof options.aggregatePipeline === 'function'
|
|
238
|
+
? options.aggregatePipeline(req)
|
|
239
|
+
: options.aggregatePipeline || [];
|
|
240
|
+
const results = await this.model.aggregate(pipeline);
|
|
241
|
+
onSuccess(res, 'GET (Aggregate)', results);
|
|
33
242
|
}
|
|
34
243
|
catch (error) {
|
|
35
|
-
onError(res,
|
|
244
|
+
onError(res, 'GET (Aggregate)', error);
|
|
36
245
|
}
|
|
37
|
-
}));
|
|
38
|
-
registerRoute('
|
|
246
|
+
}, middleware, routeMiddleware.read));
|
|
247
|
+
this.registerRoute('GET', path);
|
|
39
248
|
}
|
|
40
|
-
//
|
|
249
|
+
// ── EXISTS ─ GET /endpoint/exists/:id ───────────────────────────────
|
|
250
|
+
if (methods.includes('GET')) {
|
|
251
|
+
const path = `/${this.endpoint}/exists/:id`;
|
|
252
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const filter = { _id: req.params.id };
|
|
255
|
+
if (softDelete)
|
|
256
|
+
filter.deletedAt = { $exists: false };
|
|
257
|
+
const exists = await this.model.exists(filter);
|
|
258
|
+
onSuccess(res, 'GET (Exists)', { exists: !!exists });
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
onError(res, 'GET (Exists)', error);
|
|
262
|
+
}
|
|
263
|
+
}, middleware, routeMiddleware.read));
|
|
264
|
+
this.registerRoute('GET', path, ['id']);
|
|
265
|
+
}
|
|
266
|
+
// ── READ ALL ─ GET /endpoint ────────────────────────────────────────
|
|
41
267
|
if (methods.includes('GET')) {
|
|
42
268
|
const path = `/${this.endpoint}`;
|
|
43
|
-
this.router.get(path,
|
|
44
|
-
const method = 'GET';
|
|
269
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
45
270
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
271
|
+
let filter = {
|
|
272
|
+
...this.safeJsonParse(req.query.filter),
|
|
273
|
+
...this.softDeleteFilter(req, softDelete),
|
|
274
|
+
};
|
|
275
|
+
const sortOrder = this.safeJsonParse(req.query.sort);
|
|
276
|
+
const pageNumber = parseInt(req.query.page, 10) || 1;
|
|
277
|
+
const pageSize = parseInt(req.query.limit, 10) || 10;
|
|
51
278
|
const skip = (pageNumber - 1) * pageSize;
|
|
279
|
+
const selectStr = this.parseSelect(req.query.select, defaultSelect);
|
|
280
|
+
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
|
|
281
|
+
// Before read hook
|
|
282
|
+
if (hooks.beforeRead) {
|
|
283
|
+
filter = (await hooks.beforeRead(req, filter)) ?? filter;
|
|
284
|
+
}
|
|
52
285
|
let items;
|
|
53
286
|
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
|
|
54
287
|
items = await this.model.aggregate([
|
|
55
|
-
{ $match:
|
|
288
|
+
{ $match: filter },
|
|
56
289
|
{
|
|
57
290
|
$lookup: {
|
|
58
291
|
from: options.relatedModel.collection.name,
|
|
59
292
|
localField: options.relatedField,
|
|
60
293
|
foreignField: '_id',
|
|
61
|
-
as: 'relatedData'
|
|
62
|
-
}
|
|
294
|
+
as: 'relatedData',
|
|
295
|
+
},
|
|
63
296
|
},
|
|
64
|
-
{ $sort: sortOrder },
|
|
297
|
+
{ $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } },
|
|
65
298
|
{ $skip: skip },
|
|
66
|
-
{ $limit: pageSize }
|
|
299
|
+
{ $limit: pageSize },
|
|
67
300
|
]);
|
|
68
301
|
}
|
|
69
302
|
else {
|
|
70
|
-
|
|
303
|
+
let query = this.model
|
|
304
|
+
.find(filter)
|
|
305
|
+
.sort(Object.keys(sortOrder).length ? sortOrder : undefined)
|
|
306
|
+
.skip(skip)
|
|
307
|
+
.limit(pageSize);
|
|
308
|
+
if (selectStr)
|
|
309
|
+
query = query.select(selectStr);
|
|
310
|
+
if (populateOpt)
|
|
311
|
+
query = query.populate(populateOpt);
|
|
312
|
+
items = await query.exec();
|
|
71
313
|
}
|
|
72
|
-
|
|
314
|
+
const total = await this.model.countDocuments(filter);
|
|
315
|
+
const meta = {
|
|
316
|
+
total,
|
|
317
|
+
page: pageNumber,
|
|
318
|
+
limit: pageSize,
|
|
319
|
+
pages: Math.ceil(total / pageSize),
|
|
320
|
+
hasNext: pageNumber * pageSize < total,
|
|
321
|
+
hasPrev: pageNumber > 1,
|
|
322
|
+
};
|
|
323
|
+
let result = items;
|
|
324
|
+
if (hooks.afterRead) {
|
|
325
|
+
result = (await hooks.afterRead(req, items)) ?? items;
|
|
326
|
+
}
|
|
327
|
+
onSuccess(res, 'GET', result, meta);
|
|
73
328
|
}
|
|
74
329
|
catch (error) {
|
|
75
|
-
onError(res,
|
|
330
|
+
onError(res, 'GET', error);
|
|
76
331
|
}
|
|
77
|
-
}));
|
|
78
|
-
registerRoute('GET', path, ['filter', 'sort', 'page', 'limit']);
|
|
332
|
+
}, middleware, routeMiddleware.read));
|
|
333
|
+
this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']);
|
|
79
334
|
}
|
|
80
|
-
//
|
|
335
|
+
// ── READ ONE ─ GET /endpoint/:id ────────────────────────────────────
|
|
81
336
|
if (methods.includes('GET')) {
|
|
82
337
|
const path = `/${this.endpoint}/:id`;
|
|
83
|
-
this.router.get(path,
|
|
84
|
-
const method = 'GET';
|
|
338
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
85
339
|
try {
|
|
340
|
+
const selectStr = this.parseSelect(req.query.select, defaultSelect);
|
|
341
|
+
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
|
|
86
342
|
let item;
|
|
87
343
|
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
|
|
344
|
+
const matchFilter = { _id: new mongoose.Types.ObjectId(req.params.id) };
|
|
345
|
+
if (softDelete)
|
|
346
|
+
matchFilter.deletedAt = { $exists: false };
|
|
88
347
|
const aggregateResult = await this.model.aggregate([
|
|
89
|
-
{ $match:
|
|
348
|
+
{ $match: matchFilter },
|
|
90
349
|
{
|
|
91
350
|
$lookup: {
|
|
92
351
|
from: options.relatedModel.collection.name,
|
|
93
352
|
localField: options.relatedField,
|
|
94
353
|
foreignField: '_id',
|
|
95
|
-
as: 'relatedData'
|
|
96
|
-
}
|
|
97
|
-
}
|
|
354
|
+
as: 'relatedData',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
98
357
|
]);
|
|
99
|
-
item = aggregateResult[0];
|
|
358
|
+
item = aggregateResult[0] || null;
|
|
100
359
|
}
|
|
101
360
|
else {
|
|
102
|
-
|
|
361
|
+
const findFilter = { _id: req.params.id };
|
|
362
|
+
if (softDelete)
|
|
363
|
+
findFilter.deletedAt = { $exists: false };
|
|
364
|
+
let query = this.model.findOne(findFilter);
|
|
365
|
+
if (selectStr)
|
|
366
|
+
query = query.select(selectStr);
|
|
367
|
+
if (populateOpt)
|
|
368
|
+
query = query.populate(populateOpt);
|
|
369
|
+
item = await query.exec();
|
|
103
370
|
}
|
|
104
371
|
if (!item) {
|
|
105
|
-
return res.status(404).
|
|
372
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
373
|
+
}
|
|
374
|
+
let result = item;
|
|
375
|
+
if (hooks.afterRead) {
|
|
376
|
+
result = (await hooks.afterRead(req, item)) ?? item;
|
|
106
377
|
}
|
|
107
|
-
onSuccess(res,
|
|
378
|
+
onSuccess(res, 'GET', result);
|
|
108
379
|
}
|
|
109
380
|
catch (error) {
|
|
110
|
-
onError(res,
|
|
381
|
+
onError(res, 'GET', error);
|
|
111
382
|
}
|
|
112
|
-
}));
|
|
113
|
-
registerRoute('GET', path, ['id']);
|
|
383
|
+
}, middleware, routeMiddleware.read));
|
|
384
|
+
this.registerRoute('GET', path, ['id', 'select', 'populate']);
|
|
114
385
|
}
|
|
115
|
-
//
|
|
386
|
+
// ── UPDATE (FULL) ─ PUT /endpoint/:id ───────────────────────────────
|
|
116
387
|
if (methods.includes('PUT')) {
|
|
117
388
|
const path = `/${this.endpoint}/:id`;
|
|
118
|
-
this.router.put(path,
|
|
119
|
-
const method = 'PUT';
|
|
389
|
+
this.router.put(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
120
390
|
try {
|
|
121
|
-
|
|
391
|
+
if (validate.update) {
|
|
392
|
+
const validation = validate.update(req.body);
|
|
393
|
+
if (!validation.valid) {
|
|
394
|
+
return res.status(400).json({ errors: validation.errors });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
let data = req.body;
|
|
398
|
+
if (hooks.beforeUpdate) {
|
|
399
|
+
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
|
|
400
|
+
}
|
|
401
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, data, {
|
|
402
|
+
new: true,
|
|
403
|
+
runValidators: true,
|
|
404
|
+
});
|
|
122
405
|
if (!item) {
|
|
123
|
-
return res.status(404).
|
|
406
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
124
407
|
}
|
|
125
408
|
if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
|
|
126
|
-
await options.relatedModel.updateMany({ [options.relatedField]: item._id },
|
|
409
|
+
await options.relatedModel.updateMany({ [options.relatedField]: item._id }, data);
|
|
410
|
+
}
|
|
411
|
+
if (hooks.afterUpdate) {
|
|
412
|
+
await hooks.afterUpdate(req, item);
|
|
413
|
+
}
|
|
414
|
+
onSuccess(res, 'PUT', item);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
onError(res, 'PUT', error);
|
|
418
|
+
}
|
|
419
|
+
}, middleware, routeMiddleware.update));
|
|
420
|
+
this.registerRoute('PUT', path, ['id']);
|
|
421
|
+
}
|
|
422
|
+
// ── UPDATE (PARTIAL) ─ PATCH /endpoint/:id ──────────────────────────
|
|
423
|
+
if (methods.includes('PATCH')) {
|
|
424
|
+
const path = `/${this.endpoint}/:id`;
|
|
425
|
+
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
426
|
+
try {
|
|
427
|
+
if (validate.update) {
|
|
428
|
+
const validation = validate.update(req.body);
|
|
429
|
+
if (!validation.valid) {
|
|
430
|
+
return res.status(400).json({ errors: validation.errors });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
let data = req.body;
|
|
434
|
+
if (hooks.beforeUpdate) {
|
|
435
|
+
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
|
|
436
|
+
}
|
|
437
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, { $set: data }, { new: true, runValidators: true });
|
|
438
|
+
if (!item) {
|
|
439
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
127
440
|
}
|
|
128
|
-
|
|
441
|
+
if (hooks.afterUpdate) {
|
|
442
|
+
await hooks.afterUpdate(req, item);
|
|
443
|
+
}
|
|
444
|
+
onSuccess(res, 'PATCH', item);
|
|
129
445
|
}
|
|
130
446
|
catch (error) {
|
|
131
|
-
onError(res,
|
|
447
|
+
onError(res, 'PATCH', error);
|
|
132
448
|
}
|
|
133
|
-
}));
|
|
134
|
-
registerRoute('
|
|
449
|
+
}, middleware, routeMiddleware.update));
|
|
450
|
+
this.registerRoute('PATCH', path, ['id']);
|
|
135
451
|
}
|
|
136
|
-
//
|
|
452
|
+
// ── BULK UPDATE ─ PATCH /endpoint/bulk ──────────────────────────────
|
|
453
|
+
if (methods.includes('PATCH')) {
|
|
454
|
+
const path = `/${this.endpoint}/bulk`;
|
|
455
|
+
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
456
|
+
try {
|
|
457
|
+
const { filter, update } = req.body;
|
|
458
|
+
if (!filter || !update) {
|
|
459
|
+
return res.status(400).json({
|
|
460
|
+
error: 'Request body must contain "filter" and "update" objects',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (validate.update) {
|
|
464
|
+
const validation = validate.update(update);
|
|
465
|
+
if (!validation.valid) {
|
|
466
|
+
return res.status(400).json({ errors: validation.errors });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const result = await this.model.updateMany(filter, { $set: update }, { runValidators: true });
|
|
470
|
+
onSuccess(res, 'PATCH (Bulk)', result);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
onError(res, 'PATCH (Bulk)', error);
|
|
474
|
+
}
|
|
475
|
+
}, middleware, routeMiddleware.update));
|
|
476
|
+
this.registerRoute('PATCH', path, ['filter', 'update']);
|
|
477
|
+
}
|
|
478
|
+
// ── SOFT DELETE RESTORE ─ PATCH /endpoint/:id/restore ───────────────
|
|
479
|
+
if (softDelete && (methods.includes('PATCH') || methods.includes('PUT'))) {
|
|
480
|
+
const path = `/${this.endpoint}/:id/restore`;
|
|
481
|
+
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
482
|
+
try {
|
|
483
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, { $unset: { deletedAt: 1 } }, { new: true });
|
|
484
|
+
if (!item) {
|
|
485
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
486
|
+
}
|
|
487
|
+
onSuccess(res, 'PATCH (Restore)', item);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
onError(res, 'PATCH (Restore)', error);
|
|
491
|
+
}
|
|
492
|
+
}, middleware, routeMiddleware.update));
|
|
493
|
+
this.registerRoute('PATCH', path, ['id']);
|
|
494
|
+
}
|
|
495
|
+
// ── DELETE MULTIPLE ─ DELETE /endpoint ───────────────────────────────
|
|
137
496
|
if (methods.includes('DELETE')) {
|
|
138
497
|
const path = `/${this.endpoint}`;
|
|
139
|
-
this.router.delete(path,
|
|
140
|
-
const method = 'DELETE';
|
|
498
|
+
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
141
499
|
try {
|
|
142
|
-
const query =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
500
|
+
const query = this.safeJsonParse(req.query.filter);
|
|
501
|
+
if (Object.keys(query).length === 0) {
|
|
502
|
+
return res.status(400).json({
|
|
503
|
+
error: 'A filter is required for bulk delete to prevent accidental data loss',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
let deleteResult;
|
|
507
|
+
if (softDelete) {
|
|
508
|
+
deleteResult = await this.model.updateMany(query, {
|
|
509
|
+
$set: { deletedAt: new Date() },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
deleteResult = await this.model.deleteMany(query);
|
|
514
|
+
}
|
|
515
|
+
if ((deleteResult.deletedCount ?? deleteResult.modifiedCount) === 0) {
|
|
516
|
+
return res.status(404).json({ message: 'No matching items found to delete' });
|
|
146
517
|
}
|
|
147
518
|
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
|
|
148
|
-
await
|
|
519
|
+
const ids = (await this.model.find(query).select('_id')).map((d) => d._id);
|
|
520
|
+
await options.relatedModel.deleteMany({
|
|
521
|
+
[options.relatedField]: { $in: ids },
|
|
522
|
+
});
|
|
149
523
|
}
|
|
150
|
-
onSuccess(res,
|
|
524
|
+
onSuccess(res, 'DELETE', deleteResult);
|
|
151
525
|
}
|
|
152
526
|
catch (error) {
|
|
153
|
-
onError(res,
|
|
527
|
+
onError(res, 'DELETE', error);
|
|
154
528
|
}
|
|
155
|
-
}));
|
|
156
|
-
registerRoute('DELETE', path, ['filter']);
|
|
529
|
+
}, middleware, routeMiddleware.delete));
|
|
530
|
+
this.registerRoute('DELETE', path, ['filter']);
|
|
157
531
|
}
|
|
158
|
-
//
|
|
532
|
+
// ── DELETE ONE ─ DELETE /endpoint/:id ────────────────────────────────
|
|
159
533
|
if (methods.includes('DELETE')) {
|
|
160
534
|
const path = `/${this.endpoint}/:id`;
|
|
161
|
-
this.router.delete(path,
|
|
162
|
-
const method = 'DELETE';
|
|
535
|
+
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
163
536
|
try {
|
|
164
|
-
|
|
537
|
+
if (hooks.beforeDelete) {
|
|
538
|
+
await hooks.beforeDelete(req, req.params.id);
|
|
539
|
+
}
|
|
540
|
+
let item;
|
|
541
|
+
if (softDelete) {
|
|
542
|
+
item = await this.model.findByIdAndUpdate(req.params.id, { $set: { deletedAt: new Date() } }, { new: true });
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
item = await this.model.findByIdAndDelete(req.params.id);
|
|
546
|
+
}
|
|
165
547
|
if (!item) {
|
|
166
|
-
return res.status(404).
|
|
548
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
167
549
|
}
|
|
168
550
|
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
|
|
169
|
-
await options.relatedModel.deleteMany({
|
|
551
|
+
await options.relatedModel.deleteMany({
|
|
552
|
+
[options.relatedField]: item._id,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (hooks.afterDelete) {
|
|
556
|
+
await hooks.afterDelete(req, item);
|
|
170
557
|
}
|
|
171
|
-
onSuccess(res,
|
|
558
|
+
onSuccess(res, 'DELETE', item);
|
|
172
559
|
}
|
|
173
560
|
catch (error) {
|
|
174
|
-
onError(res,
|
|
561
|
+
onError(res, 'DELETE', error);
|
|
175
562
|
}
|
|
176
|
-
}));
|
|
177
|
-
registerRoute('DELETE', path, ['id']);
|
|
563
|
+
}, middleware, routeMiddleware.delete));
|
|
564
|
+
this.registerRoute('DELETE', path, ['id']);
|
|
178
565
|
}
|
|
179
|
-
//
|
|
180
|
-
if (methods.includes('
|
|
181
|
-
const path = `/${this.endpoint}/
|
|
182
|
-
this.router.
|
|
183
|
-
const method = 'GET (Aggregate)';
|
|
566
|
+
// ── BULK DELETE ─ DELETE /endpoint/bulk ──────────────────────────────
|
|
567
|
+
if (methods.includes('DELETE')) {
|
|
568
|
+
const path = `/${this.endpoint}/bulk`;
|
|
569
|
+
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
184
570
|
try {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
571
|
+
const { ids } = req.body;
|
|
572
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
573
|
+
return res.status(400).json({
|
|
574
|
+
error: 'Request body must contain a non-empty "ids" array',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
let deleteResult;
|
|
578
|
+
if (softDelete) {
|
|
579
|
+
deleteResult = await this.model.updateMany({ _id: { $in: ids } }, { $set: { deletedAt: new Date() } });
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
deleteResult = await this.model.deleteMany({ _id: { $in: ids } });
|
|
583
|
+
}
|
|
584
|
+
onSuccess(res, 'DELETE (Bulk)', deleteResult);
|
|
188
585
|
}
|
|
189
586
|
catch (error) {
|
|
190
|
-
onError(res,
|
|
587
|
+
onError(res, 'DELETE (Bulk)', error);
|
|
191
588
|
}
|
|
192
|
-
}));
|
|
193
|
-
registerRoute('
|
|
589
|
+
}, middleware, routeMiddleware.delete));
|
|
590
|
+
this.registerRoute('DELETE', path);
|
|
194
591
|
}
|
|
195
|
-
//
|
|
592
|
+
// ── CUSTOM ROUTES ───────────────────────────────────────────────────
|
|
196
593
|
if (options.customRoutes) {
|
|
197
|
-
options.customRoutes.forEach(route => {
|
|
198
|
-
const { method, path, handler } = route;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
594
|
+
options.customRoutes.forEach((route) => {
|
|
595
|
+
const { method, path, handler, middleware: routeMw } = route;
|
|
596
|
+
const fullPath = `/${this.endpoint}${path}`;
|
|
597
|
+
this.router[method](fullPath, ...this.buildMiddlewareChain(handler, middleware, routeMw));
|
|
598
|
+
this.registerRoute(method.toUpperCase(), fullPath);
|
|
203
599
|
});
|
|
204
600
|
}
|
|
205
601
|
}
|
|
602
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
603
|
+
/** Returns the configured Express Router with all CRUD routes. */
|
|
206
604
|
getRouter() {
|
|
207
605
|
return this.router;
|
|
208
606
|
}
|
|
607
|
+
/** Returns an array of all registered route definitions. */
|
|
209
608
|
getRoutes() {
|
|
210
609
|
return this.routes;
|
|
211
610
|
}
|
|
212
611
|
}
|
|
213
612
|
|
|
214
|
-
|
|
613
|
+
exports.CrudController = CrudController;
|
|
614
|
+
exports.default = CrudController;
|