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
text/typescript
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;