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 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 = [], onSuccess = (res, method, result) => res.status(200).send(result), onError = (res, method, error) => res.status(400).send(error), methods = ['POST', 'GET', 'PUT', 'DELETE'] } = options;
15
- const applyMiddleware = (routeHandler) => {
16
- return [...middleware, routeHandler];
17
- };
18
- // Helper to register a route
19
- const registerRoute = (method, path, params) => {
20
- this.routes.push({ method, path, params });
21
- };
22
- // Create
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, applyMiddleware(async (req, res) => {
26
- const method = 'POST';
90
+ this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
27
91
  try {
28
- const result = await this.model.create(req.body);
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({ [options.relatedField]: result._id, ...req.body });
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
- onSuccess(res, method, result);
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, method, error);
244
+ onError(res, 'GET (Aggregate)', error);
36
245
  }
37
- }));
38
- registerRoute('POST', path);
246
+ }, middleware, routeMiddleware.read));
247
+ this.registerRoute('GET', path);
39
248
  }
40
- // Read all
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, applyMiddleware(async (req, res) => {
44
- const method = 'GET';
269
+ this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
45
270
  try {
46
- const { filter, sort, page, limit } = req.query;
47
- const query = filter ? JSON.parse(filter) : {};
48
- const sortOrder = sort ? JSON.parse(sort) : {};
49
- const pageNumber = parseInt(page, 10) || 1;
50
- 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;
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: query },
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
- 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();
71
313
  }
72
- 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);
73
328
  }
74
329
  catch (error) {
75
- onError(res, method, error);
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
- // Read one
335
+ // ── READ ONE ─ GET /endpoint/:id ────────────────────────────────────
81
336
  if (methods.includes('GET')) {
82
337
  const path = `/${this.endpoint}/:id`;
83
- this.router.get(path, applyMiddleware(async (req, res) => {
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: { _id: req.params.id } },
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
- 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();
103
370
  }
104
371
  if (!item) {
105
- return res.status(404).send();
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, method, item);
378
+ onSuccess(res, 'GET', result);
108
379
  }
109
380
  catch (error) {
110
- onError(res, method, error);
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
- // Update
386
+ // ── UPDATE (FULL) ─ PUT /endpoint/:id ───────────────────────────────
116
387
  if (methods.includes('PUT')) {
117
388
  const path = `/${this.endpoint}/:id`;
118
- this.router.put(path, applyMiddleware(async (req, res) => {
119
- const method = 'PUT';
389
+ this.router.put(path, ...this.buildMiddlewareChain(async (req, res) => {
120
390
  try {
121
- 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
+ });
122
405
  if (!item) {
123
- return res.status(404).send();
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 }, req.body);
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
- onSuccess(res, method, item);
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, method, error);
447
+ onError(res, 'PATCH', error);
132
448
  }
133
- }));
134
- registerRoute('PUT', path, ['id']);
449
+ }, middleware, routeMiddleware.update));
450
+ this.registerRoute('PATCH', path, ['id']);
135
451
  }
136
- // Delete multiple
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, applyMiddleware(async (req, res) => {
140
- const method = 'DELETE';
498
+ this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
141
499
  try {
142
- const query = req.query.filter ? JSON.parse(req.query.filter) : {};
143
- const deleteResult = await this.model.deleteMany(query);
144
- if (deleteResult.deletedCount === 0) {
145
- return res.status(404).send();
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 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
+ });
149
523
  }
150
- onSuccess(res, method, deleteResult);
524
+ onSuccess(res, 'DELETE', deleteResult);
151
525
  }
152
526
  catch (error) {
153
- onError(res, method, error);
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
- // Delete one
532
+ // ── DELETE ONE ─ DELETE /endpoint/:id ────────────────────────────────
159
533
  if (methods.includes('DELETE')) {
160
534
  const path = `/${this.endpoint}/:id`;
161
- this.router.delete(path, applyMiddleware(async (req, res) => {
162
- const method = 'DELETE';
535
+ this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
163
536
  try {
164
- 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
+ }
165
547
  if (!item) {
166
- return res.status(404).send();
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({ [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);
170
557
  }
171
- onSuccess(res, method, item);
558
+ onSuccess(res, 'DELETE', item);
172
559
  }
173
560
  catch (error) {
174
- onError(res, method, error);
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
- // Aggregate
180
- if (methods.includes('GET') && options.aggregatePipeline) {
181
- const path = `/${this.endpoint}/aggregate`;
182
- this.router.get(path, applyMiddleware(async (req, res) => {
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 pipeline = options.aggregatePipeline ?? [];
186
- const results = await this.model.aggregate(pipeline);
187
- 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);
188
585
  }
189
586
  catch (error) {
190
- onError(res, method, error);
587
+ onError(res, 'DELETE (Bulk)', error);
191
588
  }
192
- }));
193
- registerRoute('GET', path);
589
+ }, middleware, routeMiddleware.delete));
590
+ this.registerRoute('DELETE', path);
194
591
  }
195
- // Custom routes
592
+ // ── CUSTOM ROUTES ───────────────────────────────────────────────────
196
593
  if (options.customRoutes) {
197
- options.customRoutes.forEach(route => {
198
- const { method, path, handler } = route;
199
- if (methods.includes(method.toUpperCase())) {
200
- this.router[method](`/${this.endpoint}${path}`, applyMiddleware(handler));
201
- registerRoute(method.toUpperCase(), `/${this.endpoint}${path}`);
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
- module.exports = CrudController;
613
+ exports.CrudController = CrudController;
614
+ exports.default = CrudController;