diff --git a/.eslintrc.json b/.eslintrc.json index 9f7adf7..37a3539 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -29,7 +29,8 @@ "{}": false } } - ] + ], + "@typescript-eslint/ban-ts-comment": "warn" }, "ignorePatterns": ["node_modules/", "dist/", "build/"] } diff --git a/api.rest b/api.rest new file mode 100644 index 0000000..e2cccd9 --- /dev/null +++ b/api.rest @@ -0,0 +1,4 @@ +https://documenter.getpostman.com/view/15120939/2sA3kUGMx7 + + +Feel free to let comments is something going wrong \ No newline at end of file diff --git a/src/apps/auth/models/_plugins/attemp-limiting.plugin.ts b/src/apps/auth/models/_plugins/attemp-limiting.plugin.ts new file mode 100644 index 0000000..f947e96 --- /dev/null +++ b/src/apps/auth/models/_plugins/attemp-limiting.plugin.ts @@ -0,0 +1,23 @@ +/** + * Ceci c'est juste un exemple de plugin + */ + +import { Schema } from 'mongoose'; + +export const attemptLimitingPlugin = ( + schema: Schema, + options?: { maxAttempts?: number }, +) => { + schema.add({ attempts: { type: Number, default: 0 } }); + + schema.methods.incrementAttempts = async function () { + this.attempts += 1; + const maxAttempts = options?.maxAttempts || 3; + + if (this.attempts >= maxAttempts) { + this.used = true; + this.isFresh = false; + } + await this.save(); + }; +}; diff --git a/src/apps/auth/models/_plugins/index.ts b/src/apps/auth/models/_plugins/index.ts new file mode 100644 index 0000000..c8158a4 --- /dev/null +++ b/src/apps/auth/models/_plugins/index.ts @@ -0,0 +1 @@ +export * from './attemp-limiting.plugin'; diff --git a/src/apps/auth/models/otp.model.ts b/src/apps/auth/models/otp.model.ts index 6eabb6f..248abc2 100644 --- a/src/apps/auth/models/otp.model.ts +++ b/src/apps/auth/models/otp.model.ts @@ -1,8 +1,12 @@ -import { Schema, model } from 'mongoose'; import { IOTPModel } from '../types'; import { config } from '../../../core/config'; +import { Schema } from 'mongoose'; +import { BaseModel, createBaseSchema } from '../../../core/engine'; +import { attemptLimitingPlugin } from './_plugins'; -const otpSchema: Schema = new Schema( +const OTP_MODEL_NAME = 'OTP'; + +const otpSchema = createBaseSchema( { code: { type: String, @@ -30,10 +34,18 @@ const otpSchema: Schema = new Schema( enum: Object.keys(config.otp.purposes), required: true, }, + attempts: { + type: Number, + default: 0, + }, + }, + { + excludePlugins: ['softDelete'], + includePlugins: [[attemptLimitingPlugin, { maxAttempts: 5 }]], + modelName: OTP_MODEL_NAME, }, - { timestamps: true }, ); -const OTPModel = model('OTP', otpSchema); +const OTPModel = new BaseModel(OTP_MODEL_NAME, otpSchema).getModel(); export default OTPModel; diff --git a/src/apps/auth/types/otp.ts b/src/apps/auth/types/otp.ts index 930651c..6e91c21 100644 --- a/src/apps/auth/types/otp.ts +++ b/src/apps/auth/types/otp.ts @@ -1,17 +1,17 @@ -import { Document } from 'mongoose'; +import { Document, Types } from 'mongoose'; import { config } from '../../../core/config'; +import { IBaseModel } from '../../../core/engine'; export type TOTPPurpose = keyof typeof config.otp.purposes; export interface IOTP { code: string; - user: string; + user: Types.ObjectId; used: boolean; isFresh: boolean; expiresAt: Date; purpose: TOTPPurpose; - createdAt?: Date; - updatedAt?: Date; + attempts?: number; } -export interface IOTPModel extends IOTP, Document {} +export interface IOTPModel extends IOTP, IBaseModel, Document {} diff --git a/src/apps/users/models/user.model.ts b/src/apps/users/models/user.model.ts index 311322c..5238d59 100644 --- a/src/apps/users/models/user.model.ts +++ b/src/apps/users/models/user.model.ts @@ -1,9 +1,12 @@ -import { Schema, model, CallbackError } from 'mongoose'; +import { CallbackError } from 'mongoose'; import bcrypt from 'bcrypt'; import { IUserModel } from '../types'; +import { BaseModel, createBaseSchema } from '../../../core/engine'; import { config } from '../../../core/config'; -const UserSchema: Schema = new Schema( +const USER_MODEL_NAME = 'User'; + +const UserSchema = createBaseSchema( { firstname: { type: String, required: true }, lastname: { type: String, required: true }, @@ -14,15 +17,16 @@ const UserSchema: Schema = new Schema( active: { type: Boolean, default: true }, verified: { type: Boolean, default: false }, }, - { timestamps: true }, + { + modelName: USER_MODEL_NAME, + }, ); -// Pre-hook for hashing the password before saving UserSchema.pre('save', async function (next) { try { if (this.isNew || this.isModified('password')) { const salt = await bcrypt.genSalt(config.bcrypt.saltRounds); - const hashedPassword = await bcrypt.hash(this.password as string, salt); + const hashedPassword = await bcrypt.hash(this.password, salt); this.password = hashedPassword; } next(); @@ -31,6 +35,9 @@ UserSchema.pre('save', async function (next) { } }); -const UserModel = model('User', UserSchema); +const UserModel = new BaseModel( + USER_MODEL_NAME, + UserSchema, +).getModel(); export default UserModel; diff --git a/src/apps/users/routes/user.routes.ts b/src/apps/users/routes/user.routes.ts index 39184f0..d132f66 100644 --- a/src/apps/users/routes/user.routes.ts +++ b/src/apps/users/routes/user.routes.ts @@ -1,12 +1,24 @@ import { Router } from 'express'; -import { authenticateRequest, validate } from '../../../common/shared'; +import { + authenticateAndAttachUserContext, + validate, +} from '../../../common/shared'; import { createUserSchema } from '../validators'; import { UserController } from '../controllers'; const router = Router(); -router.post('/', validate(createUserSchema), UserController.createUser); +router.post( + '/', + authenticateAndAttachUserContext, + validate(createUserSchema), + UserController.createUser, +); router.get('/', UserController.getAllUsers); -router.get('/current', authenticateRequest, UserController.getCurrentUser); +router.get( + '/current', + authenticateAndAttachUserContext, + UserController.getCurrentUser, +); router.get('/:id', UserController.getUserById); export default router; diff --git a/src/apps/users/types/user.ts b/src/apps/users/types/user.ts index 3da3d69..9bf3cd0 100644 --- a/src/apps/users/types/user.ts +++ b/src/apps/users/types/user.ts @@ -1,8 +1,11 @@ +// src/apps/users/types/user.ts + import { Document } from 'mongoose'; +import { IBaseModel } from '../../../core/engine'; // Importer IBaseModel pour l'extension export type TUserRole = 'admin' | 'user' | 'guest'; -export interface IUser { +export interface IUser extends IBaseModel { firstname: string; lastname: string; email: string; @@ -11,8 +14,6 @@ export interface IUser { profilePhoto?: string; verified: boolean; active: boolean; - createdAt?: Date; - updatedAt?: Date; } -export interface IUserModel extends IUser, Document {} +export interface IUserModel extends IUser, IBaseModel, Document {} diff --git a/src/common/shared/middlewares/attach-user-to-context.ts b/src/common/shared/middlewares/attach-user-to-context.ts new file mode 100644 index 0000000..f2de4e9 --- /dev/null +++ b/src/common/shared/middlewares/attach-user-to-context.ts @@ -0,0 +1,25 @@ +import { Request, Response, NextFunction } from 'express'; +import { AsyncStorageService, logger } from '../services'; + +export const attachUserToContext = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const asyncStorage = AsyncStorageService.getInstance(); + // @ts-ignore: Suppress TS error for non-existent property + const payload = req.payload; + if (payload && typeof payload.aud === 'string') { + const userId = payload.aud; + + asyncStorage.run(() => { + asyncStorage.set('currentUserId', userId); + next(); + }); + } else { + logger.warn( + 'Warning: Unable to attach user context, missing payload or audience field.', + ); + next(); + } +}; diff --git a/src/common/shared/middlewares/authenticate-req-with-user-attach.ts b/src/common/shared/middlewares/authenticate-req-with-user-attach.ts new file mode 100644 index 0000000..851f34d --- /dev/null +++ b/src/common/shared/middlewares/authenticate-req-with-user-attach.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express'; +import { JwtService, AsyncStorageService, logger } from '../services'; + +export const authenticateAndAttachUserContext = ( + req: Request, + res: Response, + next: NextFunction, +) => { + JwtService.verifyAccessToken(req, res, (authErr: any) => { + if (authErr) { + return next(authErr); + } + + // @ts-ignore: Suppress TS error for non-existent property + const payload = req.payload; + + if (payload && typeof payload.aud === 'string') { + const userId = payload.aud; + const asyncStorage = AsyncStorageService.getInstance(); + + asyncStorage.run(() => { + asyncStorage.set('currentUserId', userId); + next(); + }); + } else { + logger.warn( + 'Warning: Unable to attach user context, missing payload or audience field.', + ); + next(); + } + }); +}; diff --git a/src/common/shared/middlewares/index.ts b/src/common/shared/middlewares/index.ts index 198088f..2403e49 100644 --- a/src/common/shared/middlewares/index.ts +++ b/src/common/shared/middlewares/index.ts @@ -3,3 +3,4 @@ export { default as authenticateRequest } from './authenticate-request'; export { default as bruteForceMiddleware } from './bruteforce'; export * from './rate-limiter'; export * from './validate'; +export * from './authenticate-req-with-user-attach'; diff --git a/src/common/shared/services/async-localstorage.service.ts b/src/common/shared/services/async-localstorage.service.ts new file mode 100644 index 0000000..2928304 --- /dev/null +++ b/src/common/shared/services/async-localstorage.service.ts @@ -0,0 +1,33 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export class AsyncStorageService { + private static instance: AsyncStorageService; + private storage: AsyncLocalStorage>; + + private constructor() { + this.storage = new AsyncLocalStorage(); + } + + public static getInstance(): AsyncStorageService { + if (!AsyncStorageService.instance) { + AsyncStorageService.instance = new AsyncStorageService(); + } + return AsyncStorageService.instance; + } + + public set(key: string, value: any) { + const store = this.storage.getStore(); + if (store) { + store.set(key, value); + } + } + + public get(key: string): any { + const store = this.storage.getStore(); + return store ? store.get(key) : undefined; + } + + public run(callback: () => void, initialValue?: Map) { + this.storage.run(initialValue || new Map(), callback); + } +} diff --git a/src/common/shared/services/index.ts b/src/common/shared/services/index.ts index fa51614..48a9824 100644 --- a/src/common/shared/services/index.ts +++ b/src/common/shared/services/index.ts @@ -2,3 +2,4 @@ export { default as JwtService } from './jwt.service'; export { default as ViewService } from './view.service'; export * from './mail'; export * from './logger.service'; +export * from './async-localstorage.service'; diff --git a/src/common/shared/types/express.d.ts b/src/common/shared/types/express.d.ts index 51416d7..0217aed 100644 --- a/src/common/shared/types/express.d.ts +++ b/src/common/shared/types/express.d.ts @@ -3,5 +3,6 @@ import { JwtPayload } from 'jsonwebtoken'; declare module 'express-serve-static-core' { interface Request { payload?: JwtPayload; + mongooseOptions?: Record; } } diff --git a/src/core/engine/base/_models/_plugins/audit-trail.plugin.ts b/src/core/engine/base/_models/_plugins/audit-trail.plugin.ts new file mode 100644 index 0000000..11aa311 --- /dev/null +++ b/src/core/engine/base/_models/_plugins/audit-trail.plugin.ts @@ -0,0 +1,32 @@ +import { Schema } from 'mongoose'; +import { AsyncStorageService, logger } from '../../../../../common/shared'; + +const auditTrailPlugin = (schema: Schema) => { + schema.pre('save', function (next) { + const currentUserId = + AsyncStorageService.getInstance().get('currentUserId'); + + if (!currentUserId) { + logger.warn( + 'Warning: currentUserId is undefined. Audit trail fields will not be set.', + ); + } + + if (this.isNew) { + this.set('createdBy', currentUserId || null); + } else { + this.set('updatedBy', currentUserId || null); + } + next(); + }); + + schema.methods.softDelete = function () { + const currentUserId = + AsyncStorageService.getInstance().get('currentUserId'); + this.deletedAt = new Date(); + this.deletedBy = currentUserId || null; + return this.save(); + }; +}; + +export default auditTrailPlugin; diff --git a/src/core/engine/base/_models/_plugins/history.plugin.ts b/src/core/engine/base/_models/_plugins/history.plugin.ts new file mode 100644 index 0000000..ea9b2b2 --- /dev/null +++ b/src/core/engine/base/_models/_plugins/history.plugin.ts @@ -0,0 +1,159 @@ +import { model, Schema, Document, Types } from 'mongoose'; +import { AsyncStorageService } from '../../../../../common/shared'; + +interface IHistoryDocument extends Document { + originalId: Types.ObjectId; + changes: Record; + snapshot?: any; + modelName: string; + action: 'create' | 'update' | 'softDelete' | 'hardDelete' | 'restore'; + modifiedBy?: Types.ObjectId; +} + +const historySchema = new Schema( + { + originalId: { type: Schema.Types.ObjectId, required: true }, + changes: { type: Object, required: true }, + snapshot: { type: Object }, + modelName: { type: String, required: true }, + action: { + type: String, + enum: ['create', 'update', 'softDelete', 'hardDelete', 'restore'], + required: true, + }, + modifiedBy: { type: Types.ObjectId, ref: 'User' }, + }, + { timestamps: true }, +); + +const HistoryModel = model('History', historySchema); + +const historyPlugin = ( + schema: Schema, + options: { modelName: string }, +) => { + const createHistoryEntry = async ( + doc: Document, + action: string, + changes: Record = {}, + snapshot?: any, + ) => { + const currentUserId = + AsyncStorageService.getInstance().get('currentUserId'); + + await new HistoryModel({ + originalId: doc._id, + changes, + snapshot, + modelName: options.modelName, + action, + modifiedBy: currentUserId, + }).save(); + }; + + schema.pre('save', async function (next) { + const action = this.isNew ? 'create' : 'update'; + const changes = this.isNew + ? this.toObject() + : this.modifiedPaths().reduce( + (acc: Record, path: string) => { + acc[path] = this.get(path); + return acc; + }, + {}, + ); + + const snapshot = this.toObject(); + + await createHistoryEntry(this, action, changes, snapshot); + next(); + }); + + schema.methods.softDelete = async function ( + this: T & { deletedAt: Date | null }, + ) { + this.deletedAt = new Date(); + await this.save(); + const snapshot = this.toObject(); + await createHistoryEntry( + this as Document, + 'softDelete', + { deletedAt: this.deletedAt }, + snapshot, + ); + }; + + schema.methods.restore = async function ( + this: T & { deletedAt: Date | null }, + ) { + this.deletedAt = null; + await this.save(); + const snapshot = this.toObject(); + await createHistoryEntry( + this as Document, + 'restore', + { deletedAt: null }, + snapshot, + ); + }; + + schema.pre( + 'deleteOne', + { document: true, query: false }, + async function (next) { + const snapshot = this.toObject(); + await createHistoryEntry(this, 'hardDelete', {}, snapshot); + next(); + }, + ); + + schema.pre('findOneAndDelete', async function (next) { + const doc = await this.model.findOne(this.getQuery()); + if (doc) { + const snapshot = doc.toObject(); + await createHistoryEntry(doc as Document, 'hardDelete', {}, snapshot); + } + next(); + }); + + schema.pre('findOneAndUpdate', async function (next) { + const doc = await this.model.findOne(this.getQuery()); + const changes = this.getUpdate(); + if (doc) { + const snapshot = { ...doc.toObject(), ...changes }; + await createHistoryEntry( + doc as Document, + 'update', + changes as Record, + snapshot, + ); + } + next(); + }); + + schema.pre('deleteMany', async function (next) { + const docs = await this.model.find(this.getQuery()); + for (const doc of docs) { + const snapshot = doc.toObject(); + await createHistoryEntry(doc as Document, 'hardDelete', {}, snapshot); + } + next(); + }); + + schema.pre('updateMany', async function (next) { + const updates = this.getUpdate(); + const docs = await this.model.find(this.getQuery()); + for (const doc of docs) { + const snapshot = { ...doc.toObject(), ...updates }; + await createHistoryEntry( + doc as Document, + 'update', + updates as Record, + snapshot, + ); + } + next(); + }); +}; + +export default historyPlugin; diff --git a/src/core/engine/base/_models/_plugins/index.plugin.ts b/src/core/engine/base/_models/_plugins/index.plugin.ts new file mode 100644 index 0000000..34bf874 --- /dev/null +++ b/src/core/engine/base/_models/_plugins/index.plugin.ts @@ -0,0 +1,9 @@ +import { Schema } from 'mongoose'; +const indexPlugin = ( + schema: Schema, + options: { fields: Record }, +) => { + schema.index(options.fields); +}; + +export default indexPlugin; diff --git a/src/core/engine/base/_models/_plugins/index.ts b/src/core/engine/base/_models/_plugins/index.ts new file mode 100644 index 0000000..003261e --- /dev/null +++ b/src/core/engine/base/_models/_plugins/index.ts @@ -0,0 +1,59 @@ +// src/core/engine/base/_plugins/PluginManager.ts + +import { Schema } from 'mongoose'; +import softDeletePlugin from './soft-delete.plugin'; +import versioningPlugin from './versioning.plugin'; +import auditTrailPlugin from './audit-trail.plugin'; +import historyPlugin from './history.plugin'; +import indexPlugin from './index.plugin'; + +type PluginFunction = (schema: Schema, options?: any) => void; +type PluginWithOptions = [PluginFunction, object?]; + +const PluginManager = { + basePlugins: new Map([ + ['auditTrail', [auditTrailPlugin as PluginFunction]], + ['versioning', [versioningPlugin as PluginFunction]], + ['softDelete', [softDeletePlugin as PluginFunction]], + ['history', [historyPlugin as PluginFunction]], + [ + 'index', + [ + indexPlugin as PluginFunction, + { fields: { createdAt: 1, updatedAt: 1 } }, + ], + ], + ]), + + applyPlugins( + schema: Schema, + options: { + exclude?: string[]; + include?: PluginWithOptions[]; + modelName?: string; + } = {}, + ) { + const { exclude = [], include = [], modelName } = options; + + this.basePlugins.forEach(([plugin, defaultOptions], name) => { + if (!exclude.includes(name)) { + const pluginOptions = { + ...(defaultOptions || {}), + ...(name === 'history' && modelName ? { modelName } : {}), + }; + schema.plugin(plugin, pluginOptions); + } + }); + + include.forEach(([plugin, opts]) => { + const pluginOptions = { ...(opts || {}), modelName }; + schema.plugin(plugin, pluginOptions); + }); + }, + + registerBasePlugin(name: string, plugin: PluginFunction, options?: object) { + this.basePlugins.set(name, [plugin, options]); + }, +}; + +export default PluginManager; diff --git a/src/core/engine/base/_models/_plugins/soft-delete.plugin.ts b/src/core/engine/base/_models/_plugins/soft-delete.plugin.ts new file mode 100644 index 0000000..9ad553c --- /dev/null +++ b/src/core/engine/base/_models/_plugins/soft-delete.plugin.ts @@ -0,0 +1,28 @@ +import { Schema } from 'mongoose'; + +const softDeletePlugin = (schema: Schema) => { + const deletedAtField = 'deletedAt'; + + schema.add({ [deletedAtField]: { type: Date, default: null } }); + + schema.methods.softDelete = async function () { + this[deletedAtField] = new Date(); + await this.save(); + }; + + schema.methods.restore = async function () { + this[deletedAtField] = null; + await this.save(); + }; + + const addNotDeletedCondition = function (this: any) { + this.where({ [deletedAtField]: null }); + }; + + schema.pre('find', addNotDeletedCondition); + schema.pre('findOne', addNotDeletedCondition); + schema.pre('findOneAndUpdate', addNotDeletedCondition); + schema.pre('updateMany', addNotDeletedCondition); +}; + +export default softDeletePlugin; diff --git a/src/core/engine/base/_models/_plugins/versioning.plugin.ts b/src/core/engine/base/_models/_plugins/versioning.plugin.ts new file mode 100644 index 0000000..4c4e0be --- /dev/null +++ b/src/core/engine/base/_models/_plugins/versioning.plugin.ts @@ -0,0 +1,42 @@ +import { Schema, Document } from 'mongoose'; + +interface IVersionedDocument extends Document { + __version__: number; +} + +const versioningPlugin = (schema: Schema) => { + schema.add({ __version__: { type: Number, default: 0 } }); + + const incrementVersion = (update: any) => { + if (update) { + if (!update.$set) { + update.$set = {}; + } + update.$set.__version__ = (update.$set.__version__ || 0) + 1; + } + }; + + schema.pre('save', function (next) { + if (!this.isNew) { + this.__version__ += 1; + } + next(); + }); + + schema.pre('updateOne', function (next) { + incrementVersion(this.getUpdate()); + next(); + }); + + schema.pre('updateMany', function (next) { + incrementVersion(this.getUpdate()); + next(); + }); + + schema.pre('findOneAndUpdate', function (next) { + incrementVersion(this.getUpdate()); + next(); + }); +}; + +export default versioningPlugin; diff --git a/src/core/engine/base/_models/base.model.ts b/src/core/engine/base/_models/base.model.ts new file mode 100644 index 0000000..0321a3e --- /dev/null +++ b/src/core/engine/base/_models/base.model.ts @@ -0,0 +1,54 @@ +import { Schema, Document, model as mongooseModel, Model } from 'mongoose'; +import PluginManager from './_plugins'; + +interface IBaseModel extends Document { + deletedAt?: Date | null; + deletedBy?: string | null; + createdBy?: string | null; + updatedBy?: string | null; + __version__?: number; + [key: string]: any; +} + +function createBaseSchema( + definition: Record, + options: { + excludePlugins?: string[]; + includePlugins?: [(schema: Schema, options?: any) => void, object?][]; + modelName?: string; + } = {}, +): Schema { + const baseSchema = new Schema( + { + ...definition, + deletedAt: { type: Date, default: null }, + deletedBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, + createdBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, + updatedBy: { type: Schema.Types.ObjectId, ref: 'User', default: null }, + __version__: { type: Number, default: 0 }, + }, + { timestamps: true }, + ); + + PluginManager.applyPlugins(baseSchema, { + exclude: options.excludePlugins, + include: options.includePlugins, + modelName: options.modelName, + }); + + return baseSchema; +} + +class BaseModel { + private model: Model; + + constructor(modelName: string, schema: Schema) { + this.model = mongooseModel(modelName, schema); + } + + getModel() { + return this.model; + } +} + +export { createBaseSchema, BaseModel, IBaseModel }; diff --git a/src/core/engine/base/_models/empty.txt b/src/core/engine/base/_models/empty.txt deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/engine/base/_models/index.ts b/src/core/engine/base/_models/index.ts new file mode 100644 index 0000000..cead91e --- /dev/null +++ b/src/core/engine/base/_models/index.ts @@ -0,0 +1,2 @@ +export * from './base.model'; +export * from './_plugins'; diff --git a/src/core/engine/base/_repositories/base.repo.ts b/src/core/engine/base/_repositories/base.repo.ts index 95b66f0..2227ea7 100644 --- a/src/core/engine/base/_repositories/base.repo.ts +++ b/src/core/engine/base/_repositories/base.repo.ts @@ -22,36 +22,64 @@ export class BaseRepository { async findAll( query: FilterQuery = {}, options: QueryOptions = {}, + includeDeleted = false, ): Promise { - return await this.model.find(query, null, options).exec(); + const effectiveQuery = includeDeleted + ? query + : { ...query, deletedAt: null }; + return await this.model.find(effectiveQuery, null, options).exec(); } async findOne( query: FilterQuery, options: QueryOptions = {}, + includeDeleted = false, ): Promise { - return await this.model.findOne(query, null, options).exec(); + const effectiveQuery = includeDeleted + ? query + : { ...query, deletedAt: null }; + return await this.model.findOne(effectiveQuery, null, options).exec(); } async update( query: FilterQuery, update: UpdateQuery, options: QueryOptions = {}, + includeDeleted = false, ): Promise { + const effectiveQuery = includeDeleted + ? query + : { ...query, deletedAt: null }; return await this.model - .findOneAndUpdate(query, update, { new: true, ...options }) + .findOneAndUpdate(effectiveQuery, update, { new: true, ...options }) .exec(); } async delete( query: FilterQuery, options: QueryOptions = {}, + softDelete = true, ): Promise { - return await this.model.findOneAndDelete(query, options).exec(); + if (softDelete) { + return await this.update( + query, + { $set: { deletedAt: new Date() } } as UpdateQuery, + options, + true, + ); + } else { + return await this.model.findOneAndDelete(query, options).exec(); + } } - async countDocuments(query: FilterQuery = {}): Promise { - return await this.model.countDocuments(query).exec(); + async countDocuments( + query: FilterQuery = {}, + includeDeleted = false, + ): Promise { + const effectiveQuery = includeDeleted + ? query + : { ...query, deletedAt: null }; + return await this.model.countDocuments(effectiveQuery).exec(); } async aggregate(pipeline: PipelineStage[]): Promise { diff --git a/src/core/engine/base/_services/base.service.ts b/src/core/engine/base/_services/base.service.ts index 48fd85e..83f60e0 100644 --- a/src/core/engine/base/_services/base.service.ts +++ b/src/core/engine/base/_services/base.service.ts @@ -12,8 +12,8 @@ export class BaseService> { protected handleSlug: boolean; protected uniqueFields: string[]; protected populateFields: string[]; - protected allowedFilterFields?: string[]; /* For other probable filters */ - protected searchFields?: string[]; /* For search like keywork */ + protected allowedFilterFields?: string[]; + protected searchFields?: string[]; constructor( repository: R, @@ -134,6 +134,7 @@ export class BaseService> { limit = 10, searchTerm = '', paginate = true, + includeDeleted = false, }: { query?: Record; sort?: Record; @@ -141,6 +142,7 @@ export class BaseService> { limit?: number; searchTerm?: string; paginate?: boolean; + includeDeleted?: boolean; } = {}): Promise | ErrorResponseType> { try { let searchQuery = this.filterQueryFields(query); @@ -151,13 +153,20 @@ export class BaseService> { })); searchQuery = { ...searchQuery, $or: searchConditions }; } - const documents = await this.repository.findAll(searchQuery, { - sort, - skip: (page - 1) * limit, - limit: paginate ? limit : undefined, - }); - const total = await this.repository.countDocuments(); - const _results = await this.repository.countDocuments(searchQuery); + const documents = await this.repository.findAll( + searchQuery, + { + sort, + skip: (page - 1) * limit, + limit: paginate ? limit : undefined, + }, + includeDeleted, + ); + const total = await this.repository.countDocuments({}, includeDeleted); + const _results = await this.repository.countDocuments( + searchQuery, + includeDeleted, + ); const results = paginate ? documents.length : total; return { success: true, @@ -185,9 +194,10 @@ export class BaseService> { async findOne( query: Record, + includeDeleted = false, ): Promise | ErrorResponseType> { try { - const document = await this.repository.findOne(query); + const document = await this.repository.findOne(query, {}, includeDeleted); if (!document) { throw new ErrorResponse( 'NOT_FOUND_ERROR', @@ -209,9 +219,14 @@ export class BaseService> { async update( query: Record, updateInput: Partial, + includeDeleted = false, ): Promise | ErrorResponseType> { try { - const documentToUpdate = await this.repository.findOne(query); + const documentToUpdate = await this.repository.findOne( + query, + {}, + includeDeleted, + ); if (!documentToUpdate) { throw new ErrorResponse( 'NOT_FOUND_ERROR', @@ -253,6 +268,8 @@ export class BaseService> { const updatedDocument = await this.repository.update( query, fieldsToUpdate, + {}, + includeDeleted, ); if (!updatedDocument) { throw new ErrorResponse( @@ -274,13 +291,20 @@ export class BaseService> { async delete( query: Record, + softDelete = true, ): Promise | ErrorResponseType> { try { - const deletedDocument = await this.repository.delete(query); + const deletedDocument = await this.repository.delete( + query, + {}, + softDelete, + ); if (!deletedDocument) { throw new ErrorResponse( 'NOT_FOUND_ERROR', - 'Document to delete not found.', + softDelete + ? 'Document to soft delete not found.' + : 'Document to delete not found.', ); } return { success: true, document: deletedDocument }; diff --git a/src/core/engine/base/index.ts b/src/core/engine/base/index.ts index f003a5d..754e618 100644 --- a/src/core/engine/base/index.ts +++ b/src/core/engine/base/index.ts @@ -1,2 +1,3 @@ export * from './_repositories'; export * from './_services'; +export * from './_models'; diff --git a/src/core/framework/webserver/express.ts b/src/core/framework/webserver/express.ts index 84a959e..ef426a4 100644 --- a/src/core/framework/webserver/express.ts +++ b/src/core/framework/webserver/express.ts @@ -37,7 +37,7 @@ initializeSessionAndFlash(app); initializeViewEngine(app); // Client authentication middleware -// app.use(clientAuthentication); +app.use(clientAuthentication); // Client authentication middleware app.use(apiRateLimiter);