UNPKG

crud-api-express

Version:

A powerful, flexible CRUD controller for Express + Mongoose — auto-generates RESTful endpoints with lifecycle hooks, validation, soft delete, search, bulk operations, pagination, and more.

1,016 lines (918 loc) 39.1 kB
import { Request, Response, Router, NextFunction } from 'express'; import { Document, Model, Types } from 'mongoose'; // ─── Exported Type Aliases ─────────────────────────────────────────────────── /** Express middleware function signature. */ export type MiddlewareFunction = (req: Request, res: Response, next: NextFunction) => void; /** Success callback invoked after a successful operation. */ export type SuccessHandler<T> = ( res: Response, method: string, result: T | T[] | any, meta?: PaginationMeta ) => void; /** Error callback invoked when an operation fails. */ export type ErrorHandler = (res: Response, method: string, error: Error) => void; /** Validation result returned by validate hooks. */ export interface ValidationResult { valid: boolean; errors?: string[]; } /** Pagination metadata included in list responses. */ export interface PaginationMeta { total: number; page: number; limit: number; pages: number; hasNext: boolean; hasPrev: boolean; } /** Allowed HTTP methods for the CRUD controller. */ export type HttpMethod = 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'; // ─── CrudOptions Interface ────────────────────────────────────────────────── /** * Configuration options for CrudController. * * Every option is optional — the controller works with zero configuration, * but each option unlocks additional flexibility. */ export interface CrudOptions<T extends Document> { // ── Core ────────────────────────────────────────────────────────────── /** * HTTP methods to enable. Defaults to all five. * @default ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'] */ methods?: HttpMethod[]; /** * Global middleware applied to **every** generated route. * For per-operation middleware, use `routeMiddleware` instead. */ middleware?: MiddlewareFunction[]; /** * Per-operation middleware — lets you apply different middleware * to different CRUD operations (e.g. only admins can delete). * * @example * routeMiddleware: { * delete: [requireAdmin], * create: [validateBody], * } */ routeMiddleware?: { create?: MiddlewareFunction[]; read?: MiddlewareFunction[]; update?: MiddlewareFunction[]; delete?: MiddlewareFunction[]; }; // ── Response Callbacks ──────────────────────────────────────────────── /** * Custom success response handler. * The 4th argument `meta` is provided on paginated list endpoints. */ onSuccess?: SuccessHandler<T>; /** Custom error response handler. */ onError?: ErrorHandler; // ── Lifecycle Hooks ─────────────────────────────────────────────────── /** * Lifecycle hooks that run before/after each operation. * `before*` hooks can transform data by returning a modified object. * `after*` hooks are for side-effects (logging, events, notifications). * * @example * hooks: { * beforeCreate: async (req, data) => { * data.createdBy = req.user.id; * return data; * }, * afterDelete: async (req, result) => { * await auditLog('delete', result._id); * }, * } */ hooks?: { beforeCreate?: (req: Request, data: any) => Promise<any> | any; afterCreate?: (req: Request, result: T) => Promise<void> | void; beforeUpdate?: (req: Request, id: string, data: any) => Promise<any> | any; afterUpdate?: (req: Request, result: T) => Promise<void> | void; beforeDelete?: (req: Request, id: string) => Promise<void> | void; afterDelete?: (req: Request, result: T) => Promise<void> | void; beforeRead?: (req: Request, query: any) => Promise<any> | any; afterRead?: (req: Request, result: T | T[]) => Promise<T | T[]> | (T | T[]); }; // ── Validation ──────────────────────────────────────────────────────── /** * Validation hooks that run **before** Mongoose validation. * Return `{ valid: false, errors: [...] }` to reject early with a 400. * * @example * validate: { * create: (data) => ({ * valid: !!data.email, * errors: data.email ? [] : ['Email is required'], * }), * } */ validate?: { create?: (data: any) => ValidationResult; update?: (data: any) => ValidationResult; }; // ── Query Features ──────────────────────────────────────────────────── /** * Default fields to return (Mongoose select syntax). * Can be overridden per-request via `?select=name,email`. * @example "name email -password" */ select?: string; /** * Auto-populate references on read operations. * Can be overridden per-request via `?populate=author,comments`. * @example "author" or ["author", { path: "comments", select: "text" }] */ populate?: string | object | (string | object)[]; /** * Fields to search across when using the `/search` endpoint. * Uses case-insensitive `$regex` matching. * @example ['name', 'email', 'description'] */ searchFields?: string[]; // ── Soft Delete ─────────────────────────────────────────────────────── /** * When `true`, DELETE operations set `deletedAt: new Date()` instead of * removing the document. GET operations auto-exclude soft-deleted records * unless `?includeDeleted=true` is passed. * * A restore endpoint `PATCH /endpoint/:id/restore` is also created. * @default false */ softDelete?: boolean; // ── Aggregation ─────────────────────────────────────────────────────── /** * MongoDB aggregation pipeline stages, or a function that receives the * request and returns pipeline stages (for dynamic pipelines). * * @example * // Static pipeline * aggregatePipeline: [{ $match: { status: 'Active' } }] * * // Dynamic pipeline * aggregatePipeline: (req) => [ * { $match: { region: req.query.region } }, * ] */ aggregatePipeline?: object[] | ((req: Request) => object[]); // ── Related Model ───────────────────────────────────────────────────── /** Related Mongoose model for cascading operations. */ relatedModel?: Model<any>; /** Field name linking the related model to this model. */ relatedField?: string; /** HTTP methods to cascade to the related model. */ relatedMethods?: HttpMethod[]; // ── Custom Routes ───────────────────────────────────────────────────── /** * Additional custom routes beyond standard CRUD. * Custom routes are **always** registered regardless of `methods` filter. * * @example * customRoutes: [{ * method: 'get', * path: '/stats', * handler: async (req, res) => { * const count = await Model.countDocuments(); * res.json({ count }); * }, * }] */ customRoutes?: { method: 'post' | 'get' | 'put' | 'patch' | 'delete'; path: string; middleware?: MiddlewareFunction[]; handler: (req: Request, res: Response) => void; }[]; } // ─── Route Info ───────────────────────────────────────────────────────────── export interface RouteInfo { method: string; path: string; params?: string[]; } // ─── CrudController Class ─────────────────────────────────────────────────── /** * A powerful, flexible CRUD Controller for Express + Mongoose. * * Automatically generates RESTful endpoints for any Mongoose model with * support for lifecycle hooks, validation, soft delete, bulk operations, * search, field selection, population, and more. * * @example * const ctrl = new CrudController(UserModel, 'users', { * methods: ['GET', 'POST', 'PATCH', 'DELETE'], * softDelete: true, * searchFields: ['name', 'email'], * hooks: { * beforeCreate: (req, data) => ({ ...data, createdBy: req.user.id }), * }, * }); * app.use('/api', ctrl.getRouter()); */ class CrudController<T extends Document> { private model: Model<T>; private endpoint: string; private router: Router; private routes: RouteInfo[]; constructor(model: Model<T>, endpoint: string, options: CrudOptions<T> = {}) { this.model = model; this.endpoint = endpoint; this.router = Router(); this.routes = []; this.configureRoutes(options); } // ── Helpers ───────────────────────────────────────────────────────────── /** * Merges global middleware + per-operation middleware + handler into a single array. */ private buildMiddlewareChain( handler: (req: Request, res: Response) => void, globalMiddleware: MiddlewareFunction[], operationMiddleware?: MiddlewareFunction[] ): (MiddlewareFunction | ((req: Request, res: Response) => void))[] { return [...globalMiddleware, ...(operationMiddleware || []), handler]; } /** Records a route in the internal registry. */ private registerRoute(method: string, path: string, params?: string[]): void { this.routes.push({ method, path, params }); } /** Parses a `?select=name,email` query param into Mongoose select syntax. */ private parseSelect(querySelect: string | undefined, defaultSelect?: string): string | undefined { if (querySelect) return (querySelect as string).split(',').join(' '); return defaultSelect; } /** Parses a `?populate=author,comments` query param. */ private parsePopulate( queryPopulate: string | undefined, defaultPopulate?: string | object | (string | object)[] ): any { if (queryPopulate) { return (queryPopulate as string).split(',').map((p) => p.trim()); } return defaultPopulate; } /** Safely parses JSON from a query param, returns fallback on failure. */ private safeJsonParse(value: string | undefined, fallback: any = {}): any { if (!value) return fallback; try { return JSON.parse(value); } catch { return fallback; } } /** Builds the soft-delete filter to exclude deleted records. */ private softDeleteFilter(req: Request, softDelete: boolean): object { if (!softDelete) return {}; const includeDeleted = req.query.includeDeleted === 'true'; return includeDeleted ? {} : { deletedAt: { $exists: false } }; } // ── Route Configuration ──────────────────────────────────────────────── private configureRoutes(options: CrudOptions<T>): void { const { middleware = [], routeMiddleware = {}, onSuccess = (res, method, result, meta) => { if (meta) { res.status(200).json({ data: result, pagination: meta }); } else { res.status(200).json(result); } }, onError = (res, method, error) => res.status(400).json({ error: error.message }), methods = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'] as HttpMethod[], hooks = {}, validate = {}, softDelete = false, select: defaultSelect, populate: defaultPopulate, searchFields = [], } = options; // ── CREATE ─ POST /endpoint ───────────────────────────────────────── if (methods.includes('POST')) { const path = `/${this.endpoint}`; this.router.post( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { // Validation hook if (validate.create) { const validation = validate.create(req.body); if (!validation.valid) { return res.status(400).json({ errors: validation.errors }); } } // Before hook let data = req.body; if (hooks.beforeCreate) { data = (await hooks.beforeCreate(req, data)) ?? data; } const result: any = await this.model.create(data); // Related model cascade if (options.relatedModel && options.relatedMethods?.includes('POST')) { await options.relatedModel.create({ [options.relatedField!]: result._id, ...data, }); } // After hook if (hooks.afterCreate) { await hooks.afterCreate(req, result); } onSuccess(res.status(201), 'POST', result); } catch (error: any) { onError(res, 'POST', error); } }, middleware, routeMiddleware.create ) ); this.registerRoute('POST', path); } // ── BULK CREATE ─ POST /endpoint/bulk ─────────────────────────────── if (methods.includes('POST')) { const path = `/${this.endpoint}/bulk`; this.router.post( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const items = req.body; if (!Array.isArray(items)) { return res.status(400).json({ error: 'Request body must be an array' }); } // Validate each item if (validate.create) { for (let i = 0; i < items.length; i++) { const validation = validate.create(items[i]); if (!validation.valid) { return res.status(400).json({ error: `Validation failed for item at index ${i}`, errors: validation.errors, }); } } } // Before hooks let processedItems = items; if (hooks.beforeCreate) { processedItems = []; for (const item of items) { const result = (await hooks.beforeCreate(req, item)) ?? item; processedItems.push(result); } } const results = await this.model.insertMany(processedItems); onSuccess(res.status(201), 'POST (Bulk)', results); } catch (error: any) { onError(res, 'POST (Bulk)', error); } }, middleware, routeMiddleware.create ) ); this.registerRoute('POST', path); } // ── SEARCH ─ GET /endpoint/search ─────────────────────────────────── if (methods.includes('GET') && searchFields.length > 0) { const path = `/${this.endpoint}/search`; this.router.get( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const q = req.query.q as string; if (!q) { return res.status(400).json({ error: 'Query parameter "q" is required' }); } const fieldsParam = req.query.fields as string | undefined; const fields = fieldsParam ? fieldsParam.split(',').map((f) => f.trim()) : searchFields; const searchQuery: any = { $or: fields.map((field) => ({ [field]: { $regex: q, $options: 'i' }, })), ...this.softDeleteFilter(req, softDelete), }; const pageNumber = parseInt(req.query.page as string, 10) || 1; const pageSize = parseInt(req.query.limit as string, 10) || 10; const skip = (pageNumber - 1) * pageSize; const selectStr = this.parseSelect(req.query.select as string, defaultSelect); const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate); let query = this.model.find(searchQuery).skip(skip).limit(pageSize); if (selectStr) query = query.select(selectStr); if (populateOpt) query = query.populate(populateOpt); const [items, total] = await Promise.all([ query.exec(), this.model.countDocuments(searchQuery), ]); const meta: PaginationMeta = { total, page: pageNumber, limit: pageSize, pages: Math.ceil(total / pageSize), hasNext: pageNumber * pageSize < total, hasPrev: pageNumber > 1, }; let result: any = items; if (hooks.afterRead) { result = (await hooks.afterRead(req, items as any)) ?? items; } onSuccess(res, 'GET (Search)', result, meta); } catch (error: any) { onError(res, 'GET (Search)', error); } }, middleware, routeMiddleware.read ) ); this.registerRoute('GET', path, ['q', 'fields', 'page', 'limit', 'select', 'populate']); } // ── COUNT ─ GET /endpoint/count ───────────────────────────────────── if (methods.includes('GET')) { const path = `/${this.endpoint}/count`; this.router.get( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const filter = { ...this.safeJsonParse(req.query.filter as string), ...this.softDeleteFilter(req, softDelete), }; const count = await this.model.countDocuments(filter); onSuccess(res, 'GET (Count)', { count }); } catch (error: any) { onError(res, 'GET (Count)', error); } }, middleware, routeMiddleware.read ) ); this.registerRoute('GET', path, ['filter']); } // ── AGGREGATE ─ GET /endpoint/aggregate ───────────────────────────── if (methods.includes('GET') && options.aggregatePipeline) { const path = `/${this.endpoint}/aggregate`; this.router.get( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const pipeline: any[] = typeof options.aggregatePipeline === 'function' ? options.aggregatePipeline(req) : options.aggregatePipeline || []; const results = await this.model.aggregate(pipeline); onSuccess(res, 'GET (Aggregate)', results); } catch (error: any) { onError(res, 'GET (Aggregate)', error); } }, middleware, routeMiddleware.read ) ); this.registerRoute('GET', path); } // ── EXISTS ─ GET /endpoint/exists/:id ─────────────────────────────── if (methods.includes('GET')) { const path = `/${this.endpoint}/exists/:id`; this.router.get( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const filter: any = { _id: req.params.id }; if (softDelete) filter.deletedAt = { $exists: false }; const exists = await this.model.exists(filter); onSuccess(res, 'GET (Exists)', { exists: !!exists }); } catch (error: any) { onError(res, 'GET (Exists)', error); } }, middleware, routeMiddleware.read ) ); this.registerRoute('GET', path, ['id']); } // ── READ ALL ─ GET /endpoint ──────────────────────────────────────── if (methods.includes('GET')) { const path = `/${this.endpoint}`; this.router.get( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { let filter = { ...this.safeJsonParse(req.query.filter as string), ...this.softDeleteFilter(req, softDelete), }; const sortOrder = this.safeJsonParse(req.query.sort as string); const pageNumber = parseInt(req.query.page as string, 10) || 1; const pageSize = parseInt(req.query.limit as string, 10) || 10; const skip = (pageNumber - 1) * pageSize; const selectStr = this.parseSelect(req.query.select as string, defaultSelect); const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate); // Before read hook if (hooks.beforeRead) { filter = (await hooks.beforeRead(req, filter)) ?? filter; } let items: T[]; if (options.relatedModel && options.relatedMethods?.includes('GET')) { items = await this.model.aggregate([ { $match: filter }, { $lookup: { from: options.relatedModel.collection.name, localField: options.relatedField!, foreignField: '_id', as: 'relatedData', }, }, { $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } }, { $skip: skip }, { $limit: pageSize }, ]); } else { let query = this.model .find(filter) .sort(Object.keys(sortOrder).length ? sortOrder : undefined) .skip(skip) .limit(pageSize); if (selectStr) query = query.select(selectStr); if (populateOpt) query = query.populate(populateOpt); items = await query.exec(); } const total = await this.model.countDocuments(filter); const meta: PaginationMeta = { total, page: pageNumber, limit: pageSize, pages: Math.ceil(total / pageSize), hasNext: pageNumber * pageSize < total, hasPrev: pageNumber > 1, }; let result: T | T[] = items; if (hooks.afterRead) { result = (await hooks.afterRead(req, items)) ?? items; } onSuccess(res, 'GET', result, meta); } catch (error: any) { onError(res, 'GET', error); } }, middleware, routeMiddleware.read ) ); this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']); } // ── READ ONE ─ GET /endpoint/:id ──────────────────────────────────── if (methods.includes('GET')) { const path = `/${this.endpoint}/:id`; this.router.get( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const selectStr = this.parseSelect(req.query.select as string, defaultSelect); const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate); let item: T | null; if (options.relatedModel && options.relatedMethods?.includes('GET')) { const matchFilter: any = { _id: new Types.ObjectId(req.params.id) }; if (softDelete) matchFilter.deletedAt = { $exists: false }; const aggregateResult = await this.model.aggregate([ { $match: matchFilter }, { $lookup: { from: options.relatedModel.collection.name, localField: options.relatedField!, foreignField: '_id', as: 'relatedData', }, }, ]); item = (aggregateResult[0] as T) || null; } else { const findFilter: any = { _id: req.params.id }; if (softDelete) findFilter.deletedAt = { $exists: false }; let query = this.model.findOne(findFilter); if (selectStr) query = query.select(selectStr); if (populateOpt) query = query.populate(populateOpt); item = await query.exec(); } if (!item) { return res.status(404).json({ message: 'Item not found' }); } let result: T | T[] = item; if (hooks.afterRead) { result = (await hooks.afterRead(req, item)) ?? item; } onSuccess(res, 'GET', result); } catch (error: any) { onError(res, 'GET', error); } }, middleware, routeMiddleware.read ) ); this.registerRoute('GET', path, ['id', 'select', 'populate']); } // ── UPDATE (FULL) ─ PUT /endpoint/:id ─────────────────────────────── if (methods.includes('PUT')) { const path = `/${this.endpoint}/:id`; this.router.put( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { if (validate.update) { const validation = validate.update(req.body); if (!validation.valid) { return res.status(400).json({ errors: validation.errors }); } } let data = req.body; if (hooks.beforeUpdate) { data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data; } const item = await this.model.findByIdAndUpdate(req.params.id, data, { new: true, runValidators: true, }); if (!item) { return res.status(404).json({ message: 'Item not found' }); } if (options.relatedModel && options.relatedMethods?.includes('PUT')) { await options.relatedModel.updateMany( { [options.relatedField!]: item._id }, data ); } if (hooks.afterUpdate) { await hooks.afterUpdate(req, item); } onSuccess(res, 'PUT', item); } catch (error: any) { onError(res, 'PUT', error); } }, middleware, routeMiddleware.update ) ); this.registerRoute('PUT', path, ['id']); } // ── UPDATE (PARTIAL) ─ PATCH /endpoint/:id ────────────────────────── if (methods.includes('PATCH')) { const path = `/${this.endpoint}/:id`; this.router.patch( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { if (validate.update) { const validation = validate.update(req.body); if (!validation.valid) { return res.status(400).json({ errors: validation.errors }); } } let data = req.body; if (hooks.beforeUpdate) { data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data; } const item = await this.model.findByIdAndUpdate( req.params.id, { $set: data }, { new: true, runValidators: true } ); if (!item) { return res.status(404).json({ message: 'Item not found' }); } if (hooks.afterUpdate) { await hooks.afterUpdate(req, item); } onSuccess(res, 'PATCH', item); } catch (error: any) { onError(res, 'PATCH', error); } }, middleware, routeMiddleware.update ) ); this.registerRoute('PATCH', path, ['id']); } // ── BULK UPDATE ─ PATCH /endpoint/bulk ────────────────────────────── if (methods.includes('PATCH')) { const path = `/${this.endpoint}/bulk`; this.router.patch( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const { filter, update } = req.body; if (!filter || !update) { return res.status(400).json({ error: 'Request body must contain "filter" and "update" objects', }); } if (validate.update) { const validation = validate.update(update); if (!validation.valid) { return res.status(400).json({ errors: validation.errors }); } } const result = await this.model.updateMany(filter, { $set: update }, { runValidators: true }); onSuccess(res, 'PATCH (Bulk)', result); } catch (error: any) { onError(res, 'PATCH (Bulk)', error); } }, middleware, routeMiddleware.update ) ); this.registerRoute('PATCH', path, ['filter', 'update']); } // ── SOFT DELETE RESTORE ─ PATCH /endpoint/:id/restore ─────────────── if (softDelete && (methods.includes('PATCH') || methods.includes('PUT'))) { const path = `/${this.endpoint}/:id/restore`; this.router.patch( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const item = await this.model.findByIdAndUpdate( req.params.id, { $unset: { deletedAt: 1 } }, { new: true } ); if (!item) { return res.status(404).json({ message: 'Item not found' }); } onSuccess(res, 'PATCH (Restore)', item); } catch (error: any) { onError(res, 'PATCH (Restore)', error); } }, middleware, routeMiddleware.update ) ); this.registerRoute('PATCH', path, ['id']); } // ── DELETE MULTIPLE ─ DELETE /endpoint ─────────────────────────────── if (methods.includes('DELETE')) { const path = `/${this.endpoint}`; this.router.delete( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const query = this.safeJsonParse(req.query.filter as string); if (Object.keys(query).length === 0) { return res.status(400).json({ error: 'A filter is required for bulk delete to prevent accidental data loss', }); } let deleteResult: any; if (softDelete) { deleteResult = await this.model.updateMany(query, { $set: { deletedAt: new Date() }, }); } else { deleteResult = await this.model.deleteMany(query); } if ((deleteResult.deletedCount ?? deleteResult.modifiedCount) === 0) { return res.status(404).json({ message: 'No matching items found to delete' }); } if (options.relatedModel && options.relatedMethods?.includes('DELETE')) { const ids = (await this.model.find(query).select('_id')).map((d: any) => d._id); await options.relatedModel.deleteMany({ [options.relatedField!]: { $in: ids }, }); } onSuccess(res, 'DELETE', deleteResult); } catch (error: any) { onError(res, 'DELETE', error); } }, middleware, routeMiddleware.delete ) ); this.registerRoute('DELETE', path, ['filter']); } // ── DELETE ONE ─ DELETE /endpoint/:id ──────────────────────────────── if (methods.includes('DELETE')) { const path = `/${this.endpoint}/:id`; this.router.delete( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { if (hooks.beforeDelete) { await hooks.beforeDelete(req, req.params.id); } let item: T | null; if (softDelete) { item = await this.model.findByIdAndUpdate( req.params.id, { $set: { deletedAt: new Date() } as any }, { new: true } ); } else { item = await this.model.findByIdAndDelete(req.params.id); } if (!item) { return res.status(404).json({ message: 'Item not found' }); } if (options.relatedModel && options.relatedMethods?.includes('DELETE')) { await options.relatedModel.deleteMany({ [options.relatedField!]: item._id, }); } if (hooks.afterDelete) { await hooks.afterDelete(req, item); } onSuccess(res, 'DELETE', item); } catch (error: any) { onError(res, 'DELETE', error); } }, middleware, routeMiddleware.delete ) ); this.registerRoute('DELETE', path, ['id']); } // ── BULK DELETE ─ DELETE /endpoint/bulk ────────────────────────────── if (methods.includes('DELETE')) { const path = `/${this.endpoint}/bulk`; this.router.delete( path, ...this.buildMiddlewareChain( async (req: Request, res: Response) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ error: 'Request body must contain a non-empty "ids" array', }); } let deleteResult: any; if (softDelete) { deleteResult = await this.model.updateMany( { _id: { $in: ids } }, { $set: { deletedAt: new Date() } } ); } else { deleteResult = await this.model.deleteMany({ _id: { $in: ids } }); } onSuccess(res, 'DELETE (Bulk)', deleteResult); } catch (error: any) { onError(res, 'DELETE (Bulk)', error); } }, middleware, routeMiddleware.delete ) ); this.registerRoute('DELETE', path); } // ── CUSTOM ROUTES ─────────────────────────────────────────────────── if (options.customRoutes) { options.customRoutes.forEach((route) => { const { method, path, handler, middleware: routeMw } = route; const fullPath = `/${this.endpoint}${path}`; this.router[method]( fullPath, ...this.buildMiddlewareChain(handler, middleware, routeMw) ); this.registerRoute(method.toUpperCase(), fullPath); }); } } // ── Public API ────────────────────────────────────────────────────────── /** Returns the configured Express Router with all CRUD routes. */ public getRouter(): Router { return this.router; } /** Returns an array of all registered route definitions. */ public getRoutes(): RouteInfo[] { return this.routes; } } export { CrudController }; export default CrudController;