import { ObjectId, } from 'bson'; import { NextFunction, Request, Response, } from 'express'; import { Document, PopulateOptions, Types, } from 'mongoose'; import ApiController from './api.controller'; import { isString, toNumber, } from './helpers'; import { ApiDocument, ApiModel, IApiModel, IApiRequest, } from './types'; import { ApiSortQuery, IApiParsedQuery, } from './types/IApiQuery'; export type ServerResponsePromise = Promise>>; const isValidId = Types.ObjectId.isValid; abstract class BaseController extends ApiController { protected filters: string[]; constructor(model: ApiModel) { super(model); this.filters = [ 'type', 'deleted' ]; } public async index(req: IApiRequest, _res: Response, next: NextFunction): Promise { let query: IApiParsedQuery = { ...req.query, _q: '', deleted: false, limit: 100, offset: 0, q: {}, select: {}, sort: null, total: {}, }; const processedQuery = this.processQuery(req.query, query); try { if (typeof this.model.parseQuery === 'function') { query = this.model.parseQuery(processedQuery); } else { query = this.parseQuery(processedQuery); } } catch (error) { return next(error); } query.populate = query.populate ? query.populate : []; req.modelQuery = { total: query.q, offset: query.offset, limit: query.limit, }; return this.model.find(query.q) .limit(toNumber(query.limit)) .skip(toNumber(query.offset)) .sort(query.sort) .select(query.select) .populate(query.populate) .exec() .then((models: T[]) => { req.data = models; return next(); }) .catch((err) => next(err)); } public async read( req: IApiRequest, res: Response, _next: NextFunction, ): ServerResponsePromise { if (this.hasModel(req.model)) { const model = req.model.toObject(); return res.jsonp(model); } else { return this.respondModelMissingError(res); } } public async create( req: IApiRequest, res: Response, next: NextFunction, ): ServerResponsePromise { delete req.body._id; delete req.body.timestamps; // eslint-disable-next-line @typescript-eslint/naming-convention const Model = this.model; const entity = { ...req.body, timestamps: { created: { by: req.user?.username || 'missing', }, }, }; const model: T = new Model(entity); return model.save() .then((resModel) => (res.status(201).json(resModel.toObject()))) .catch((err) => this.respondValidationError(err, res, next)); } public async update( req: IApiRequest, res: Response, next: NextFunction, ): ServerResponsePromise { if (req.body._id === null) { delete req.body._id; } delete req.body.timestamps; if (this.hasModel(req.model)) { const model: any = req.model; Object.keys(req.body).forEach((key) => { model[key] = req.body[key]; }); model.timestamps.updated.by = req.user?.username || 'missing'; return model.save() .then((resModel: T) => res.status(200).json(resModel.toObject())) .catch((err: any) => this.respondValidationError(err, res, next)); } else { return this.respondModelMissingError(res); } } public async softDelete( req: IApiRequest, res: Response, _next: NextFunction, ): ServerResponsePromise { if (this.hasModel(req.model)) { const model = req.model; model.mark.deleted = true; model.timestamps.updated.by = req.user?.username || 'missing'; return model.save() .then((resModel) => res.status(200).jsonp(resModel.toObject())) .catch((err) => this.respondDeletionError(res, err)); } else { return Promise.resolve(this.respondModelMissingError(res)); } } public async delete( req: IApiRequest, res: Response, _next: NextFunction, ): ServerResponsePromise { if (this.hasModel(req.model)) { const model = req.model; return this.model.deleteOne({ _id: model._id }) .then(() => res.status(200).jsonp(model.toObject())) .catch((err: unknown) => { if (err instanceof Error) { return this.respondDeletionError(res, err); } else { return this.respondDeletionError(res, new Error('Unknown error occurred while deleting')); } }); } else { return this.respondModelMissingError(res); } } public async findById( req: IApiRequest, res: Response, next: NextFunction, id: string | number | ObjectId, _urlParam?: any, populate?: PopulateOptions[], ): ServerResponsePromise { if (isValidId(id)) { if (typeof populate === 'undefined') { populate = []; } return this.model .findById(id) // this for sure is not the right way. Inject type into method? // .findOne({}).populate<{ child: Child }>('child').orFail().then(doc => { // .populate>('child').orFail().then(doc .populate(populate) .exec() .then((model) => { if (model === null) { return this.respondNotFound(id, res, this.model.modelName); } else if (this.hasModel(model)){ req.model = model; return next(); } else { return this.respondModelMissingError(res); } }) .catch((err) => this.respondServerError(res, err)); } else { return this.respondInvalidId(res); } } public async stats(req: IApiRequest, res: Response, next: NextFunction): ServerResponsePromise { return this.model.countDocuments() .then((result) => { if (typeof req.stats !== 'object') { req.stats = {}; } req.stats[this.model.collection.name] = result; return next(); }) .catch((err) => this.respondServerError(res, err)); } public statsResponse(req: IApiRequest, res: Response, _next: NextFunction): Response { if (typeof req.stats !== 'object') { req.stats = {}; } return res.status(200).json(req.stats); } public async statistics(req: IApiRequest, res: Response, next: NextFunction): ServerResponsePromise { if (typeof this.model.statistics === 'function') { const query = req.dateRange || {}; return this.model.statistics(query).then((result) => { if (typeof req.stats !== 'object') { req.stats = {}; } req.stats[this.model.collection.name] = result; return next(); }) .catch((err) => this.respondServerError(res, err)); } else { return this.stats(req, res, next); } } public parseDateRange( req: IApiRequest, _res: Response, next: NextFunction, _id: string, _urlParam: string, ): void { // FIXME: this function is called twice for /year/month .... const year = parseInt(req.params.year, 10); let month = parseInt(req.params.month, 10); let toMonth = 12; if (!isNaN(year)) { if (isNaN(month)) { month = 0; } else { month = Math.max(Math.min(month, 12), 1); toMonth = --month + 1; } let from: Date = new Date(); from = new Date(from.setFullYear(year, month, 1)); from = new Date(from.setHours(0, 0, 0, 0)); let to = new Date(from.valueOf()); to = new Date(to.setFullYear(year, toMonth, 1)); if (typeof req.stats !== 'object') { req.stats = {}; } req.stats.range = { from: from, to: to, }; req.dateRange = { $and: [{ date: { $gte: from } }, { date: { $lt: to } }] }; } return next(); } public processQuery( query: Request['query'], defaultQuery: Readonly, ): IApiParsedQuery { const modelQuery: IApiParsedQuery = { ...query, _q: query.q?.toString() || '', offset: 0, deleted: false, limit: 100, q: {}, select: {}, sort: null, total: {}, }; if (typeof query.offset === 'string') { modelQuery.offset = this.parsePagination(query.offset, defaultQuery.offset); } if (typeof query.limit === 'string') { modelQuery.limit = this.parsePagination(query.limit, defaultQuery.limit); } if (typeof query.sort === 'string') { modelQuery.sort = this.parseSort(query.sort); } if (typeof query.select === 'string') { modelQuery.select = query.select.split(' ').reduce((acc, cur) => ({ ...acc, [cur]: true, }), {} as Record); } if (typeof query.filter === 'string') { modelQuery.filter = this.parseFilter(query.filter); } if (typeof query.deleted === 'string') { modelQuery.deleted = query.deleted === 'true'; } return modelQuery; } public parseSort(sort: string | null = null): ApiSortQuery | null { if (sort) { const parsedSort: ApiSortQuery = {}; let _sort: Record = {}; try { _sort = JSON.parse(sort); } catch (error: unknown) { /* istanbul ignore next */ if (error instanceof SyntaxError) { _sort = sort.split(' ') .filter(s => /^\w+$/.test(s)) .reduce((acc: any, cur) => { acc[cur] = 1; return acc; }, {}); } else { throw error; } } Object.entries(_sort).forEach(([ key, value ]) => { const order = isString(value) ? parseInt(value, 10) : value; parsedSort[key] = isNaN(order) ? 1 : Math.min(Math.max(order, -1), 1) as -1 | 1; }); if (Object.keys(parsedSort).length === 0) { parsedSort['date'] = -1; } return parsedSort; } else { return null; } } public parseFilter(filterQuery: string | null = null): Record { let filter: Record = {}; try { filter = filterQuery ? JSON.parse(filterQuery.replace(/\'/g, '"')) : {}; } catch (e) { filter = {}; } const allowedFilters: Record = {}; this.filters.forEach(f => { if (typeof filter[f] !== 'undefined' && filter[f] !== null) { allowedFilters[f] = filter[f].toString(); } }); return allowedFilters; } public parsePagination(value: string, defaultValue: string | number): number { const _value = toNumber(value); const _default = toNumber(defaultValue); return isNaN(_value) ? _default : _value; } public parseQuery(query: Partial): IApiParsedQuery { return { _q: query._q || '', q: {}, offset: query.offset || 0, limit: query.limit || 100, sort: query.sort ? { ...query.sort, } : null, filter: { ...query.filter, }, populate: query.populate ? [ ...query.populate ] : [], deleted: !!query.deleted, select: query.select ? { ...query.select, } : {}, total: {}, }; } } export default BaseController;