crud-api-express 1.2.6 → 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 CHANGED
@@ -1,12 +1,28 @@
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 ───────────────────────────────────────────────────
5
9
  /**
6
- * A generic CRUD Controller for Express/Mongoose API development.
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.
7
15
  *
8
- * It registers standard CRUD endpoints based on the provided model and endpoint string,
9
- * along with any custom routes.
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());
10
26
  */
11
27
  class CrudController {
12
28
  constructor(model, endpoint, options = {}) {
@@ -16,235 +32,583 @@ class CrudController {
16
32
  this.routes = [];
17
33
  this.configureRoutes(options);
18
34
  }
35
+ // ── Helpers ─────────────────────────────────────────────────────────────
19
36
  /**
20
- * Applies middleware to the route handler.
21
- * @param routeHandler The original route handler.
22
- * @param middlewareList Optional array of middleware functions.
23
- * @returns Array of middleware functions including the route handler.
37
+ * Merges global middleware + per-operation middleware + handler into a single array.
24
38
  */
25
- applyMiddleware(handler, middleware) {
26
- return [...middleware, handler];
39
+ buildMiddlewareChain(handler, globalMiddleware, operationMiddleware) {
40
+ return [...globalMiddleware, ...(operationMiddleware || []), handler];
27
41
  }
28
- /**
29
- * Registers route definitions to the internal list.
30
- * @param method HTTP method.
31
- * @param path URL path.
32
- * @param params Optional route parameter names.
33
- */
42
+ /** Records a route in the internal registry. */
34
43
  registerRoute(method, path, params) {
35
44
  this.routes.push({ method, path, params });
36
45
  }
37
- /**
38
- * Configures all endpoints based on given options.
39
- * @param options CrudOptions to setup CRUD behaviors.
40
- */
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 ────────────────────────────────────────────────
41
78
  configureRoutes(options) {
42
- // Destructure and set default middleware and callbacks
43
- const { middleware = [], onSuccess = (res, method, result) => res.status(200).send(result), onError = (res, method, error) => res.status(400).send(error), methods = ['POST', 'GET', 'PUT', 'DELETE'] } = options;
44
- // CREATE operation - POST /endpoint
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 ─────────────────────────────────────────
45
88
  if (methods.includes('POST')) {
46
89
  const path = `/${this.endpoint}`;
47
- this.router.post(path, this.applyMiddleware(async (req, res) => {
48
- const method = 'POST';
90
+ this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
49
91
  try {
50
- const result = await this.model.create(req.body);
51
- // If a related model is defined and supports POST, create related entry
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
52
106
  if (options.relatedModel && options.relatedMethods?.includes('POST')) {
53
107
  await options.relatedModel.create({
54
108
  [options.relatedField]: result._id,
55
- ...req.body
109
+ ...data,
56
110
  });
57
111
  }
58
- // Return 201 Created status if successful
59
- onSuccess(res.status(201), method, result);
112
+ // After hook
113
+ if (hooks.afterCreate) {
114
+ await hooks.afterCreate(req, result);
115
+ }
116
+ onSuccess(res.status(201), 'POST', result);
60
117
  }
61
118
  catch (error) {
62
- onError(res, method, error);
119
+ onError(res, 'POST', error);
63
120
  }
64
- }, middleware));
121
+ }, middleware, routeMiddleware.create));
65
122
  this.registerRoute('POST', path);
66
123
  }
67
- // READ ALL operation - GET /endpoint
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' });
171
+ }
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);
242
+ }
243
+ catch (error) {
244
+ onError(res, 'GET (Aggregate)', error);
245
+ }
246
+ }, middleware, routeMiddleware.read));
247
+ this.registerRoute('GET', path);
248
+ }
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 ────────────────────────────────────────
68
267
  if (methods.includes('GET')) {
69
268
  const path = `/${this.endpoint}`;
70
- this.router.get(path, this.applyMiddleware(async (req, res) => {
71
- const method = 'GET';
269
+ this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
72
270
  try {
73
- const { filter, sort, page, limit } = req.query;
74
- const query = filter ? JSON.parse(filter) : {};
75
- const sortOrder = sort ? JSON.parse(sort) : {};
76
- const pageNumber = parseInt(page, 10) || 1;
77
- const pageSize = parseInt(limit, 10) || 10;
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;
78
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
+ }
79
285
  let items;
80
286
  if (options.relatedModel && options.relatedMethods?.includes('GET')) {
81
- // Use aggregation with lookup if related model exists
82
287
  items = await this.model.aggregate([
83
- { $match: query },
288
+ { $match: filter },
84
289
  {
85
290
  $lookup: {
86
291
  from: options.relatedModel.collection.name,
87
292
  localField: options.relatedField,
88
293
  foreignField: '_id',
89
- as: 'relatedData'
90
- }
294
+ as: 'relatedData',
295
+ },
91
296
  },
92
- { $sort: sortOrder },
297
+ { $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } },
93
298
  { $skip: skip },
94
- { $limit: pageSize }
299
+ { $limit: pageSize },
95
300
  ]);
96
301
  }
97
302
  else {
98
- items = await this.model.find(query).sort(sortOrder).skip(skip).limit(pageSize);
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();
99
313
  }
100
- onSuccess(res, method, items);
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);
101
328
  }
102
329
  catch (error) {
103
- onError(res, method, error);
330
+ onError(res, 'GET', error);
104
331
  }
105
- }, middleware));
106
- this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit']);
332
+ }, middleware, routeMiddleware.read));
333
+ this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']);
107
334
  }
108
- // READ ONE operation - GET /endpoint/:id
335
+ // ── READ ONE GET /endpoint/:id ────────────────────────────────────
109
336
  if (methods.includes('GET')) {
110
337
  const path = `/${this.endpoint}/:id`;
111
- this.router.get(path, this.applyMiddleware(async (req, res) => {
112
- const method = 'GET';
338
+ this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
113
339
  try {
340
+ const selectStr = this.parseSelect(req.query.select, defaultSelect);
341
+ const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
114
342
  let item;
115
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 };
116
347
  const aggregateResult = await this.model.aggregate([
117
- { $match: { _id: req.params.id } },
348
+ { $match: matchFilter },
118
349
  {
119
350
  $lookup: {
120
351
  from: options.relatedModel.collection.name,
121
352
  localField: options.relatedField,
122
353
  foreignField: '_id',
123
- as: 'relatedData'
124
- }
125
- }
354
+ as: 'relatedData',
355
+ },
356
+ },
126
357
  ]);
127
358
  item = aggregateResult[0] || null;
128
359
  }
129
360
  else {
130
- item = await this.model.findById(req.params.id);
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();
131
370
  }
132
371
  if (!item) {
133
- return res.status(404).send({ message: 'Item not found' });
372
+ return res.status(404).json({ message: 'Item not found' });
134
373
  }
135
- onSuccess(res, method, item);
374
+ let result = item;
375
+ if (hooks.afterRead) {
376
+ result = (await hooks.afterRead(req, item)) ?? item;
377
+ }
378
+ onSuccess(res, 'GET', result);
136
379
  }
137
380
  catch (error) {
138
- onError(res, method, error);
381
+ onError(res, 'GET', error);
139
382
  }
140
- }, middleware));
141
- this.registerRoute('GET', path, ['id']);
383
+ }, middleware, routeMiddleware.read));
384
+ this.registerRoute('GET', path, ['id', 'select', 'populate']);
142
385
  }
143
- // UPDATE operation - PUT /endpoint/:id
386
+ // ── UPDATE (FULL) PUT /endpoint/:id ───────────────────────────────
144
387
  if (methods.includes('PUT')) {
145
388
  const path = `/${this.endpoint}/:id`;
146
- this.router.put(path, this.applyMiddleware(async (req, res) => {
147
- const method = 'PUT';
389
+ this.router.put(path, ...this.buildMiddlewareChain(async (req, res) => {
148
390
  try {
149
- const item = await this.model.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
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
+ });
150
405
  if (!item) {
151
- return res.status(404).send({ message: 'Item not found' });
406
+ return res.status(404).json({ message: 'Item not found' });
152
407
  }
153
- // Update related model entries if configured
154
408
  if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
155
- await options.relatedModel.updateMany({ [options.relatedField]: item._id }, req.body);
409
+ await options.relatedModel.updateMany({ [options.relatedField]: item._id }, data);
156
410
  }
157
- onSuccess(res, method, item);
411
+ if (hooks.afterUpdate) {
412
+ await hooks.afterUpdate(req, item);
413
+ }
414
+ onSuccess(res, 'PUT', item);
158
415
  }
159
416
  catch (error) {
160
- onError(res, method, error);
417
+ onError(res, 'PUT', error);
161
418
  }
162
- }, middleware));
419
+ }, middleware, routeMiddleware.update));
163
420
  this.registerRoute('PUT', path, ['id']);
164
421
  }
165
- // DELETE MULTIPLE operation - DELETE /endpoint?filter=...
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' });
440
+ }
441
+ if (hooks.afterUpdate) {
442
+ await hooks.afterUpdate(req, item);
443
+ }
444
+ onSuccess(res, 'PATCH', item);
445
+ }
446
+ catch (error) {
447
+ onError(res, 'PATCH', error);
448
+ }
449
+ }, middleware, routeMiddleware.update));
450
+ this.registerRoute('PATCH', path, ['id']);
451
+ }
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 ───────────────────────────────
166
496
  if (methods.includes('DELETE')) {
167
497
  const path = `/${this.endpoint}`;
168
- this.router.delete(path, this.applyMiddleware(async (req, res) => {
169
- const method = 'DELETE';
498
+ this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
170
499
  try {
171
- const query = req.query.filter ? JSON.parse(req.query.filter) : {};
172
- const deleteResult = await this.model.deleteMany(query);
173
- if (deleteResult.deletedCount === 0) {
174
- return res.status(404).send({ message: 'No matching items found to delete' });
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' });
175
517
  }
176
518
  if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
177
- await options.relatedModel.deleteMany({ [options.relatedField]: { $in: query } });
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
+ });
178
523
  }
179
- onSuccess(res, method, deleteResult);
524
+ onSuccess(res, 'DELETE', deleteResult);
180
525
  }
181
526
  catch (error) {
182
- onError(res, method, error);
527
+ onError(res, 'DELETE', error);
183
528
  }
184
- }, middleware));
529
+ }, middleware, routeMiddleware.delete));
185
530
  this.registerRoute('DELETE', path, ['filter']);
186
531
  }
187
- // DELETE ONE operation - DELETE /endpoint/:id
532
+ // ── DELETE ONE DELETE /endpoint/:id ────────────────────────────────
188
533
  if (methods.includes('DELETE')) {
189
534
  const path = `/${this.endpoint}/:id`;
190
- this.router.delete(path, this.applyMiddleware(async (req, res) => {
191
- const method = 'DELETE';
535
+ this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
192
536
  try {
193
- const item = await this.model.findByIdAndDelete(req.params.id);
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
+ }
194
547
  if (!item) {
195
- return res.status(404).send({ message: 'Item not found' });
548
+ return res.status(404).json({ message: 'Item not found' });
196
549
  }
197
550
  if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
198
- await options.relatedModel.deleteMany({ [options.relatedField]: item._id });
551
+ await options.relatedModel.deleteMany({
552
+ [options.relatedField]: item._id,
553
+ });
554
+ }
555
+ if (hooks.afterDelete) {
556
+ await hooks.afterDelete(req, item);
199
557
  }
200
- onSuccess(res, method, item);
558
+ onSuccess(res, 'DELETE', item);
201
559
  }
202
560
  catch (error) {
203
- onError(res, method, error);
561
+ onError(res, 'DELETE', error);
204
562
  }
205
- }, middleware));
563
+ }, middleware, routeMiddleware.delete));
206
564
  this.registerRoute('DELETE', path, ['id']);
207
565
  }
208
- // AGGREGATE operation - GET /endpoint/aggregate
209
- if (methods.includes('GET') && options.aggregatePipeline) {
210
- const path = `/${this.endpoint}/aggregate`;
211
- this.router.get(path, this.applyMiddleware(async (req, res) => {
212
- 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) => {
213
570
  try {
214
- const pipeline = options.aggregatePipeline || [];
215
- const results = await this.model.aggregate(pipeline);
216
- onSuccess(res, method, results);
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);
217
585
  }
218
586
  catch (error) {
219
- onError(res, method, error);
587
+ onError(res, 'DELETE (Bulk)', error);
220
588
  }
221
- }, middleware));
222
- this.registerRoute('GET', path);
589
+ }, middleware, routeMiddleware.delete));
590
+ this.registerRoute('DELETE', path);
223
591
  }
224
- // CUSTOM ROUTES
592
+ // ── CUSTOM ROUTES ───────────────────────────────────────────────────
225
593
  if (options.customRoutes) {
226
- options.customRoutes.forEach(route => {
227
- const { method, path, handler } = route;
228
- if (methods.includes(method.toUpperCase())) {
229
- // Prepend base endpoint to custom route path
230
- this.router[method](`/${this.endpoint}${path}`, this.applyMiddleware(handler, middleware));
231
- this.registerRoute(method.toUpperCase(), `/${this.endpoint}${path}`);
232
- }
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);
233
599
  });
234
600
  }
235
601
  }
236
- /**
237
- * Returns the configured Express router.
238
- */
602
+ // ── Public API ──────────────────────────────────────────────────────────
603
+ /** Returns the configured Express Router with all CRUD routes. */
239
604
  getRouter() {
240
605
  return this.router;
241
606
  }
242
- /**
243
- * Returns an array of registered route definitions.
244
- */
607
+ /** Returns an array of all registered route definitions. */
245
608
  getRoutes() {
246
609
  return this.routes;
247
610
  }
248
611
  }
249
612
 
250
- module.exports = CrudController;
613
+ exports.CrudController = CrudController;
614
+ exports.default = CrudController;