backend/lib/seed/graphql/BaseGraphModel.ts
2025-05-14 21:45:16 +02:00

829 lines
28 KiB
TypeScript

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<void>;
async beforeCreate?(ctx?: ApolloContext | null): Promise<void>;
async beforeUpdate?(ctx?: ApolloContext | null): Promise<void>;
async beforeDelete?(ctx?: ApolloContext | null): Promise<void>;
async afterCreate?(changeStream: any): Promise<void>;
async afterUpdate?(changeStream: any): Promise<void>;
async afterDelete?(changeStream: any): Promise<void>;
/*
*
* DB Functions
*
*/
public async db(): Promise<Collection<this>> {
return await (await DB.getInstance()).db.collection<this>(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<this>, 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<this>, pagination: GetArgs | undefined, ctx: ApolloContext | null): Promise<any> {
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<this>, ctx: ApolloContext | null): Promise<number> {
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<this>, ctx: ApolloContext | null): Promise<any> {
const finalQuery = this.getQuery(query, ctx);
try {
return await (await this.db()).find(finalQuery).toArray();
} catch (error) {
return [];
}
}
public async getOne(query: FilterQuery<this>, ctx: ApolloContext | null): Promise<this> {
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<this>, ctx: ApolloContext | null, upsert = true): Promise<this> {
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<this>[], ctx: ApolloContext | null): Promise<any[]> {
// 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<this>, newData: Partial<this>, ctx: ApolloContext | null): Promise<this> {
try {
const dataThatHaveBeenChanged = createSetRequest<this>('', { ...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<this>, set: UpdateQuery<this>, ctx: ApolloContext | null): Promise<this> {
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<this>, ctx: ApolloContext | null): Promise<this> {
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<this>, ctx: ApolloContext): Promise<FilterQuery<this>> {
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<this>,
// add: 'add' | 'remove',
// type: 'r' | 'w' | 'd',
// newPerm: string,
// account?: AccountModel,
// ): Promise<this> {
// // 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<SUB extends BaseGraphModel>(
parentQ: { _id: string },
subModelRefField: string,
subModelFieldName: keyof this,
subModel: SUB,
subModelInput: any,
ctx: ApolloContext | null,
): Promise<SUB> {
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<SUB extends BaseGraphModel>(
subModel: SUB,
subModelId: string,
subModelInput: any,
ctx: ApolloContext | null,
): Promise<SUB> {
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<SUB extends BaseGraphModel>(
parentQ: FilterQuery<this>,
subModelFieldName: keyof this,
subModel: SUB,
subModelId: string,
ctx: ApolloContext | null,
): Promise<SUB> {
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<any> {
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<any> {
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<string> {
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;
}
}
}