import { v4 as uuid } from 'uuid'; import { Collection, FilterQuery, UpdateQuery } from 'mongodb'; import { classToPlain, Exclude, plainToClassFromExist } from 'class-transformer'; import { Field, ID, ObjectType, Authorized } from 'type-graphql'; import DB, { createSetRequest } from '@seed/services/database/DBService'; import { Permission } from '@seed/interfaces/permission'; import { newError } from '@seed/helpers/Error'; import { parsePaginationOptions } from '@seed/helpers/Request'; import { GetArgs, GetManyArgs } from './Request'; import { addPermissions, addPermissionToQuery, checkPermissions, checkOrganisationPermissions } from './AccessService'; import { ApolloContext } from '@seed/interfaces/context'; import _ from 'lodash'; import { StreamOperationType, PostHookStatus } from '@seed/services/change-stream/change-stream.components'; import { asyncHook } from '@seed/services/hooks/hooks.decorator'; import { AccountTypeEnum } from '@src/accounts/account.components'; import { promiseAll } from '@seed/helpers/Utils'; import { EnginePathComponent } from '@seed/interfaces/components'; export interface BaseInterface { _id: string; updatedAt: Date; createdAt: Date; collectionName: string; } @ObjectType() export abstract class BaseGraphModel implements BaseInterface { @Exclude() public collectionName: string; @Exclude() protected permissions: Permission; @Exclude() defaultSort: string; // eslint-disable-next-line @typescript-eslint/no-unused-vars @Field(() => ID) _id: string; @Field(() => ID, { nullable: true }) organisationId?: string; @Field(() => ID, { nullable: true }) createdBy?: string; @Field(() => ID, { nullable: true }) updatedBy?: string; @Field(() => ID, { nullable: true }) deletedBy?: string; @Field() createdAt: Date; @Field() updatedAt: Date; @Field(() => [String]) public r: string[]; @Authorized('admin') @Field(() => [String]) public w: string[]; @Authorized('admin') @Field(() => [String]) public d: string[]; paths: EnginePathComponent[]; public constructor(init: { collectionName: string; permissions: Permission; defaultSort?: string }) { this.collectionName = init.collectionName; this.permissions = init.permissions; this.r = init.permissions.r; this.w = init.permissions.w; this.d = init.permissions.d; this.createdAt = new Date(); this.updatedAt = new Date(); this.defaultSort = init.defaultSort || 'createdAt desc'; } /* * * Model functions * */ public get(): any { return classToPlain(this); } public set(doc: any): void { plainToClassFromExist(this, doc); if (doc._id) this._id = doc._id; if (doc.r) this.r = _.uniq(_.concat(this.r, doc.r)); if (doc.w) this.w = _.uniq(_.concat(this.w, doc.w)); if (doc.d) this.d = _.uniq(_.concat(this.d, doc.d)); if (doc.organisationId) this.organisationId = doc.organisationId; if (doc.createdAt) this.createdAt = doc.createdAt; if (doc.updatedAt) this.updatedAt = doc.updatedAt; } abstract searchOptions(): string[]; abstract filterOptions(): string[]; searchEngine?(): any; async prehook?(): Promise; async beforeCreate?(ctx?: ApolloContext | null): Promise; async beforeUpdate?(ctx?: ApolloContext | null): Promise; async beforeDelete?(ctx?: ApolloContext | null): Promise; async afterCreate?(changeStream: any): Promise; async afterUpdate?(changeStream: any): Promise; async afterDelete?(changeStream: any): Promise; /* * * DB Functions * */ public async db(): Promise> { return await (await DB.getInstance()).db.collection(this.collectionName); } public getPath(): EnginePathComponent[] { if (this.paths) { this.paths.push({ ressourceModel: this.collectionName, ressourceId: this._id, }); return this.paths; } else return [ { ressourceModel: this.collectionName, ressourceId: this._id, }, ]; } public getQuery(query: FilterQuery, ctx: ApolloContext | null): any { let finalQuery: any; if (ctx) { const account = ctx.ctx.user; const organisationId = ctx.ctx.organisationId; // We add the permission (on r field) to make sure that kind of user has access to the ressource finalQuery = addPermissionToQuery(account, 'get', { ...query }); // We check if it comes from an organisation // Check of the user's correct authorisation on that organisation comes from the middleware if (!ctx.ctx.noOrganisationCheck) { if (organisationId) { if (this.collectionName == 'accounts') finalQuery['organisationIds'] = organisationId; else finalQuery['organisationId'] = { $eq: organisationId }; } else { // If admin & public query -> do not add this condition and let search every records) if ( account && account.types && !account.types.includes(AccountTypeEnum.public) && !account.types.includes(AccountTypeEnum.admin) ) { finalQuery['organisationId'] = { $exists: false }; } } } } else { finalQuery = { ...query }; } return finalQuery; } public async getMany(query: FilterQuery, pagination: GetArgs | undefined, ctx: ApolloContext | null): Promise { const finalQuery = this.getQuery(query, ctx); if (!pagination) pagination = { limit: 100, skip: 0 }; if (!pagination.sort) pagination.sort = this.defaultSort; const paginationOption = parsePaginationOptions(pagination); try { console.log('search request', JSON.stringify(finalQuery, null, 2)); return await (await this.db()).find(finalQuery, paginationOption).toArray(); } catch (error) { return []; } } public async getCount(query: FilterQuery, ctx: ApolloContext | null): Promise { const finalQuery = this.getQuery(query, ctx); try { const result = (await (await this.db()).countDocuments(finalQuery)) as any; console.log(result); return result; } catch (error) { return 0; } } public async getAll(query: FilterQuery, ctx: ApolloContext | null): Promise { const finalQuery = this.getQuery(query, ctx); try { return await (await this.db()).find(finalQuery).toArray(); } catch (error) { return []; } } public async getOne(query: FilterQuery, ctx: ApolloContext | null): Promise { if (ctx) { const account = ctx.ctx.user; const organisationId = ctx.ctx.organisationId; const doc = await (await this.db()).findOne(query); if (!doc) throw newError(404, query); this.set(doc); if (!ctx.ctx.noOrganisationCheck) { if (organisationId) { if (this.collectionName != 'accounts') checkOrganisationPermissions(this, organisationId); } } checkPermissions(this, account, 'r'); return this; } else { const doc = await (await this.db()).findOne(query); if (!doc) throw newError(404, query); this.set(doc); return this; } } public async saveOne(newData: Partial, ctx: ApolloContext | null, upsert = true): Promise { if (ctx) { const account = ctx.ctx.user; const organisationId = ctx.ctx.organisationId; addPermissions(this, ['r', 'w', 'd'], [account._id]); if (organisationId) { newData.organisationId = organisationId; } newData.createdBy = account._id; } this.set(newData); // if (!ctx) ctx = await createApolloContext(); try { if (this.prehook) await this.prehook(); if (this.beforeCreate) await this.beforeCreate(ctx); } catch (error) { throw error; } const { _id, ...dataToSave } = this.get(); const savedId = _id ? _id : uuid(); try { const result = await (await this.db()).findOneAndUpdate( { _id: savedId }, { $set: dataToSave, $setOnInsert: { _id: savedId, }, } as any, { upsert, returnOriginal: false }, ); if (result.ok == 0) throw newError(1000, result); this.set({ _id: savedId }); const changeStreamId = uuid(); const changeStreamData = { _id: changeStreamId, operation: StreamOperationType.insert, collection: this.collectionName, documentKey: this._id, insertedValues: this.get(), hookStatus: PostHookStatus.new, createdAt: new Date(), updateAt: new Date(), }; await (await DB.getInstance()).db.collection('stream.changes').insertOne(changeStreamData); if (this.afterCreate && (await asyncHook(changeStreamData))) { await this.afterCreate(changeStreamData); await (await DB.getInstance()).db .collection('stream.changes') .updateOne({ _id: changeStreamId }, { $set: { hookStatus: PostHookStatus.completed } }); } return this; } catch (error) { throw newError(1000, error); } } public async saveMany(newDataArr: Partial[], ctx: ApolloContext | null): Promise { // Initialize the change stream const changeStreams: any[] = []; const afterCreatePromises: any[] = []; const prehooksPromises = newDataArr.map(async (newData) => { const clone = Object.assign(Object.create(Object.getPrototypeOf(this)), this); if (ctx) { const account = ctx.ctx.user; const organisationId = ctx.ctx.organisationId; addPermissions(clone, ['r', 'w', 'd'], [account._id]); if (organisationId) { newData.organisationId = organisationId; } newData.createdBy = account._id; } clone.set(newData); // if (!ctx) ctx = await createApolloContext(); if (clone.prehook) await clone.prehook(); if (clone.beforeCreate) await clone.beforeCreate(ctx); const { _id, ...dataToSave } = clone.get(); const savedId = _id ? _id : uuid(); clone.set({ _id: savedId }); const changeStreamId = uuid(); const changeStreamData = { _id: changeStreamId, operation: StreamOperationType.insert, collection: clone.collectionName, documentKey: clone._id, insertedValues: clone.get(), hookStatus: PostHookStatus.new, createdAt: new Date(), updateAt: new Date(), }; changeStreams.push(changeStreamData); // eslint-disable-next-line @typescript-eslint/no-this-alias afterCreatePromises.push(async () => { if (clone.afterCreate) { const res = await asyncHook(changeStreamData); if (res) { await clone.afterCreate(changeStreamData); await (await DB.getInstance()).db .collection('stream.changes') .updateOne({ _id: changeStreamId }, { $set: { hookStatus: PostHookStatus.completed } }); } } }); return clone.get(); }); try { const dataArr = await Promise.all(prehooksPromises); await (await this.db()).insertMany(dataArr, { ordered: false }); await (await DB.getInstance()).db.collection('stream.changes').insertMany(changeStreams, { ordered: false }); await promiseAll(afterCreatePromises); return dataArr; } catch (error) { console.error(error); throw newError(1000, error); } } public async updateOne(query: FilterQuery, newData: Partial, ctx: ApolloContext | null): Promise { try { const dataThatHaveBeenChanged = createSetRequest('', { ...newData, updatedAt: new Date() }); let dataBeforeChange: any; if (ctx) { const account = ctx.ctx.user; // Check if doc exists & correct permissions if (!this._id) await this.getOne(query, ctx); dataBeforeChange = this.get(); dataThatHaveBeenChanged.updatedBy = account._id; this.set(dataThatHaveBeenChanged); if (this.beforeUpdate) await this.beforeUpdate(ctx); checkPermissions(this, account, 'w'); const result = await (await this.db()).findOneAndUpdate( query, { $set: dataThatHaveBeenChanged }, { upsert: false, returnOriginal: false }, ); if (result.ok != 1) throw newError(1000, result); this.set(result.value); } else { if (!this._id) await this.getOne(query, null); dataBeforeChange = this.get(); this.set(dataThatHaveBeenChanged); if (this.beforeUpdate) await this.beforeUpdate(); const result = await (await this.db()).findOneAndUpdate( query, { $set: dataThatHaveBeenChanged }, { upsert: false, returnOriginal: false }, ); if (result.ok != 1) throw newError(1000, result); this.set(result.value); } const changeStreamId = uuid(); const changeStreamData = { _id: changeStreamId, operation: StreamOperationType.update, collection: this.collectionName, documentKey: this._id, updatedValues: dataThatHaveBeenChanged, beforeUpdateValues: dataBeforeChange, hookStatus: PostHookStatus.new, createdAt: new Date(), updateAt: new Date(), }; await (await DB.getInstance()).db.collection('stream.changes').insertOne(changeStreamData); if (this.afterUpdate && (await asyncHook(changeStreamData))) { await this.afterUpdate(changeStreamData); await (await DB.getInstance()).db .collection('stream.changes') .updateOne({ _id: changeStreamId }, { $set: { hookStatus: PostHookStatus.completed } }); } return this; } catch (error) { throw error; } } public async updateOneCustom(query: FilterQuery, set: UpdateQuery, ctx: ApolloContext | null): Promise { try { let dataBeforeChange: any; if (ctx) { const account = ctx.ctx.user; // Check if doc exists if (!this._id) await this.getOne(query, ctx); dataBeforeChange = this.get(); checkPermissions(this, account, 'w'); // Validate new data that will go into the DB // const validation = await this.validate(); // if (!validation.success) return validation; // Prepare data this.updatedAt = new Date(); const result = await (await this.db()).findOneAndUpdate(query, set, { upsert: false, returnOriginal: false }); if (result.ok != 1) throw newError(1000, result); if (result.value) this.set(result.value); } else { if (!this._id) await this.getOne(query, null); dataBeforeChange = this.get(); // Prepare data this.updatedAt = new Date(); const result = await (await this.db()).findOneAndUpdate(query, set, { upsert: false, returnOriginal: false }); if (result.ok != 1) throw newError(1000, result); if (result.value) this.set(result.value); } const changeStreamId = uuid(); const changeStreamData = { _id: changeStreamId, operation: StreamOperationType.update, collection: this.collectionName, documentKey: this._id, hookStatus: PostHookStatus.new, updatedValues: JSON.stringify(set), beforeUpdateValues: dataBeforeChange, createdAt: new Date(), updateAt: new Date(), }; await (await DB.getInstance()).db.collection('stream.changes').insertOne(changeStreamData); if (this.afterUpdate && (await asyncHook(changeStreamData))) { await this.afterUpdate(changeStreamData); await (await DB.getInstance()).db .collection('stream.changes') .updateOne({ _id: changeStreamId }, { $set: { hookStatus: PostHookStatus.completed } }); } return this; } catch (error) { throw error; } } public async deleteOne(query: FilterQuery, ctx: ApolloContext | null): Promise { try { if (ctx) { const account = ctx.ctx.user; if (!this._id) await this.getOne(query, ctx); checkPermissions(this, account, 'd'); if (this.beforeDelete) await this.beforeDelete(ctx); const result = await (await this.db()).findOneAndDelete(query); if (result.ok != 1) throw newError(1001); const deletedModel = '_deleted_' + this.collectionName; await (await DB.getInstance()).db.collection(deletedModel).insertOne({ ...result.value, deletedBy: account._id }); } else { if (!this._id) await this.getOne(query, null); if (this.beforeDelete) await this.beforeDelete(); const result = await (await this.db()).findOneAndDelete(query); if (result.ok != 1) throw newError(1001); const deletedModel = '_deleted_' + this.collectionName; await (await DB.getInstance()).db.collection(deletedModel).insertOne(result.value); } const changeStreamId = uuid(); const changeStreamData = { _id: changeStreamId, operation: StreamOperationType.delete, collection: this.collectionName, documentKey: this._id, hookStatus: PostHookStatus.new, beforeUpdateValues: this.get(), createdAt: new Date(), updateAt: new Date(), }; await (await DB.getInstance()).db.collection('stream.changes').insertOne(changeStreamData); if (this.afterDelete && (await asyncHook(changeStreamData))) { await this.afterDelete(changeStreamData); await (await DB.getInstance()).db .collection('stream.changes') .updateOne({ _id: changeStreamId }, { $set: { hookStatus: PostHookStatus.completed } }); } return this; } catch (error) { throw error; } } /* * * Permissions * */ public async updateQueryWithPermission(query: FilterQuery, ctx: ApolloContext): Promise> { const account = ctx.ctx.user; const organisationId = ctx.ctx.organisationId; // We add the permission (on r field) to make sure that kind of user has access to the ressource const finalQuery = addPermissionToQuery(account, 'get', { ...query }); // We check if it comes from an organisation // Check of the user's correct authorisation on that organisation comes from the middleware if (organisationId) { finalQuery['organisationId'] = { $eq: organisationId }; } else { finalQuery['organisationId'] = { $exists: false }; } return finalQuery; } // public async updateOnePermission( // query: FilterQuery, // add: 'add' | 'remove', // type: 'r' | 'w' | 'd', // newPerm: string, // account?: AccountModel, // ): Promise { // // Get the object from the DB // try { // // Check if doc exists // // TODO : if (this.get() returns, then don't) // await this.getOne(query, account); // if (account) this.checkPermissions(account.get(), 'w'); // // Validate new data that will go into the DB // // const validation = await this.validate(); // // if (!validation.success) return validation; // // Prepare data // this.updatedAt = new Date(); // if (add == 'add') { // const result = await (await this.db()).findOneAndUpdate( // query, // { $addToSet: { [type]: newPerm } }, // { upsert: false, returnOriginal: false }, // ); // if (result.ok != 1) throw newError('general.dbError', '400', result); // this.set(result.value); // } else if (add == 'remove') { // const result = await (await this.db()).findOneAndUpdate( // query, // { $pull: { [type]: newPerm } }, // { upsert: false, returnOriginal: false }, // ); // if (result.ok != 1) throw newError('general.dbError', '400', result); // this.set(result.value); // } // return this; // } catch (error) { // throw error; // } // } public async addOneSubModel( parentQ: { _id: string }, subModelRefField: string, subModelFieldName: keyof this, subModel: SUB, subModelInput: any, ctx: ApolloContext | null, ): Promise { try { subModelInput[subModelRefField] = parentQ._id; const subM = await subModel.saveOne(subModelInput, ctx); await this.updateOneCustom(parentQ as any, { $push: { [`${subModelFieldName}`]: subM._id } } as any, null); return subM; } catch (error) { throw error; } } public async editOneSubModel( subModel: SUB, subModelId: string, subModelInput: any, ctx: ApolloContext | null, ): Promise { try { // No need to check if belongs to the correct organisation, the middleware does that const subM = await subModel.updateOne({ _id: subModelId } as any, subModelInput, ctx); return subM; } catch (error) { throw error; } } public async deleteOneSubModel( parentQ: FilterQuery, subModelFieldName: keyof this, subModel: SUB, subModelId: string, ctx: ApolloContext | null, ): Promise { try { const subM = await subModel.deleteOne({ _id: subModelId } as any, ctx); await this.updateOneCustom(parentQ, { $pull: { [`${subModelFieldName}`]: subM._id } } as any, null); return subM; } catch (error) { throw error; } } public getOneSubRessource(subRessoureFieldName: keyof this | string, subRessoureId: string): any | undefined { try { const subress = _.get(this, subRessoureFieldName); if (subress) { const ind = _.findIndex(subress, { _id: subRessoureId }); if (ind !== -1) return subress[ind]; } return; } catch (error) { throw error; } } public getDefaultSubRessource(subRessoureFieldName: keyof this | string): any | undefined { try { const subress = _.get(this, subRessoureFieldName); if (subress) { const ind = _.findIndex(subress, { default: true }); if (ind !== -1) return subress[ind]; return subress[0]; } return; } catch (error) { throw error; } } public async addOneSubRessource( parentQ: { _id: string }, subRessoureFieldName: keyof this | string, subRessoure: any, ctx: ApolloContext | null, ): Promise { try { if (subRessoure.default && _.get(this, subRessoureFieldName)) { // Remove all default from others await this.updateOneCustom( { _id: this._id } as any, { $set: { [`${subRessoureFieldName}.$[].default`]: false } as any, }, ctx, ); } else subRessoure.default = false; // Generate ID if not in the model if (!subRessoure._id) subRessoure._id = uuid(); await this.updateOneCustom(parentQ as any, { $push: { [`${subRessoureFieldName}`]: subRessoure } } as any, ctx); return subRessoure; } catch (error) { throw error; } } public async editOneSubRessource( parentQ: { _id: string }, subRessoureFieldName: keyof this | string, subRessoureId: string, subRessoure: any, ctx: ApolloContext | null, ): Promise { try { if (!this.getOneSubRessource(subRessoureFieldName, subRessoureId)) throw newError(404); if (subRessoure.default) { // Remove all default from others await this.updateOneCustom( { _id: this._id } as any, { $set: { [`${subRessoureFieldName}.$[].default`]: false } as any, }, ctx, ); } else subRessoure.default = false; const filterQ = { ...parentQ, [`${subRessoureFieldName}._id`]: subRessoureId, }; await this.updateOneCustom( filterQ as any, { $set: { [`${subRessoureFieldName}.$`]: { ...subRessoure, _id: subRessoureId, }, } as any, }, ctx, ); return { ...subRessoure, _id: subRessoureId, }; } catch (error) { throw error; } } public async deleteOneSubRessource( parentQ: { _id: string }, subRessoureFieldName: keyof this | string, subRessoureId: string, ctx: ApolloContext | null, ): Promise { try { if (!this.getOneSubRessource(subRessoureFieldName, subRessoureId)) throw newError(404); const filterQ = { ...parentQ, [`${subRessoureFieldName}._id`]: subRessoureId, }; await this.updateOneCustom(filterQ as any, { $pull: { [`${subRessoureFieldName}`]: { _id: subRessoureId } } } as any, ctx); return subRessoureId; } catch (error) { throw error; } } }