From efbd0b6b9edd47e6133c8b8fbed42b839e0ab205 Mon Sep 17 00:00:00 2001 From: Siyao Wang Date: Mon, 10 Jun 2024 20:39:18 +0100 Subject: [PATCH 01/12] feat(trpc-study): Add the trpc study backend --- packages/itmat-cores/src/index.ts | 1 + packages/itmat-cores/src/trpcCore/fileCore.ts | 2 +- .../src/trpcCore/permissionCore.ts | 21 + .../itmat-cores/src/trpcCore/studyCore.ts | 431 ++++++++- .../src/trpc/studyProcedure.ts | 107 +++ .../itmat-interface/src/trpc/tRPCRouter.ts | 4 +- .../test/GraphQLTests/file.test.ts | 3 + .../test/filesForTests/dsi.jpeg | Bin 0 -> 5139 bytes .../test/trpcTests/study.test.ts | 829 ++++++++++++++++++ .../test/trpcTests/user.test.ts | 1 + 10 files changed, 1392 insertions(+), 7 deletions(-) create mode 100644 packages/itmat-cores/src/trpcCore/permissionCore.ts create mode 100644 packages/itmat-interface/src/trpc/studyProcedure.ts create mode 100644 packages/itmat-interface/test/filesForTests/dsi.jpeg create mode 100644 packages/itmat-interface/test/trpcTests/study.test.ts diff --git a/packages/itmat-cores/src/index.ts b/packages/itmat-cores/src/index.ts index 0c4a5357f..a67431da3 100644 --- a/packages/itmat-cores/src/index.ts +++ b/packages/itmat-cores/src/index.ts @@ -15,6 +15,7 @@ export * from './trpcCore/driveCore'; export * from './trpcCore/fileCore'; export * from './trpcCore/studyCore'; export * from './trpcCore/userCore'; +export * from './trpcCore/permissionCore'; export * from './rest/fileDownload'; export * from './authentication/pubkeyAuthentication'; export * from './log/logPlugin'; diff --git a/packages/itmat-cores/src/trpcCore/fileCore.ts b/packages/itmat-cores/src/trpcCore/fileCore.ts index 6b7db3dd6..6584eeeda 100644 --- a/packages/itmat-cores/src/trpcCore/fileCore.ts +++ b/packages/itmat-cores/src/trpcCore/fileCore.ts @@ -25,9 +25,9 @@ export class TRPCFileCore { * @param studyId - The id of the study. Could be null for non-study files. * @param userId - The id of the user. * @param fileUpload - The file to upload. - * @param description - The description of the file. * @param fileType - The type of the file. * @param fileCategory - The category of the file. + * @param description - The description of the file. * @param properties - The properties of the file. Note if the data is attached to a field, the fieldproperties will be used. * * @return IFile - The object of IFile. diff --git a/packages/itmat-cores/src/trpcCore/permissionCore.ts b/packages/itmat-cores/src/trpcCore/permissionCore.ts new file mode 100644 index 000000000..455227323 --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/permissionCore.ts @@ -0,0 +1,21 @@ +import { IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; + +export class TRPCPermissionCore { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + + /** + * Get the roles of a user. + * + * @param user + * @param studyId + * @returns + */ + public async getRolesOfUser(user: IUserWithoutToken, studyId?: string) { + return studyId ? await this.db.collections.roles_collection.find({ 'studyId': studyId, 'users': user.id, 'life.deletedTime': null }).toArray() : + await this.db.collections.roles_collection.find({ 'users': user.id, 'life.deletedTime': null }).toArray(); + } +} \ No newline at end of file diff --git a/packages/itmat-cores/src/trpcCore/studyCore.ts b/packages/itmat-cores/src/trpcCore/studyCore.ts index 081c4a504..a3ad665e5 100644 --- a/packages/itmat-cores/src/trpcCore/studyCore.ts +++ b/packages/itmat-cores/src/trpcCore/studyCore.ts @@ -1,14 +1,22 @@ import { ObjectStore } from '@itmat-broker/itmat-commons'; import { DBType } from '../database/database'; -import { CoreError, IStudy, enumCoreErrors } from '@itmat-broker/itmat-types'; -import { Filter } from 'mongodb'; +import { CoreError, FileUpload, IFile, IStudy, IStudyDataVersion, IUserWithoutToken, enumConfigType, enumCoreErrors, enumFileCategories, enumFileTypes, enumStudyRoles, enumUserTypes } from '@itmat-broker/itmat-types'; +import { Filter, UpdateFilter } from 'mongodb'; +import { TRPCPermissionCore } from './permissionCore'; +import { v4 as uuid } from 'uuid'; +import { TRPCFileCore } from './fileCore'; +import { makeGenericReponse } from '../utils'; export class TRPCStudyCore { db: DBType; objStore: ObjectStore; - constructor(db: DBType, objStore: ObjectStore) { + permissionCore: TRPCPermissionCore; + fileCore: TRPCFileCore; + constructor(db: DBType, objStore: ObjectStore, permissionCore: TRPCPermissionCore, fileCore: TRPCFileCore) { this.db = db; this.objStore = objStore; + this.permissionCore = permissionCore; + this.fileCore = fileCore; } /** * Get the info of a study. @@ -17,13 +25,28 @@ export class TRPCStudyCore { * * @return IStudy - The object of IStudy. */ - public async getStudies(studyId: string | null): Promise { + public async getStudies(requester: IUserWithoutToken | undefined, studyId?: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + if (requester.type === 'ADMIN') { + return studyId ? await this.db.collections.studies_collection.find({ 'id': studyId, 'life.deletedTime': null }).toArray() : + await this.db.collections.studies_collection.find({ 'life.deletedTime': null }).toArray(); + } + const roleStudyIds = (await this.permissionCore.getRolesOfUser(requester)).map(role => role.studyId); + const query: Filter = { 'life.deletedTime': null }; if (studyId) { query.id = studyId; + } else { + query.id = { $in: roleStudyIds }; } const studies = await this.db.collections.studies_collection.find(query).toArray(); - if (studies.length === 0) { + if (studies.length === 0 && studyId) { throw new CoreError( enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, 'Study does not exist.' @@ -31,4 +54,402 @@ export class TRPCStudyCore { } return studies; } + + /** + * Create a study. + * + * @param requester - The id of the requester. + * @param studyName - The name of the study. + * @param description - The description of the study. + * + * @return IStudy - The object of the IStudy. + */ + public async createStudy(requester: IUserWithoutToken | undefined, studyName: string, description?: string, profile?: FileUpload): Promise> { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + if (requester.type !== 'ADMIN') { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'Only admin can create a study.' + ); + } + + const studyId = uuid(); + const existing = await this.db.collections.studies_collection.findOne({ 'name': studyName, 'life.deletedTime': null }); + if (existing) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Study name already used.' + ); + } + + const studyEntry: IStudy = { + id: studyId, + name: studyName, + currentDataVersion: -1, + dataVersions: [], + description: description ?? '', + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + await this.db.collections.studies_collection.insertOne(studyEntry); + let fileEntry: IFile | null = null; + if (profile) { + if (!Object.keys(enumFileTypes).includes((profile?.filename?.split('.').pop() || '').toUpperCase())) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'File type not supported.' + ); + } + fileEntry = await this.fileCore.uploadFile(requester, studyId, null, profile, enumFileTypes[(profile.filename.split('.').pop() || '').toUpperCase() as keyof typeof enumFileTypes], enumFileCategories.PROFILE_FILE); + await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId }, { + $set: { + profile: fileEntry.id + } + }, { + returnDocument: 'after' + }); + + // create a default study config + await this.db.collections.configs_collection.insertOne({ + id: uuid(), + type: enumConfigType.STUDYCONFIG, + key: studyEntry.id, + properties: { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultStudyProfile: null, + defaultMaximumFileSize: 8 * 1024 * 1024 * 1024, // 8 GB, + defaultRepresentationForMissingValue: '99999', + defaultFileColumns: [], + defaultFileColumnsPropertyColor: 'black', + defaultFileDirectoryStructure: { + pathLabels: [], + description: null + }, + defaultVersioningKeys: [] + }, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }); + return { + ...studyEntry, + profile: fileEntry.id + }; + } + return studyEntry; + } + + /** + * Edit the description of the study. + * + * @param requester - The requester. + * @param studyId - The id of the study. + * @param name - The name of the study. + * @param description - The description of the study. + * @param profile - The profile of the study. + * + * @return IStudy - The object of IStudy + */ + public async editStudy(requester: IUserWithoutToken | undefined, studyId: string, name?: string, description?: string, profile?: FileUpload) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + const roleStudyRoles: enumStudyRoles[] = (await this.permissionCore.getRolesOfUser(requester, studyId)).map(role => role.studyRole); + if (requester.type !== enumUserTypes.ADMIN && !roleStudyRoles.includes(enumStudyRoles.STUDY_MANAGER)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'Only admin or study manager can edit a study.' + ); + } + + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); + if (!study) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Study does not exist.' + ); + } + + const setObj: UpdateFilter = {}; + let fileEntry; + if (profile) { + try { + if (!Object.keys(enumFileTypes).includes((profile?.filename?.split('.').pop() || '').toUpperCase())) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'File format not supported' + ); + } + fileEntry = await this.fileCore.uploadFile(requester, studyId, null, profile, enumFileTypes[(profile.filename.split('.').pop() || '').toUpperCase() as keyof typeof enumFileTypes], enumFileCategories.PROFILE_FILE); + setObj['profile'] = fileEntry.id; + } catch (error) { + setObj['profile'] = study.profile; + } + } + if (name) { + if (name !== study.name) { + const existing = await this.db.collections.studies_collection.findOne({ 'name': name, 'life.deletedTime': null }); + if (existing) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Study name already used.' + ); + } + } + setObj['name'] = name; + } + if (description) { + setObj['description'] = description; + } + const response = await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId }, { + $set: setObj + }, { + returnDocument: 'after' + }); + return response; + + } + /** + * Delete a study. + * + * @param requester - The id of the requester. + * @param studyId - The id of the study. + * + * @return IGenericResponse - The obejct of IGenericResponse. + */ + public async deleteStudy(requester: IUserWithoutToken | undefined, studyId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + if (requester.type !== 'ADMIN') { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'Only admin can delete a study.' + ); + } + + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); + if (!study) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Study does not exist.' + ); + } + + const timestamp = new Date().valueOf(); + /* delete the study */ + await this.db.collections.studies_collection.findOneAndUpdate({ 'id': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester } }); + + /* delete all projects related to the study */ + await this.db.collections.projects_collection.updateMany({ 'studyId': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester } }); + + /* delete all roles related to the study */ + // await this.localPermissionCore.removeRoleFromStudyOrProject({ studyId }); + + /* delete all files belong to the study*/ + await this.db.collections.files_collection.updateMany({ 'studyId': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester } }); + + /* delete all fields belong to the study*/ + await this.db.collections.field_dictionary_collection.updateMany({ 'studyId': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester } }); + + /* delete all data belong to the study*/ + await this.db.collections.data_collection.updateMany({ 'studyId': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester } }); + + /* delete all config belong to the study*/ + await this.db.collections.configs_collection.updateMany({ 'type': enumConfigType.STUDYCONFIG, 'key': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester } }); + + return makeGenericReponse(studyId, true, undefined, `Study ${study.name} has been deleted.`); + } + /** + * Create a new data version of the study. + * + * @param requester - The id of the requester. + * @param studyId - The id of the study. + * @param tag - The tag of the study. + * @param dataVersion - The new version of the study. Use float number. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + public async createDataVersion(requester: IUserWithoutToken | undefined, studyId: string, tag: string, dataVersion: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + const roleStudyRoles: enumStudyRoles[] = (await this.permissionCore.getRolesOfUser(requester)).map(role => role.studyRole); + if (requester.type !== enumUserTypes.ADMIN && !roleStudyRoles.includes(enumStudyRoles.STUDY_MANAGER)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'Only admin or study manager can create a study version.' + ); + } + + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); + if (!study) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Study does not exist.' + ); + } + + const decimalRegex = /^[0-9]+(\.[0-9]+)?$/; + if (!decimalRegex.test(dataVersion)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Version must be a float number.' + ); + } + + if (study.dataVersions.map(el => el.version).includes(dataVersion)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Version has been used.' + ); + } + + const newDataVersionId = uuid(); + + + // update data + const resData = await this.db.collections.data_collection.updateMany({ + studyId: studyId, + dataVersion: null + }, { + $set: { + dataVersion: newDataVersionId + } + }); + // update field + const resField = await this.db.collections.field_dictionary_collection.updateMany({ + studyId: studyId, + dataVersion: null + }, { + $set: { + dataVersion: newDataVersionId + } + }); + + // TODO: invalidate hash + // await this.db.collections.cache_collection.updateMany({ + // 'keys.studyId': studyId + // }, { + // $set: { + // status: enumCacheStatus.OUTDATED + // } + // }); + + // TODO: update stds, ontologies + + // TODO: update cold storage + + + const newDataVersion: IStudyDataVersion = { + id: newDataVersionId, + version: dataVersion, + tag: tag, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + await this.db.collections.studies_collection.updateOne({ id: studyId }, { + $push: { dataVersions: newDataVersion }, + $inc: { + currentDataVersion: 1 + } + }); + if (resData.modifiedCount === 0 && resField.modifiedCount === 0) { + throw new CoreError( + enumCoreErrors.UNQUALIFIED_ERROR, + 'Nothing to update.' + ); + } + } + + /** + * Set a data version as the current data version of a study. + * + * @param requester - The requester. + * @param studyId - The id of the study. + * @param dataVersionId - The id of the data version. + * @returns IGenericResponse + */ + public async setDataversionAsCurrent(requester: IUserWithoutToken | undefined, studyId: string, dataVersionId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + /* check privileges */ + const roleStudyRoles: enumStudyRoles[] = (await this.permissionCore.getRolesOfUser(requester)).map(role => role.studyRole); + if (requester.type !== enumUserTypes.ADMIN && !roleStudyRoles.includes(enumStudyRoles.STUDY_MANAGER)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'Only admin or study manager can set a study version.' + ); + } + + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); + if (!study) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Study does not exist.' + ); + } + + /* check whether the dataversion exists */ + const selectedataVersionFiltered = study.dataVersions.filter((el) => el.id === dataVersionId); + if (selectedataVersionFiltered.length !== 1) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Data version does not exist.' + ); + } + + /* update the currentversion field in database */ + const versionIdsList = study.dataVersions.map((el) => el.id); + await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { + $set: { currentDataVersion: versionIdsList.indexOf(dataVersionId) } + }, { + returnDocument: 'after' + }); + + return makeGenericReponse(studyId, true, undefined, `Data version ${dataVersionId} has been set as the current version of study ${study.name}.`); + } } \ No newline at end of file diff --git a/packages/itmat-interface/src/trpc/studyProcedure.ts b/packages/itmat-interface/src/trpc/studyProcedure.ts new file mode 100644 index 000000000..707d15541 --- /dev/null +++ b/packages/itmat-interface/src/trpc/studyProcedure.ts @@ -0,0 +1,107 @@ +import { IUser, FileUploadSchema } from '@itmat-broker/itmat-types'; +import { z } from 'zod'; +import { baseProcedure, router } from './trpc'; +import { TRPCFileCore, TRPCPermissionCore, TRPCStudyCore } from '@itmat-broker/itmat-cores'; +import { db } from '../database/database'; +import { objStore } from '../objStore/objStore'; + +const studyCore = new TRPCStudyCore(db, objStore, new TRPCPermissionCore(db), new TRPCFileCore(db, objStore)); + +export const studyRouter = router({ + /** + * Get the info of studies. + * + * @param studyId - The if of the study. + * + * @return Partial + */ + getStudies: baseProcedure.input(z.object({ + studyId: z.optional(z.string()) + })).query(async (opts) => { + const requester: IUser = opts.ctx.req?.user ?? opts.ctx.user; + // if (opts.input.studyId) { + // await permissionCore.checkOperationPermissionByUser(requester.id, opts.input.studyId); + // } + return await studyCore.getStudies(requester, opts.input.studyId); + }), + /** + * Create a study. + * + * @param name - The name of the study. + * @param description - The description of the study. + * @param profile - The profile of the study. + * + * @return IStudy + */ + createStudy: baseProcedure.input(z.object({ + name: z.string(), + description: z.optional(z.string()), + files: z.optional(z.object({ + profile: z.optional(z.array(FileUploadSchema)) + })) + })).mutation(async (opts) => { + return await studyCore.createStudy(opts.ctx.req.user, opts.input.name, opts.input.description, opts.input.files?.profile?.[0]); + }), + /** + * Edit a study. + * + * @param studyId - The id of the study. + * @param name - The name of the study. + * @param description - The description of the study. + * @param profile - The profile of the user. + * + * @return Partial + */ + editStudy: baseProcedure.input(z.object({ + studyId: z.string(), + name: z.optional(z.string()), + description: z.optional(z.string()), + files: z.optional(z.object({ + profile: z.optional(z.array(FileUploadSchema)) + })) + })).mutation(async (opts) => { + return await studyCore.editStudy(opts.ctx.req.user, opts.input.studyId, opts.input.name, opts.input.description, opts.input.files?.profile?.[0]); + }), + /** + * Delete a study. + * + * @param studyId - The id of the study. + * + * @return IGenericResponse - The obejct of IGenericResponse. + */ + deleteStudy: baseProcedure.input(z.object({ + studyId: z.string() + })).mutation(async (opts) => { + return await studyCore.deleteStudy(opts.ctx.req.user, opts.input.studyId); + }), + /** + * Create a new data version of the study. + * + * @param studyId - The id of the study. + * @param tag - The tag of the study. + * @param dataVersion - The new version of the study. User float number. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + createDataVersion: baseProcedure.input(z.object({ + studyId: z.string(), + dataVersion: z.string(), + tag: z.string() + })).mutation(async (opts) => { + return await studyCore.createDataVersion(opts.ctx.req.user, opts.input.studyId, opts.input.tag, opts.input.dataVersion); + }), + /** + * Set a data version as the current data version of a study. + * + * @param studyId - The id of the study. + * @param dataVersionId - The id of the data version. + * + * @return IGenreicResponse + */ + setDataversionAsCurrent: baseProcedure.input(z.object({ + studyId: z.string(), + dataVersionId: z.string() + })).mutation(async (opts) => { + return await studyCore.setDataversionAsCurrent(opts.ctx.req.user, opts.input.studyId, opts.input.dataVersionId); + }) +}); \ No newline at end of file diff --git a/packages/itmat-interface/src/trpc/tRPCRouter.ts b/packages/itmat-interface/src/trpc/tRPCRouter.ts index 4c46694d5..32e890945 100644 --- a/packages/itmat-interface/src/trpc/tRPCRouter.ts +++ b/packages/itmat-interface/src/trpc/tRPCRouter.ts @@ -1,10 +1,12 @@ import { driveRouter } from './driveProcedure'; +import { studyRouter } from './studyProcedure'; import { router } from './trpc'; import { userRouter } from './userProcedure'; export const tRPCRouter = router({ user: userRouter, - drive: driveRouter + drive: driveRouter, + study: studyRouter }); export type APPTRPCRouter = typeof tRPCRouter; \ No newline at end of file diff --git a/packages/itmat-interface/test/GraphQLTests/file.test.ts b/packages/itmat-interface/test/GraphQLTests/file.test.ts index 4a669e5b0..d1a7acf07 100644 --- a/packages/itmat-interface/test/GraphQLTests/file.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/file.test.ts @@ -64,6 +64,9 @@ if (global.hasMinio) { }, 10000); describe('FILE API', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let adminId: string; + beforeAll(async () => { /* setup: first retrieve the generated user id */ const result = await mongoClient.collection(config.database.collections.users_collection).find({}, { projection: { id: 1, username: 1 } }).toArray(); diff --git a/packages/itmat-interface/test/filesForTests/dsi.jpeg b/packages/itmat-interface/test/filesForTests/dsi.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f78e06878b5a398b580783aef7f6d88f9da2ed00 GIT binary patch literal 5139 zcmeH~XEfa3y2pPQ22r9*^iFgJ(UKr~kkLaBJ!--rN|Z!GbVf@=h?YsBM{g0L2Ek}y z7;TI?qKJaTtbSf*JV^B zWFay{6ciLxm#A2&saa)s*m-3B$3gf8(2)Z@Ko1EKFF;I3L_$YI=pf1m03uSNzX$MV z5D}ArNXfwD6qJ`P1H?omB*Y*RQc@7;VrS^Z8UUgrrRR}WBV&NtgL!=zWuB$vk@Kln zwlNtGZ}H1I_&%qgWM*N#a#cW3NLb_=L{47ex}uWCZA~q09bG*WQ!{f5%X?OiPR=f_ z58d2he*OVZ0)v7hU%ZTpeiai7PfbhD$b9`KE5D$y=mX;8r_W!is%vWN>Khu{zjt(Y zb@%lCK#q)#jZgfVoSI))#4KT#S60`ycXs#o@dt-T$3y_hKX5L_e*pa#9=Z!2Vi1S~ z1pb4Ei1^8cNa#SMJkn(JYEZDf4+F2vGjc}tl)TC|3O-rmEhY!wVM=Cx$h^SzA4q>E z`tJcf|6dXP3+P`wgeicEgy`bJNa%o@0On(UEIx@}I6oHnzf~@y%+C{^7>uVYd0}=K zBk)7G4GelPy6WtUm$5Ao^UC*O>z%aR<0=w;M#>1iLmVso{x4PfIKw=sn@8G*yC1(@ za6I+Mb_$cTP2VKaWHbTtV}<`I5?EX7j^ZlU{$B3S>cyt|ale~%f(Y5^vv{%boqkk~ z;4bl;^fHKEdL$O>x4v3EpoKo3ee{kb+Tc4M-+1Y)$AQIo)R3l^m-ir6m!sNRrE9}- z|N0;-_9xWaLR_zBI#oUT1Z<*ULoci0U{9 zKp3Tn;|vBOE|Gp0ee{b$nRcK$={0_^MLqsN8UBy{j*XWM&CLT z;zVlBp5ZCg3nnWV@>e!R3L!D0LP#up?oM5b2v5X)&rn2wY)@AZ&Q#KW@)4vBA^xtY z=JUYv=UXLGm9o`H_iYy+cq@{Fgq?qO(d3InL z$`V%nGO9yicPE(q+2Ww!%s19fUH5Umrcr$E%O6XINsF+N#cOodq1oS6zjV{<^cUCP z*hJ(uO6R2IZl%rk28GD=n@`Z8dAsGBU)^@LV7o=B#^%#SZJKG3j{7aX_#90xfmR4y zFeZN8b}1en{%ToqfKp~1L*20^XG5Y_>7~`SP!+vVi^^|Odj6*H%`JY`gFTf9;z(o` zB!LT;arME-t^gV?F@@%n(|48rNzFT{-;Scg`*!0QAt?}zv}Hrs#|H@GEG+dLVUuU z17!48_%yvDB#?^y45_(A9;m>OJI^2=B^|2hY^_Rm(S2c+x-?sQIQ2yyE!lKMYy-+G z*wSys)AdVLbJWb_+=;rhH1m2wi44Uum^b~Yn#h;Bx`v7`O&(6tZIG4DVZ$(6R$?+k z1(H~A8TvHcX3nOk9z_yrom26N(OEg7p*N^$PD~rVu)xaRb0=_`o3@!dCKoasgwUOd zG8le+duP<^cPaj8=(^vujEt%q?$iAnZ^dIKF};r^*b<*aXfH*{8OM3*7AAf$V9~Cb z@Q>rp7?2^i`f&*rl_ztLN_T((0@F-1)I05l~G=V5R*EqfY;)+}EEs zxkISp&;4OPkgd!30|G$ALI4uYu8LSPREGROhW|AOU2?m*dlEtbBH{=@_q7B@>;5M@ zl0jBRr{`z_Fvm*(wtuLU3Y~{P3g4m&KU{+5#|6DSjYFHK5`dd^t%u~Tr@l#|x&$B> zHG2xOJ6AM)n2)v(=RHWjxX?k|?{i{F0>EDIZ#m7b3fH~1>uLG7)WSQ}xkqO$1mK${ z0a#4eiWSGQ{9(l6^xXC@BckWwk~ja|Ncx45%-lbX=v^4;8~oEq(IctWlndO}<(=I> zkKh8=ZucnYFI?PTS;GHG`=3douix6>6YI@Qt7zk!c3Ry1Fsvzk(PVD@A+{R~&qZ^Y z-5w9p>oZIXJNZ+U$PPu0H{N7CR3ZR(w*xBAq7j;SYnPZiK3=y9I}k4yrzdXC%^tB= zCmkOdE!!59RPl4(aOUTzh)+76s~Ipilr=xEQ9v*Wdz?KC8YKX<&Dxae>n^GM&EB!j z&UOchkHM`*!Osamn-%t*zbj~byRzAbot8bKKpK}bhFtw}{g9{F9u@b#Ag6P&=ger? zDwslYE}T3=QM6PTgl8+v8_k++d1exas`;rJ#(5ZnfRoie6NK(`9gtr&lnIF6-Cw;Y zZ_7DWLv3szKlM|q=~c=#g}$Y^xlN&GHnXoFXU0bH^6*ZoF8M5Q;w{9(1oh43A^R!< zFj1E7?RSEE)QPXH5FZ$*3Xspyyf28!ZK zeo+r4`02Fen)`TJ&R4m&!fM>tzvXaO);2XXRNamp*66V^YvTx9&Jhh3cWNu-;GJ#H zWM+ojwhxM>D!;;@ThX=@sKgp@r*%{0!a?0`;pNgsB38$`?9@)H?CCUJpM)~)g&A4n zu~Vp-XtyVOszbBM0s`*{95x?@<<6$jpT;M~P(OaFCZO+?ih=GHk?>-O!Qm@wUp%iU~UX3=Z)H=%|&sG2kGUV{Ik z2it2z?M1~j(b0ERuOv;PiDu{ce(bn@4}as*czG#r1Dys4MgTsZ-05R8IA0MGEY_V* z2tezCj#XDI)!b;|DD_0$nRpAEZFw~I#Bz93j1TT1QVR8Y`d()S{#bgJG`y#{F;0&| zr^>CtkN?oQ$iQ)tTC2~vdIy_0gjFyX?~wJ;MN&Eb~-3m z8!ay!I)(Ybyyn@8-!3~CFG)D@n(=Fv1Vw$};-daliNRj&Co&=Y_@h*&2qCo&JuI7>G}0GtAUjOfy9NeQ)%3dR zK&AtCs${0OX3akT=i8K6KP0A9g0H?VJwHpffrExJ_pUo83tuYLK<};~CQ%_org1V& zMU8F-uFXb{D#Sz5ACN1EB!|_6FDQwHsg9;IUP}@MsA%_f@6|ROS&0kv4Zv~@43AZn z3{)ks){1PfZ>$@R$-dfiH?K`>b(O1Fp_4pdPkRw+f$$CZ9j$1UIu#m3Zm@x5`xBNa z=vdir@oL=7fR3#Bq~C1Aw?H}3J9tQAbY%I4MD;#{nvdH@ol4Y9dq-Di5^%s-{Wh`@ zR9GYT)PA8d#;cF0UY%t8bnB1pRlV>=?RNkNPbx zDn5;b*S0LDRf9~svE|qW5r0ucB4+cA zm9a*SQqs3DvnQ5YrQ_+2WO+=TE%%)dXXz?7*1-B>C@|tm92?W1mNC?c=M3T z2e>g+^!0Bb*O6Qwvw=JJs}z-STDvB1T)sADESTMWw&c(ujN8MEO6&8}msIp@_aLsV z{Ng^jIy|u_+DhYIZXbBJ{_fg}Zy6$wt9J!hHC3lWpR^yVlGvTBCf5r!p3C|oqKn?xY_=|*(wO&%ke=6KSfYJQL%YXLi9BKlP(x*cJ%G1vM#{$>eubvk#ik_gv<#kIjJ9wQdx2`9s zt%a(UD``KRR+2Vne~ti|CE?h9ADz|{fY#D;xwJe9Z>K)v`^_bLHr~j^4lbJN*N~=w ztVIJK(wPzsSi$FXA(C>wn_H5l_qetNwvwVC`dci}OlT~WLh)hdfRND0N3NK?a*3!- zEZT&hg}#%|-^sV|oHuK?k00#^Z_>~H?IKXM{|PTK{`GA-jv24q%FaLlE}7F9(OWyB zHYdD=B2I5j_+ir>8k#F&9ymPKrJU?;Xn4aY8bhJ^YwvOPJO^EQDiha$u2)=k4C*bmz| zgYSJ%{pwnF)eT#p+^h0MZ~5?D99KV5A5!)8$SH`8wXaf$E8#k_x}kj9_*3A{ug$OG z1fXZkjR4qE5P-av$EHUv83ELgBtD`Do0#jTZs~rDeN6PO5^nO0P|bThnv8cZKKTEy K|2P9MVe)U&m1; + let user: request.SuperTest; + let authorisedUser: request.SuperTest; + let mongoConnection: MongoClient; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let mongoClient: Db; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let adminProfile: IUser; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let userProfile: IUser; + let authorisedUserProfile: IUser; + + afterAll(async () => { + await db.closeConnection(); + await mongoConnection?.close(); + await mongodb.stop(); + + /* claer all mocks */ + jest.clearAllMocks(); + }); + + beforeAll(async () => { // eslint-disable-line no-undef + /* Creating a in-memory MongoDB instance for testing */ + const dbName = uuid(); + mongodb = await MongoMemoryServer.create({ instance: { dbName } }); + const connectionString = mongodb.getUri(); + await setupDatabase(connectionString, dbName); + /* Wiring up the backend server */ + config.objectStore.port = (global as any).minioContainerPort; + config.database.mongo_url = connectionString; + config.database.database = dbName; + await db.connect(config.database, MongoClient); + await objStore.connect(config.objectStore); + const router = new Router(config); + await router.init(); + /* Connect mongo client (for test setup later / retrieve info later) */ + mongoConnection = await MongoClient.connect(connectionString); + mongoClient = mongoConnection.db(dbName); + + /* Connecting clients for testing later */ + app = router.getApp(); + admin = request.agent(app); + user = request.agent(app); + await connectAdmin(admin); + await connectUser(user); + + // add the root node for each user + const users = await db.collections.users_collection.find({}).toArray(); + adminProfile = users.filter(el => el.type === enumUserTypes.ADMIN)[0]; + userProfile = users.filter(el => el.type === enumUserTypes.STANDARD)[0]; + + /* setup: creating a privileged user */ + const username = uuid(); + authorisedUserProfile = { + username, + type: enumUserTypes.STANDARD, + firstname: `${username}_firstname`, + lastname: `${username}_lastname`, + password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', + otpSecret: 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA', + email: `${username}@example.com`, + description: 'I am a new user.', + emailNotificationsActivated: true, + organisation: 'organisation_system', + id: `new_user_id_${username}`, + emailNotificationsStatus: { expiringNotification: false }, + resetPasswordRequests: [], + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); + + const roleId = uuid(); + const newRole = { + id: roleId, + studyId: '', + name: `${roleId}_rolename`, + dataPermissions: [{ + fields: ['^.*$'], + dataProperties: {}, + includeUnVersioned: true, + permission: 7 + }], + studyRole: enumStudyRoles.STUDY_MANAGER, + users: [authorisedUserProfile.id], + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + await mongoClient.collection(config.database.collections.roles_collection).insertOne(newRole); + + authorisedUser = request.agent(app); + await connectAgent(authorisedUser, username, 'admin', authorisedUserProfile.otpSecret); + }); + + afterEach(async () => { + await db.collections.studies_collection.deleteMany({}); + await db.collections.files_collection.deleteMany({}); + await db.collections.field_dictionary_collection.deleteMany({}); + await db.collections.data_collection.deleteMany({}); + }); + + describe('tRPC Study APIs', () => { + test('Create a study', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response = await request; + expect(response.status).toBe(200); + expect(response.body.result.data.name).toBe('Test Study'); + const file = await db.collections.files_collection.findOne({}); + expect(file?.id).toBe(response.body.result.data.profile); + const study = await db.collections.studies_collection.findOne({}); + expect(study?.id).toBe(response.body.result.data.id); + }); + test('Create a study (no permission)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = user.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response = await request; + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Only admin can create a study.'); + }); + test('Create a study (duplicates names)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + await request; + const request2 = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response2 = await request2; + expect(response2.status).toBe(400); + expect(response2.body.error.message).toBe('Study name already used.'); + }); + test('Edit a study', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + const request2 = admin.post('/trpc/study.editStudy') + .field('studyId', response1.body.result.data.id) + .attach('profile', filePath) + .field('name', 'Test Study edited') + .field('description', ''); + const response2 = await request2; + expect(response2.status).toBe(200); + expect(response2.body.result.data.name).toBe('Test Study edited'); + const files = await db.collections.files_collection.find({}).toArray(); + expect(files).toHaveLength(2); + expect(files[1]?.id).toBe(response2.body.result.data.profile); + const study = await db.collections.studies_collection.findOne({}); + expect(study?.id).toBe(response2.body.result.data.id); + }); + test('Edit a study (authorised study manager)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + + await db.collections.roles_collection.updateMany({}, { $set: { studyId: response1.body.result.data.id } }); + + const request2 = authorisedUser.post('/trpc/study.editStudy') + .field('studyId', response1.body.result.data.id) + .attach('profile', filePath) + .field('name', 'Test Study edited') + .field('description', ''); + const response2 = await request2; + expect(response2.status).toBe(200); + expect(response2.body.result.data.name).toBe('Test Study edited'); + const files = await db.collections.files_collection.find({}).toArray(); + expect(files).toHaveLength(2); + expect(files[1]?.id).toBe(response2.body.result.data.profile); + const study = await db.collections.studies_collection.findOne({}); + expect(study?.id).toBe(response2.body.result.data.id); + + // clear database + await db.collections.roles_collection.updateMany({}, { $set: { studyId: '' } }); + }); + test('Edit a study (no permission)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + const request2 = user.post('/trpc/study.editStudy') + .field('studyId', response1.body.result.data.id) + .attach('profile', filePath) + .field('name', 'Test Study edited') + .field('description', ''); + const response2 = await request2; + expect(response2.status).toBe(400); + expect(response2.body.error.message).toBe('Only admin or study manager can edit a study.'); + }); + test('Edit a study (study does not exist)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.editStudy') + .attach('profile', filePath) + .field('name', 'Test Study edited') + .field('description', '') + .field('studyId', 'random'); + const response = await request; + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Study does not exist.'); + }); + test('Edit a study (used name)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request1 = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request1; + const request2 = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study 2') + .field('description', ''); + await request2; + const request3 = admin.post('/trpc/study.editStudy') + .field('studyId', response1.body.result.data.id) + .attach('profile', filePath) + .field('name', 'Test Study 2') + .field('description', ''); + const response3 = await request3; + expect(response3.status).toBe(400); + expect(response3.body.error.message).toBe('Study name already used.'); + }); + test('Delete a study', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + const response2 = await admin.post('/trpc/study.deleteStudy') + .send({ + studyId: response1.body.result.data.id + }); + expect(response2.status).toBe(200); + expect(response2.body.result.data.id).toBe(response1.body.result.data.id); + }); + test('Delete a study (no permission)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + const response2 = await user.post('/trpc/study.deleteStudy') + .send({ + studyId: response1.body.result.data.id + }); + expect(response2.status).toBe(400); + expect(response2.body.error.message).toBe('Only admin can delete a study.'); + }); + test('Delete a study (study does not exist)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + await request; + const response2 = await admin.post('/trpc/study.deleteStudy') + .send({ + studyId: 'random' + }); + expect(response2.status).toBe(400); + expect(response2.body.error.message).toBe('Study does not exist.'); + }); + test('Get studies', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + await db.collections.roles_collection.updateMany({}, { $set: { studyId: response1.body.result.data.id } }); + const paramteres = { + }; + const response2 = await authorisedUser.get('/trpc/study.getStudies?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response2.status).toBe(200); + expect(response2.body.result.data).toHaveLength(1); + expect(response2.body.result.data[0].id).toBe(response1.body.result.data.id); + }); + test('Get studies (with valid study id)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + await db.collections.roles_collection.updateMany({}, { $set: { studyId: response1.body.result.data.id } }); + const paramteres = { + studyId: response1.body.result.data.id + }; + const response2 = await authorisedUser.get('/trpc/study.getStudies?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response2.status).toBe(200); + expect(response2.body.result.data).toHaveLength(1); + expect(response2.body.result.data[0].id).toBe(response1.body.result.data.id); + }); + test('Get studies (with invalid study id)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + await db.collections.roles_collection.updateMany({}, { $set: { studyId: response1.body.result.data.id } }); + const paramteres = { + studyId: 'random' + }; + const response2 = await authorisedUser.get('/trpc/study.getStudies?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response2.status).toBe(400); + expect(response2.body.error.message).toBe('Study does not exist.'); + }); + test('Get studies (unpermitted user)', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const request = admin.post('/trpc/study.createStudy') + .attach('profile', filePath) + .field('name', 'Test Study') + .field('description', ''); + const response1 = await request; + await db.collections.roles_collection.updateMany({}, { $set: { studyId: response1.body.result.data.id } }); + const paramteres = { + }; + const response2 = await user.get('/trpc/study.getStudies?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response2.status).toBe(200); + expect(response2.body.result.data).toHaveLength(0); + }); + // test('Create a new data version', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // await db.collections.roles_collection.updateMany({}, { $set: { studyId: response1.body.result.data.id } }); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // const response2 = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // expect(response2.status).toBe(200); + // expect(response2.body.result.data.version).toBe('1.0'); + // const data = await db.collections.data_collection.findOne({}); + // const field = await db.collections.field_dictionary_collection.findOne({}); + // const study = await db.collections.studies_collection.findOne({}); + // expect(field?.dataVersion).toBe(response2.body.result.data.id); + // expect(data?.dataVersion).toBe(response2.body.result.data.id); + // expect(study?.currentDataVersion).toBe(0); + // expect(study?.dataVersions).toHaveLength(1); + // expect(study?.dataVersions[0].id).toBe(response2.body.result.data.id); + // }); + // test('Create a new data version (no permission)', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_USER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // const response2 = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // expect(response2.status).toBe(400); + // expect(response2.body.error.message).toBe(errorCodes.NO_PERMISSION_ERROR); + // }); + // test('Create a new data version (version is not float string)', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_MANAGER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // const response2 = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0.5', + // tag: '1.0' + // }); + // expect(response2.status).toBe(400); + // expect(response2.body.error.message).toBe('Version must be a float number.'); + // }); + // test('Create a new data version (duplicate versions)', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_MANAGER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '11' + // }] + // }); + // const response2 = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // expect(response2.status).toBe(400); + // expect(response2.body.error.message).toBe('Version has been used.'); + // }); + // test('Create a new data version (nothing update)', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_MANAGER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // const response = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // expect(response.status).toBe(400); + // expect(response.body.error.message).toBe('Nothing to update.'); + // }); + // test('Set to a previous data version', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_MANAGER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // const response2 = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '11' + // }] + // }); + // await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.1', + // tag: '1.1' + // }); + // const response3 = await user.post('/trpc/study.setDataversionAsCurrent') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersionId: response2.body.result.data.id + // }); + // expect(response3.status).toBe(200); + // expect(response3.body.result.data.successful).toBe(true); + // const study = await db.collections.studies_collection.findOne({}); + // expect(study?.currentDataVersion).toBe(0); + // expect(study?.dataVersions).toHaveLength(2); + // }); + // test('Set to a previous data version (no permission)', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_MANAGER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // const response2 = await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '11' + // }] + // }); + // await db.collections?.roles_collection.updateOne({}, { + // $set: { + // studyRole: enumStudyRoles.STUDY_USER + // } + // }); + // await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.1', + // tag: '1.1' + // }); + // const response3 = await user.post('/trpc/study.setDataversionAsCurrent') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersionId: response2.body.result.data.id + // }); + // expect(response3.status).toBe(400); + // expect(response3.body.error.message).toBe(errorCodes.NO_PERMISSION_ERROR); + // }); + // test('Set to a previous data version (version id not exist)', async () => { + // const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + // const request = admin.post('/trpc/study.createStudy'); + // request.attach('profile', filePath); + // request.field('name', 'Test Study'); + // request.field('description', ''); + // const response1 = await request; + // const fullPermissionRole = { + // id: 'full_permission_role_id', + // studyId: response1.body.result.data.id, + // name: 'Full Permissison Role', + // description: '', + // // data permissions for studyId + // dataPermissions: [{ + // fields: ['^1.*$'], + // dataProperties: { + // }, + // permission: parseInt('110', 2) + // }], + // studyRole: enumStudyRoles.STUDY_MANAGER, + // users: [userProfile.id], + // groups: [] + // }; + // await db.collections.roles_collection.insertOne(fullPermissionRole); + // await user.post('/trpc/data.createStudyField') + // .send({ + // studyId: response1.body.result.data.id, + // fieldName: 'Test Field 1', + // fieldId: '1', + // description: '', + // dataType: enumDataTypes.INTEGER + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '10' + // }] + // }); + // await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.0', + // tag: '1.0' + // }); + // await user.post('/trpc/data.uploadStudyData') + // .send({ + // studyId: response1.body.result.data.id, + // data: [{ + // fieldId: '1', + // value: '11' + // }] + // }); + // await user.post('/trpc/study.createDataVersion') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersion: '1.1', + // tag: '1.1' + // }); + // const response3 = await user.post('/trpc/study.setDataversionAsCurrent') + // .send({ + // studyId: response1.body.result.data.id, + // dataVersionId: 'random' + // }); + // expect(response3.status).toBe(400); + // expect(response3.body.error.message).toBe('Data version does not exist.'); + // }); + }); +} else { + test(`${__filename.split(/[\\/]/).pop()} skipped because it requires Minio on Docker`, () => { + expect(true).toBe(true); + }); +} \ No newline at end of file diff --git a/packages/itmat-interface/test/trpcTests/user.test.ts b/packages/itmat-interface/test/trpcTests/user.test.ts index 6fb6732e9..53d07f263 100644 --- a/packages/itmat-interface/test/trpcTests/user.test.ts +++ b/packages/itmat-interface/test/trpcTests/user.test.ts @@ -99,6 +99,7 @@ if (global.hasMinio) { await db.collections.users_collection.findOneAndUpdate({ id: userProfile.id }, { $set: userProfile }); + await db.collections.users_collection.deleteMany({ id: { $nin: [adminProfile.id, userProfile.id] } }); }); describe('tRPC User APIs', () => { From 842d114851c3464ebbd2511e2fdea83768f1e3ff Mon Sep 17 00:00:00 2001 From: Florian Guitton Date: Tue, 14 May 2024 16:26:14 +0100 Subject: [PATCH 02/12] chore(interface): Provide better naming for declaration file --- .../src/graphql/{declare.d.ts => graphql-type-json.d.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/itmat-interface/src/graphql/{declare.d.ts => graphql-type-json.d.ts} (100%) diff --git a/packages/itmat-interface/src/graphql/declare.d.ts b/packages/itmat-interface/src/graphql/graphql-type-json.d.ts similarity index 100% rename from packages/itmat-interface/src/graphql/declare.d.ts rename to packages/itmat-interface/src/graphql/graphql-type-json.d.ts From a78d386c54fee7405f50483738e1fa388c5acf7a Mon Sep 17 00:00:00 2001 From: Florian Guitton Date: Tue, 14 May 2024 16:26:53 +0100 Subject: [PATCH 03/12] chore: Provide more flexible naming choices for commitlint --- .commitlintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.commitlintrc.js b/.commitlintrc.js index 7f4146536..30bdd333a 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -13,6 +13,7 @@ module.exports = { (await getProjects(ctx, projectFilter)) .forEach(element => { projectNames.add(element); + projectNames.add(element.replaceAll('itmat-', '')); }); return [ 2, From 9f16d471ddae208591cb6ddd72a589c7e585875c Mon Sep 17 00:00:00 2001 From: Florian Guitton Date: Tue, 14 May 2024 16:28:17 +0100 Subject: [PATCH 04/12] chore: Add dependabot configuration --- .editorconfig | 3 +++ .prettierignore | 1 + dependabot.yml | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 dependabot.yml diff --git a/.editorconfig b/.editorconfig index 9b7352176..c7c16d044 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,6 +8,9 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +[.yml] +indent_size = 2 + [*.md] max_line_length = off trim_trailing_whitespace = false diff --git a/.prettierignore b/.prettierignore index 0f144365e..121a58c9c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,3 +14,4 @@ *.mjsx *.cjs *.cjsx +*.yaml diff --git a/dependabot.yml b/dependabot.yml new file mode 100644 index 000000000..905452b31 --- /dev/null +++ b/dependabot.yml @@ -0,0 +1,58 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" + labels: + - "dependencies" + groups: + nx: + patterns: + - "nx" + - "@nx/*" + - "@jscutlery/semver" + lint: + patterns: + - "eslint*" + - "@typescript-eslint/*" + dev: + patterns: + - "commitlint" + - "@commitlint/*" + - "@swc/*" + - "@svgr/webpack" + - "esbuild*" + - "postcss" + - "prettier" + - "webpack" + - "verdaccio" + - "typscript" + test: + patterns: + - "jsdom" + - "sinon-chrome" + - "jest*" + - "babel-jest" + babel: + patterns: + - "babel*" + - "@babel/*" + types: + patterns: + - "@types/*" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" + labels: + - "actions" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" + labels: + - "docker" From 8e0d63ec62945a09ff82f30829b46a36d3b8ee1e Mon Sep 17 00:00:00 2001 From: Siyao Wang Date: Wed, 8 May 2024 16:36:58 +0100 Subject: [PATCH 05/12] refactor(core functions): Move core functions and interfaces to a separate package itmat-cores --- packages/itmat-commons/src/utils/emailer.ts | 13 + packages/itmat-commons/src/utils/index.ts | 1 + packages/itmat-commons/src/utils/poller.ts | 4 - packages/itmat-cores/.eslintrc.json | 34 + packages/itmat-cores/.lib.swcrc | 31 + packages/itmat-cores/README.md | 11 + .../itmat-cores/config/config.sample.json | 47 + packages/itmat-cores/express-user.d.ts | 17 + packages/itmat-cores/jest.config.ts | 23 + packages/itmat-cores/package.json | 5 + packages/itmat-cores/project.json | 43 + .../authentication/pubkeyAuthentication.ts | 4 +- packages/itmat-cores/src/core/fileCore.ts | 321 +++ packages/itmat-cores/src/core/jobCore.ts | 101 + packages/itmat-cores/src/core/logCore.ts | 63 + .../itmat-cores/src/core/organisationCore.ts | 72 + .../src}/core/permissionCore.ts | 200 +- packages/itmat-cores/src/core/pubkeyCore.ts | 208 ++ packages/itmat-cores/src/core/queryCore.ts | 109 + .../src/core/standardizationCore.ts | 172 ++ packages/itmat-cores/src/core/studyCore.ts | 2241 +++++++++++++++++ packages/itmat-cores/src/core/userCore.ts | 876 +++++++ packages/itmat-cores/src/database/database.ts | 38 + packages/itmat-cores/src/declare.d.ts | 1 + packages/itmat-cores/src/index.ts | 15 + packages/itmat-cores/src/log/logPlugin.ts | 121 + packages/itmat-cores/src/rest/fileDownload.ts | 80 + .../src/utils/ApolloServerContext.ts | 4 + .../itmat-cores/src/utils/configManager.ts | 43 + .../src/utils/definition.ts | 0 .../src/utils}/errors.ts | 0 packages/itmat-cores/src/utils/index.ts | 13 + .../src/utils/mfa.ts | 0 .../src/utils/noop.ts | 0 .../src/utils/pubkeycrypto.ts | 0 packages/itmat-cores/src/utils/pubsub.ts | 8 + .../src/utils/query.ts | 2 +- .../src/utils/regrex.ts | 0 packages/itmat-cores/src/utils/responses.ts | 16 + packages/itmat-cores/src/utils/server.ts | 7 + .../itmat-cores/src/utils/userLoginUtils.ts | 27 + packages/itmat-cores/tsconfig.json | 23 + packages/itmat-cores/tsconfig.lib.json | 18 + packages/itmat-cores/tsconfig.spec.json | 17 + .../itmat-interface/src/database/database.ts | 37 +- .../itmat-interface/src/emailer/emailer.ts | 14 +- .../src/graphql/core/fieldCore.ts | 84 - .../src/graphql/core/jobCore.ts | 24 - .../src/graphql/core/queryCore.ts | 32 - .../src/graphql/core/studyCore.ts | 659 ----- .../src/graphql/core/userCore.ts | 129 - .../itmat-interface/src/graphql/pubsub.ts | 5 - .../src/graphql/resolvers/fileResolvers.ts | 313 +-- .../src/graphql/resolvers/index.ts | 2 +- .../src/graphql/resolvers/jobResolvers.ts | 81 +- .../src/graphql/resolvers/logResolvers.ts | 60 +- .../resolvers/organisationResolvers.ts | 21 +- .../graphql/resolvers/permissionResolvers.ts | 176 +- .../src/graphql/resolvers/pubkeyResolvers.ts | 199 +- .../src/graphql/resolvers/queryResolvers.ts | 87 +- .../resolvers/standardizationResolvers.ts | 173 +- .../src/graphql/resolvers/studyResolvers.ts | 1623 +----------- .../src/graphql/resolvers/userResolvers.ts | 830 +----- .../itmat-interface/src/interfaceRunner.ts | 3 +- packages/itmat-interface/src/log/logPlugin.ts | 118 +- .../itmat-interface/src/rest/fileDownload.ts | 66 +- packages/itmat-interface/src/server/router.ts | 18 +- packages/itmat-interface/src/server/server.ts | 2 +- .../src/utils/configManager.ts | 43 +- .../src/utils/userLoginUtils.ts | 27 +- .../test/serverTests/_loginHelper.ts | 4 +- .../test/serverTests/file.test.ts | 2 +- .../test/serverTests/job.test.ts | 2 +- .../test/serverTests/log.test.ts | 5 +- .../test/serverTests/permission.test.ts | 2 +- .../test/serverTests/standardization.test.ts | 2 +- .../test/serverTests/study.test.ts | 6 +- .../test/serverTests/users.test.ts | 12 +- .../src/utils/configManager.ts | 8 +- .../src/components/log/logList.tsx | 1 - tsconfig.base.json | 3 + yarn.lock | 2 +- 82 files changed, 5229 insertions(+), 4675 deletions(-) create mode 100644 packages/itmat-commons/src/utils/emailer.ts create mode 100644 packages/itmat-cores/.eslintrc.json create mode 100644 packages/itmat-cores/.lib.swcrc create mode 100644 packages/itmat-cores/README.md create mode 100644 packages/itmat-cores/config/config.sample.json create mode 100644 packages/itmat-cores/express-user.d.ts create mode 100644 packages/itmat-cores/jest.config.ts create mode 100644 packages/itmat-cores/package.json create mode 100644 packages/itmat-cores/project.json rename packages/{itmat-interface => itmat-cores}/src/authentication/pubkeyAuthentication.ts (89%) create mode 100644 packages/itmat-cores/src/core/fileCore.ts create mode 100644 packages/itmat-cores/src/core/jobCore.ts create mode 100644 packages/itmat-cores/src/core/logCore.ts create mode 100644 packages/itmat-cores/src/core/organisationCore.ts rename packages/{itmat-interface/src/graphql => itmat-cores/src}/core/permissionCore.ts (65%) create mode 100644 packages/itmat-cores/src/core/pubkeyCore.ts create mode 100644 packages/itmat-cores/src/core/queryCore.ts create mode 100644 packages/itmat-cores/src/core/standardizationCore.ts create mode 100644 packages/itmat-cores/src/core/studyCore.ts create mode 100644 packages/itmat-cores/src/core/userCore.ts create mode 100644 packages/itmat-cores/src/database/database.ts create mode 100644 packages/itmat-cores/src/declare.d.ts create mode 100644 packages/itmat-cores/src/index.ts create mode 100644 packages/itmat-cores/src/log/logPlugin.ts create mode 100644 packages/itmat-cores/src/rest/fileDownload.ts create mode 100644 packages/itmat-cores/src/utils/ApolloServerContext.ts create mode 100644 packages/itmat-cores/src/utils/configManager.ts rename packages/{itmat-interface => itmat-cores}/src/utils/definition.ts (100%) rename packages/{itmat-interface/src/graphql => itmat-cores/src/utils}/errors.ts (100%) create mode 100644 packages/itmat-cores/src/utils/index.ts rename packages/{itmat-interface => itmat-cores}/src/utils/mfa.ts (100%) rename packages/{itmat-interface => itmat-cores}/src/utils/noop.ts (100%) rename packages/{itmat-interface => itmat-cores}/src/utils/pubkeycrypto.ts (100%) create mode 100644 packages/itmat-cores/src/utils/pubsub.ts rename packages/{itmat-interface => itmat-cores}/src/utils/query.ts (99%) rename packages/{itmat-interface => itmat-cores}/src/utils/regrex.ts (100%) create mode 100644 packages/itmat-cores/src/utils/responses.ts create mode 100644 packages/itmat-cores/src/utils/server.ts create mode 100644 packages/itmat-cores/src/utils/userLoginUtils.ts create mode 100644 packages/itmat-cores/tsconfig.json create mode 100644 packages/itmat-cores/tsconfig.lib.json create mode 100644 packages/itmat-cores/tsconfig.spec.json delete mode 100644 packages/itmat-interface/src/graphql/core/fieldCore.ts delete mode 100644 packages/itmat-interface/src/graphql/core/jobCore.ts delete mode 100644 packages/itmat-interface/src/graphql/core/queryCore.ts delete mode 100644 packages/itmat-interface/src/graphql/core/studyCore.ts delete mode 100644 packages/itmat-interface/src/graphql/core/userCore.ts diff --git a/packages/itmat-commons/src/utils/emailer.ts b/packages/itmat-commons/src/utils/emailer.ts new file mode 100644 index 000000000..64d262308 --- /dev/null +++ b/packages/itmat-commons/src/utils/emailer.ts @@ -0,0 +1,13 @@ +import nodemailer, { SendMailOptions } from 'nodemailer'; + +export class Mailer { + private readonly _client: nodemailer.Transporter; + + constructor(config: Parameters[0]) { + this._client = nodemailer.createTransport(config); + } + + public async sendMail(mail: SendMailOptions): Promise { + await this._client.sendMail(mail); + } +} diff --git a/packages/itmat-commons/src/utils/index.ts b/packages/itmat-commons/src/utils/index.ts index cb0a37b90..799551154 100644 --- a/packages/itmat-commons/src/utils/index.ts +++ b/packages/itmat-commons/src/utils/index.ts @@ -7,3 +7,4 @@ export type { IObjectStoreConfig } from './objStore'; export { ObjectStore } from './objStore'; export { Logger } from './logger'; export { JobPoller } from './poller'; +export { Mailer } from './emailer'; diff --git a/packages/itmat-commons/src/utils/poller.ts b/packages/itmat-commons/src/utils/poller.ts index 666def16e..73536e35a 100644 --- a/packages/itmat-commons/src/utils/poller.ts +++ b/packages/itmat-commons/src/utils/poller.ts @@ -60,11 +60,7 @@ export class JobPoller { await this.action(updateResult).catch(() => { return; }); Logger.log(`${this.identity} Finished processing job of type ${updateResult.jobType} - id: ${updateResult.id}.`); this.setInterval(); - } else { - Logger.log(`${this.identity} No job found.`); - // this.setInterval(); } - } catch (err) { //TODO Handle error recording Logger.error(`${this.identity} Errored picking up a job: ${err}`); diff --git a/packages/itmat-cores/.eslintrc.json b/packages/itmat-cores/.eslintrc.json new file mode 100644 index 000000000..4de975cd3 --- /dev/null +++ b/packages/itmat-cores/.eslintrc.json @@ -0,0 +1,34 @@ +{ + "extends": [ + "../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*", + "node_modules" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/packages/itmat-cores/.lib.swcrc b/packages/itmat-cores/.lib.swcrc new file mode 100644 index 000000000..4bc7f5daf --- /dev/null +++ b/packages/itmat-cores/.lib.swcrc @@ -0,0 +1,31 @@ +{ + "jsc": { + "target": "ES2022", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + }, + "keepClassNames": true, + "externalHelpers": true, + "loose": true + }, + "module": { + "type": "commonjs", + "strict": true, + "noInterop": false + }, + "sourceMaps": true, + "exclude": [ + "jest.config.ts", + ".*.spec.tsx?$", + ".*.test.tsx?$", + "./src/jest-setup.ts$", + "./**/jest-setup.ts$", + ".*.js$" + ] +} \ No newline at end of file diff --git a/packages/itmat-cores/README.md b/packages/itmat-cores/README.md new file mode 100644 index 000000000..5feddf1e1 --- /dev/null +++ b/packages/itmat-cores/README.md @@ -0,0 +1,11 @@ +# itmat-cores + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build itmat-cores` to build the library. + +## Running unit tests + +Run `nx test itmat-cores` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/itmat-cores/config/config.sample.json b/packages/itmat-cores/config/config.sample.json new file mode 100644 index 000000000..622ca4650 --- /dev/null +++ b/packages/itmat-cores/config/config.sample.json @@ -0,0 +1,47 @@ +{ + "appName": "ITMAT Interface", + "database": { + "mongo_url": "mongodb://localhost:27017", + "database": "itmat", + "collections": { + "jobs_collection": "JOB_COLLECTION", + "users_collection": "USER_COLLECTION", + "organisations_collection": "ORGANISATION_COLLECTION", + "studies_collection": "STUDY_COLLECTION", + "projects_collection": "PROJECT_COLLECTION", + "queries_collection": "QUERY_COLLECTION", + "log_collection": "LOG_COLLECTION", + "data_collection": "DATA_COLLECTION", + "roles_collection": "ROLE_COLLECTION", + "field_dictionary_collection": "FIELD_COLLECTION", + "files_collection": "FILES_COLLECTION", + "sessions_collection": "SESSIONS_COLLECTION", + "pubkeys_collection": "PUBKEY_COLLECTION", + "standardizations_collection": "STANDARDIZATION_COLLECTION" + } + }, + "server": { + "port": 3333 + }, + "bcrypt": { + "saltround": 2 + }, + "objectStore": { + "host": "localhost", + "port": 8080, + "accessKey": "minioadmin", + "secretKey": "minioadmin", + "bucketRegion": "region", + "useSSL": false + }, + "sessionsSecret": "Change_Me", + "aesSecret": "change_this", + "nodemailer": { + "auth": { + "user": "change_this", + "pass": "change_this" + } + }, + "adminEmail": "admin@example.com", + "aeEndpoint": "http://localhost:9090" +} \ No newline at end of file diff --git a/packages/itmat-cores/express-user.d.ts b/packages/itmat-cores/express-user.d.ts new file mode 100644 index 000000000..6e7b3469d --- /dev/null +++ b/packages/itmat-cores/express-user.d.ts @@ -0,0 +1,17 @@ +import type { IUserWithoutToken } from '@itmat-broker/itmat-types'; + +declare global { + + namespace Express { + + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface User extends IUserWithoutToken { } + + interface Request { + user?: User; + headers: IncomingHttpHeaders; + login(user: User, done: (err: unknown) => void): void; + logout(done: (err: unknown) => void): void; + } + } +} \ No newline at end of file diff --git a/packages/itmat-cores/jest.config.ts b/packages/itmat-cores/jest.config.ts new file mode 100644 index 000000000..febf085e4 --- /dev/null +++ b/packages/itmat-cores/jest.config.ts @@ -0,0 +1,23 @@ +/* eslint-disable */ +import { readFileSync } from 'fs'; + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.lib.swcrc`, 'utf-8') +); +export default { + displayName: 'itmat-cores', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/itmat-cores', + testEnvironment: "/../../test/fixtures/_minioJestEnv", + transformIgnorePatterns: [ + "node_modules", + "\\.pnp\\.[^\\\/]+$", + "test[\\/]fixtures[\\/]_minio" + ] +}; diff --git a/packages/itmat-cores/package.json b/packages/itmat-cores/package.json new file mode 100644 index 000000000..e52ad7906 --- /dev/null +++ b/packages/itmat-cores/package.json @@ -0,0 +1,5 @@ +{ + "name": "@itmat-broker/itmat-cores", + "version": "0.0.1", + "type": "commonjs" +} \ No newline at end of file diff --git a/packages/itmat-cores/project.json b/packages/itmat-cores/project.json new file mode 100644 index 000000000..70acf92f4 --- /dev/null +++ b/packages/itmat-cores/project.json @@ -0,0 +1,43 @@ +{ + "name": "itmat-cores", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/itmat-cores/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/itmat-cores", + "main": "packages/itmat-cores/src/index.ts", + "tsConfig": "packages/itmat-cores/tsconfig.lib.json", + "assets": [ + "packages/itmat-cores/*.md" + ], + "generatePackageJson": true, + "thirdParty": true, + "external": [ + "bcrypt" + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": [ + "{workspaceRoot}/coverage/packages/itmat-cores" + ], + "options": { + "jestConfig": "packages/itmat-cores/jest.config.ts" + } + } + } +} \ No newline at end of file diff --git a/packages/itmat-interface/src/authentication/pubkeyAuthentication.ts b/packages/itmat-cores/src/authentication/pubkeyAuthentication.ts similarity index 89% rename from packages/itmat-interface/src/authentication/pubkeyAuthentication.ts rename to packages/itmat-cores/src/authentication/pubkeyAuthentication.ts index e2f2b1520..27f7b1ce7 100644 --- a/packages/itmat-interface/src/authentication/pubkeyAuthentication.ts +++ b/packages/itmat-cores/src/authentication/pubkeyAuthentication.ts @@ -1,10 +1,10 @@ import { GraphQLError } from 'graphql'; import { ApolloServerErrorCode } from '@apollo/server/errors'; -import { db } from '../database/database'; +import { DBType } from '../database/database'; import { IUser } from '@itmat-broker/itmat-types'; -export async function userRetrieval(pubkey: string): Promise { +export async function userRetrieval(db: DBType, pubkey: string): Promise { // retrieve userId associated with the token const pubkeyrec = await db.collections.pubkeys_collection.findOne({ jwtPubkey: pubkey, deleted: null }); if (pubkeyrec === null || pubkeyrec === undefined) { diff --git a/packages/itmat-cores/src/core/fileCore.ts b/packages/itmat-cores/src/core/fileCore.ts new file mode 100644 index 000000000..51d71d7d5 --- /dev/null +++ b/packages/itmat-cores/src/core/fileCore.ts @@ -0,0 +1,321 @@ +import { IDataEntry, IFile, IOrganisation, IPermissionManagementOptions, IUserWithoutToken, atomicOperation, deviceTypes } from '@itmat-broker/itmat-types'; +import { v4 as uuid } from 'uuid'; +import { DBType } from '../database/database'; +import { FileUpload } from 'graphql-upload-minimal'; +import { GraphQLError } from 'graphql'; +import { validate } from '@ideafast/idgen'; +import { fileSizeLimit } from '../utils/definition'; +import crypto from 'crypto'; +import { makeGenericReponse } from '../utils/responses'; +import { MatchKeysAndValues } from 'mongodb'; +import { errorCodes } from '../utils/errors'; +import { PermissionCore } from './permissionCore'; +import { StudyCore } from './studyCore'; +import { ObjectStore } from '@itmat-broker/itmat-commons'; + +// default visitId for file data +const targetVisitId = '0'; +export class FileCore { + db: DBType; + permissionCore: PermissionCore; + studyCore: StudyCore; + objStore: ObjectStore; + constructor(db: DBType, objStore: ObjectStore) { + this.db = db; + this.permissionCore = new PermissionCore(db); + this.studyCore = new StudyCore(db, objStore); + this.objStore = objStore; + } + + public async uploadFile(requester: IUserWithoutToken | undefined, studyId: string, file: Promise, description: string, hash?: string, fileLength?: bigint) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + // get the target fieldId of this file + const study = await this.studyCore.findOneStudy_throwErrorIfNotExist(studyId); + + const hasStudyLevelSubjectPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + const hasStudyLevelStudyDataPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasStudyLevelSubjectPermission && !hasStudyLevelStudyDataPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + let targetFieldId: string; + let isStudyLevel = false; + // obtain sitesIDMarker from db + const sitesIDMarkers = (await this.db.collections.organisations_collection.find({ deleted: null }).toArray()).reduce>((acc, curr) => { + if (curr.metadata?.siteIDMarker) { + acc[curr.metadata.siteIDMarker] = curr.shortname; + } + return acc; + }, {}); + // if the description object is empty, then the file is study-level data + // otherwise, a subjectId must be provided in the description object + // we will check other properties in the decription object (deviceId, startDate, endDate) + const parsedDescription = JSON.parse(description); + if (!parsedDescription) { + throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + if (!parsedDescription.participantId) { + isStudyLevel = true; + } else { + isStudyLevel = false; + if (!Object.keys(sitesIDMarkers).includes(parsedDescription.participantId?.substr(0, 1)?.toUpperCase())) { + throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + // check deviceId, startDate, endDate if necessary + if (parsedDescription.deviceId && parsedDescription.startDate && parsedDescription.endDate) { + if (!Object.keys(deviceTypes).includes(parsedDescription.deviceId?.substr(0, 3)?.toUpperCase()) || + !validate(parsedDescription.participantId?.substr(1) ?? '') || + !validate(parsedDescription.deviceId.substr(3) ?? '')) { + throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + } else { + throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + // if the targetFieldId is in the description object; then use the fieldId, otherwise, infer it from the device types + if (parsedDescription.fieldId) { + targetFieldId = parsedDescription.fieldId; + } else { + const device = parsedDescription.deviceId?.slice(0, 3); + targetFieldId = `Device_${deviceTypes[device].replace(/ /g, '_')}`; + } + // check fieldId exists + if ((await this.db.collections.field_dictionary_collection.find({ studyId: study.id, fieldId: targetFieldId, dateDeleted: null }).sort({ dateAdded: -1 }).limit(1).toArray()).length === 0) { + throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + // check field permission + if (!this.permissionCore.checkDataEntryValid(await this.permissionCore.combineUserDataPermissions(atomicOperation.WRITE, requester, studyId, undefined), targetFieldId, parsedDescription.participantId, targetVisitId)) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + } + + const file_ = await file; + const fileNameParts = file_.filename.split('.'); + + return new Promise((resolve, reject) => { + (async () => { + try { + let fileName: string = file_.filename; + let metadata: Record = {}; + if (!isStudyLevel) { + const matcher = /(.{1})(.{6})-(.{3})(.{6})-(\d{8})-(\d{8})\.(.*)/; + let startDate; + let endDate; + let participantId; + let deviceId; + // check description first, then filename + if (description) { + const parsedDescription = JSON.parse(description); + startDate = parseInt(parsedDescription.startDate); + endDate = parseInt(parsedDescription.endDate); + participantId = parsedDescription.participantId.toString(); + deviceId = parsedDescription.deviceId.toString(); + } else if (matcher.test(file_.filename)) { + const particles = file_.filename.split('-'); + participantId = particles[0]; + deviceId = particles[1]; + startDate = particles[2]; + endDate = particles[3]; + } else { + reject(new GraphQLError('Missing file description', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + + try { + if ( + !Object.keys(sitesIDMarkers).includes(participantId.substr(0, 1)?.toUpperCase()) || + !Object.keys(deviceTypes).includes(deviceId.substr(0, 3)?.toUpperCase()) || + !validate(participantId.substr(1) ?? '') || + !validate(deviceId.substr(3) ?? '') || + !startDate || !endDate || + (new Date(endDate).setHours(0, 0, 0, 0).valueOf()) > (new Date().setHours(0, 0, 0, 0).valueOf()) + ) { + reject(new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + } catch (e) { + reject(new GraphQLError('Missing file description', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + const typedStartDate = new Date(startDate); + const formattedStartDate = typedStartDate.getFullYear() + `${typedStartDate.getMonth() + 1}`.padStart(2, '0') + `${typedStartDate.getDate()}`.padStart(2, '0'); + const typedEndDate = new Date(endDate); + const formattedEndDate = typedEndDate.getFullYear() + `${typedEndDate.getMonth() + 1}`.padStart(2, '0') + `${typedEndDate.getDate()}`.padStart(2, '0'); + fileName = `${parsedDescription.participantId.toUpperCase()}-${parsedDescription.deviceId.toUpperCase()}-${formattedStartDate}-${formattedEndDate}.${fileNameParts[fileNameParts.length - 1]}`; + metadata = { + participantId: parsedDescription.participantId, + deviceId: parsedDescription.deviceId, + startDate: parsedDescription.startDate, // should be in milliseconds + endDate: parsedDescription.endDate, + tup: parsedDescription.tup + }; + } + + if (fileLength !== undefined && fileLength > fileSizeLimit) { + reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + const stream = file_.createReadStream(); + const fileUri = uuid(); + const hash_ = crypto.createHash('sha256'); + let readBytes = 0; + + stream.pause(); + + /* if the client cancelled the request mid-stream it will throw an error */ + stream.on('error', (e) => { + reject(new GraphQLError('Upload resolver file stream failure', { extensions: { code: errorCodes.FILE_STREAM_ERROR, error: e } })); + return; + }); + + stream.on('data', (chunk) => { + readBytes += chunk.length; + if (readBytes > fileSizeLimit) { + stream.destroy(); + reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + hash_.update(chunk); + }); + + await this.objStore.uploadFile(stream, studyId, fileUri); + + const hashString = hash_.digest('hex'); + if (hash && hash !== hashString) { + reject(new GraphQLError('File hash not match', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + // check if readbytes equal to filelength in parameters + if (fileLength !== undefined && fileLength.toString() !== readBytes.toString()) { + reject(new GraphQLError('File size mismatch', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + const fileEntry: IFile = { + id: uuid(), + fileName: fileName, + studyId: studyId, + description: description, + uploadTime: `${Date.now()}`, + uploadedBy: requester.id, + deleted: null, + metadata: metadata, + fileSize: readBytes.toString(), + uri: fileUri, + hash: hashString + }; + fileEntry.fileSize = readBytes.toString(); + fileEntry.uri = fileUri; + fileEntry.hash = hashString; + if (!isStudyLevel) { + await this.db.collections.data_collection.insertOne({ + id: uuid(), + m_studyId: studyId, + m_subjectId: parsedDescription.participantId, + m_versionId: null, + m_visitId: targetVisitId, + m_fieldId: targetFieldId, + value: '', + uploadedAt: (new Date()).valueOf(), + metadata: { + 'uploader:user': requester.id, + 'add': [fileEntry.id], + 'remove': [] + } + }); + } + const insertResult = await this.db.collections.files_collection.insertOne(fileEntry); + if (insertResult.acknowledged) { + resolve(fileEntry); + } else { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + + } catch (error) { + reject(new GraphQLError('General upload error', { extensions: { code: errorCodes.UNQUALIFIED_ERROR, error } })); + } + })().catch((e) => reject(e)); + }); + } + + public async deleteFile(requester: IUserWithoutToken | undefined, fileId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const file = await this.db.collections.files_collection.findOne({ deleted: null, id: fileId }); + + if (!file) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + file.studyId + ); + if (!hasStudyLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + const parsedDescription = JSON.parse(file.description); + if (Object.keys(parsedDescription).length === 0) { + await this.db.collections.files_collection.findOneAndUpdate({ deleted: null, id: fileId }, { $set: { deleted: Date.now().valueOf() } }); + return makeGenericReponse(); + } + const device = parsedDescription.deviceId.slice(0, 3); + if (!Object.keys(deviceTypes).includes(device)) { + throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + const targetFieldId = `Device_${(deviceTypes[device] as string).replace(/ /g, '_')}`; + if (!this.permissionCore.checkDataEntryValid(hasStudyLevelPermission.raw, targetFieldId, parsedDescription.participantId, targetVisitId)) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + // update data record + const obj = { + m_studyId: file.studyId, + m_subjectId: parsedDescription.participantId, + m_versionId: null, + m_visitId: targetVisitId, + m_fieldId: targetFieldId + }; + const existing = await this.db.collections.data_collection.findOne(obj); + if (!existing) { + await this.db.collections.data_collection.insertOne({ + ...obj, + id: uuid(), + uploadedAt: (new Date()).valueOf(), + value: '', + metadata: { + add: [], + remove: [] + } + }); + } + const objWithData: Partial> = { + ...obj, + id: uuid(), + value: '', + uploadedAt: (new Date()).valueOf(), + metadata: { + 'uploader:user': requester.id, + 'add': existing?.metadata?.add ?? [], + 'remove': (existing?.metadata?.remove || []).concat(fileId) + } + }; + const updateResult = await this.db.collections.data_collection.updateOne(obj, { $set: objWithData }, { upsert: true }); + + // const updateResult = await this.db.collections.files_collection.updateOne({ deleted: null, id: args.fileId }, { $set: { deleted: new Date().valueOf() } }); + if (updateResult.modifiedCount === 1 || updateResult.upsertedCount === 1) { + return makeGenericReponse(); + } else { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + } +} \ No newline at end of file diff --git a/packages/itmat-cores/src/core/jobCore.ts b/packages/itmat-cores/src/core/jobCore.ts new file mode 100644 index 000000000..9948f5fe7 --- /dev/null +++ b/packages/itmat-cores/src/core/jobCore.ts @@ -0,0 +1,101 @@ +import { IJobEntry, IJobEntryForQueryCuration, IPermissionManagementOptions, IUserWithoutToken, atomicOperation } from '@itmat-broker/itmat-types'; +import { v4 as uuid } from 'uuid'; +import { DBType } from '../database/database'; +import { GraphQLError } from 'graphql'; +import { errorCodes } from '../utils/errors'; +import { PermissionCore } from './permissionCore'; +import { StudyCore } from './studyCore'; +import { ObjectStore } from '@itmat-broker/itmat-commons'; + +enum JOB_TYPE { + QUERY_EXECUTION = 'QUERY_EXECUTION', + DATA_EXPORT = 'DATA_EXPORT' +} + +export class JobCore { + db: DBType; + permissionCore: PermissionCore; + studyCore: StudyCore; + constructor(db: DBType, objStore: ObjectStore) { + this.db = db; + this.permissionCore = new PermissionCore(db); + this.studyCore = new StudyCore(db, objStore); + } + + public async createJob(userId: string, jobType: string, files: string[], studyId: string, projectId?: string, jobId?: string): Promise { + const job: IJobEntry = { + requester: userId, + id: jobId || uuid(), + studyId, + jobType, + projectId, + requestTime: new Date().valueOf(), + receivedFiles: files, + status: 'QUEUED', + error: null, + cancelled: false + }; + await this.db.collections.jobs_collection.insertOne(job); + return job; + } + + public async createQueryCurationJob(requester: IUserWithoutToken | undefined, queryId: string[], studyId: string, projectId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check permission */ + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.job, + atomicOperation.WRITE, + requester, + studyId + ); + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.job, + atomicOperation.WRITE, + requester, + studyId, + projectId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + /* check study exists */ + await this.studyCore.findOneStudy_throwErrorIfNotExist(studyId); + + /* check if project exists */ + const projectExist = await this.db.collections.projects_collection.findOne({ id: projectId }); + if (!projectExist) { + throw new GraphQLError('Project does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + + /* check if the query exists */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const queryExist = await this.db.collections.queries_collection.findOne({ id: queryId[0] }); + if (!queryExist) { + throw new GraphQLError('Query does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + + const job: IJobEntryForQueryCuration = { + id: uuid(), + jobType: JOB_TYPE.QUERY_EXECUTION, + studyId: studyId, + requester: requester.id, + requestTime: new Date().valueOf(), + receivedFiles: [], + error: null, + status: 'QUEUED', + cancelled: false, + data: { + queryId: queryId, + projectId: projectId, + studyId: studyId + } + }; + const result = await this.db.collections.jobs_collection.insertOne(job); + if (!result.acknowledged) { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + return job; + } +} diff --git a/packages/itmat-cores/src/core/logCore.ts b/packages/itmat-cores/src/core/logCore.ts new file mode 100644 index 000000000..a7833a020 --- /dev/null +++ b/packages/itmat-cores/src/core/logCore.ts @@ -0,0 +1,63 @@ +import { ILogEntry, IUserWithoutToken, LOG_ACTION, LOG_STATUS, LOG_TYPE, userTypes } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; +import { GraphQLError } from 'graphql'; +import { errorCodes } from '../utils/errors'; +import { Filter } from 'mongodb'; + +export class LogCore { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + + static readonly hiddenFields = { + LOGIN_USER: ['password', 'totp'], + UPLOAD_FILE: ['file', 'description'] + }; + + public async getLogs(requester: IUserWithoutToken | undefined, requesterName?: string, requesterType?: userTypes, logType?: LOG_TYPE, actionType?: LOG_ACTION, status?: LOG_STATUS) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* only admin can access this field */ + if (!(requester.type === userTypes.ADMIN) && !(requester.metadata?.logPermission === true)) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + const queryObj: Filter = {}; + if (requesterName) { queryObj.requesterName = requesterName; } + if (requesterType) { queryObj.requesterType = requesterType; } + if (logType) { queryObj.logType = logType; } + if (actionType) { queryObj.actionType = actionType; } + if (status) { queryObj.status = status; } + + const logData = await this.db.collections.log_collection.find(queryObj, { projection: { _id: 0 } }).limit(1000).sort('time', -1).toArray(); + // log information decoration + for (const i in logData) { + logData[i].actionData = JSON.stringify(await this.logDecorationHelper(logData[i].actionData, logData[i].actionType)); + } + + return logData; + } + + public async logDecorationHelper(actionData: string, actionType: string) { + const obj = JSON.parse(actionData) ?? {}; + if (Object.keys(LogCore.hiddenFields).includes(actionType)) { + for (let i = 0; i < LogCore.hiddenFields[actionType as keyof typeof LogCore.hiddenFields].length; i++) { + delete obj[LogCore.hiddenFields[actionType as keyof typeof LogCore.hiddenFields][i]]; + } + } + if (actionType === LOG_ACTION.getStudy) { + const studyId = obj['studyId']; + const study = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (study === null || study === undefined) { + obj['name'] = ''; + } + else { + obj['name'] = study.name; + } + } + return obj; + } + +} diff --git a/packages/itmat-cores/src/core/organisationCore.ts b/packages/itmat-cores/src/core/organisationCore.ts new file mode 100644 index 000000000..6adf99385 --- /dev/null +++ b/packages/itmat-cores/src/core/organisationCore.ts @@ -0,0 +1,72 @@ +import { IOrganisation, IUserWithoutToken, userTypes } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; +import { GraphQLError } from 'graphql'; +import { errorCodes } from '../utils/errors'; +import { v4 as uuid } from 'uuid'; + +export class OrganisationCore { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + + public async getOrganisations(organisationId?: string) { + const queryObj = organisationId === undefined ? { deleted: null } : { deleted: null, id: organisationId }; + return await this.db.collections.organisations_collection.find(queryObj, { projection: { _id: 0 } }).toArray(); + } + + public async createOrganisation(requester: IUserWithoutToken | undefined, org: { name: string, shortname: string | null, containOrg: string | null, metadata }): Promise { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + const { name, shortname, containOrg, metadata } = org; + const entry: IOrganisation = { + id: uuid(), + name, + shortname, + containOrg, + deleted: null, + metadata: metadata?.siteIDMarker ? { + siteIDMarker: metadata.siteIDMarker + } : {} + }; + const result = await this.db.collections.organisations_collection.findOneAndUpdate({ name: name, deleted: null }, { + $set: entry + }, { + upsert: true + }); + if (result) { + return entry; + } else { + throw new GraphQLError('Database error', { extensions: { code: errorCodes.DATABASE_ERROR } }); + } + } + + public async deleteOrganisation(requester: IUserWithoutToken | undefined, id: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + const res = await this.db.collections.organisations_collection.findOneAndUpdate({ id: id }, { + $set: { + deleted: Date.now() + } + }, { + returnDocument: 'after' + }); + + if (res) { + return res; + } else { + throw new GraphQLError('Delete organisation failed.'); + } + } +} diff --git a/packages/itmat-interface/src/graphql/core/permissionCore.ts b/packages/itmat-cores/src/core/permissionCore.ts similarity index 65% rename from packages/itmat-interface/src/graphql/core/permissionCore.ts rename to packages/itmat-cores/src/core/permissionCore.ts index e237f077c..174bfbe98 100644 --- a/packages/itmat-interface/src/graphql/core/permissionCore.ts +++ b/packages/itmat-cores/src/core/permissionCore.ts @@ -1,16 +1,10 @@ import { GraphQLError } from 'graphql'; import { atomicOperation, IDataEntry, IDataPermission, IManagementPermission, IPermissionManagementOptions, IRole, IUserWithoutToken, userTypes } from '@itmat-broker/itmat-types'; -import { BulkWriteResult, Document } from 'mongodb'; +import { BulkWriteResult, Document, Filter } from 'mongodb'; import { v4 as uuid } from 'uuid'; -import { db } from '../../database/database'; -import { errorCodes } from '../errors'; - -interface ICreateRoleInput { - studyId: string; - projectId?: string; - roleName: string; - createdBy: string; -} +import { DBType } from '../database/database'; +import { errorCodes } from '../utils/errors'; +import { makeGenericReponse } from '../utils/responses'; export interface ICombinedPermissions { subjectIds: string[], @@ -25,8 +19,41 @@ export interface QueryMatcher { } export class PermissionCore { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + + public async getGrantedPermissions(requester: IUserWithoutToken | undefined, studyId?: string, projectId?: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const matchClause: Filter = { users: requester.id }; + if (studyId) + matchClause.studyId = studyId; + if (projectId) + matchClause.projectId = { $in: [projectId, undefined] }; + const aggregationPipeline = [ + { $match: matchClause } + // { $group: { _id: requester.id, arrArrPrivileges: { $addToSet: '$permissions' } } }, + // { $project: { arrPrivileges: { $reduce: { input: '$arrArrPrivileges', initialValue: [], in: { $setUnion: ['$$this', '$$value'] } } } } } + ]; + + const grantedPermissions = { + studies: await this.db.collections.roles_collection.aggregate(aggregationPipeline).toArray(), + projects: await this.db.collections.roles_collection.aggregate(aggregationPipeline).toArray() + }; + return grantedPermissions; + } + + public async getUsersOfRole(role: IRole) { + const listOfUsers = role.users; + return await (this.db.collections.users_collection.find({ id: { $in: listOfUsers } }, { projection: { _id: 0, password: 0, email: 0 } }).toArray()); + + } + public async getAllRolesOfStudyOrProject(studyId: string, projectId?: string): Promise { - return db.collections.roles_collection.find({ studyId, projectId }).toArray(); + return await this.db.collections.roles_collection.find({ studyId, projectId }).toArray(); } public async userHasTheNeccessaryManagementPermission(type: string, operation: string, user: IUserWithoutToken, studyId: string, projectId?: string) { @@ -39,7 +66,7 @@ export class PermissionCore { return true; } const tag = `permissions.manage.${type}`; - const roles = await db.collections.roles_collection.aggregate([ + const roles = await this.db.collections.roles_collection.aggregate([ { $match: { studyId, projectId: { $in: [projectId, null] }, users: user.id, deleted: null } }, // matches all the role documents where the study and project matches and has the user inside { $match: { [tag]: operation } } ]).toArray(); @@ -58,7 +85,7 @@ export class PermissionCore { fieldIds: [matchAnyString] }; } - const roles = await db.collections.roles_collection.aggregate([ + const roles = await this.db.collections.roles_collection.aggregate([ { $match: { studyId, projectId: { $in: [projectId, null] }, users: user.id, deleted: null } }, // matches all the role documents where the study and project matches and has the user inside { $match: { 'permissions.data.operations': operation } } ]).toArray(); @@ -114,7 +141,7 @@ export class PermissionCore { }; } - const roles = await db.collections.roles_collection.aggregate([ + const roles = await this.db.collections.roles_collection.aggregate([ { $match: { studyId, projectId: { $in: [projectId, null] }, users: user.id, deleted: null } }, // matches all the role documents where the study and project matches and has the user inside { $match: { 'permissions.data.operations': operation } } ]).toArray(); @@ -179,10 +206,28 @@ export class PermissionCore { return res; } - public async removeRole(roleId: string): Promise { - const updateResult = await db.collections.roles_collection.findOneAndUpdate({ id: roleId, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + public async removeRole(requester: IUserWithoutToken | undefined, roleId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + const role = await this.db.collections.roles_collection.findOne({ id: roleId, deleted: null }); + if (role === null) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* check permission */ + const hasPermission = await this.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.role, + atomicOperation.WRITE, + requester, + role.studyId, + role.projectId + ); + if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + const updateResult = await this.db.collections.roles_collection.findOneAndUpdate({ id: roleId, deleted: null }, { $set: { deleted: new Date().valueOf() } }); if (updateResult) { - return; + return makeGenericReponse(roleId); } else { throw new GraphQLError('Cannot delete role.', { extensions: { code: errorCodes.DATABASE_ERROR } }); } @@ -200,7 +245,7 @@ export class PermissionCore { } else if (projectId !== undefined) { queryObj = { projectId, deleted: null }; } - const updateResult = await db.collections.roles_collection.updateMany(queryObj, { $set: { deleted: new Date().valueOf() } }); + const updateResult = await this.db.collections.roles_collection.updateMany(queryObj, { $set: { deleted: new Date().valueOf() } }); if (updateResult.acknowledged) { return; } else { @@ -208,7 +253,61 @@ export class PermissionCore { } } - public async editRoleFromStudyOrProject(roleId: string, name?: string, description?: string, permissionChanges?: { data?: IDataPermission, manage?: IManagementPermission }, userChanges?: { add: string[], remove: string[] }): Promise { + public async editRole(requester: IUserWithoutToken | undefined, roleId: string, name?: string, description?: string, permissionChanges?: { data?: IDataPermission, manage?: IManagementPermission }, userChanges?: { add: string[], remove: string[] }): Promise { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const role = await this.db.collections.roles_collection.findOne({ id: roleId, deleted: null }); + if (role === null) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* check the requester has privilege */ + const hasPermission = await this.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.role, + atomicOperation.WRITE, + requester, + role.studyId, + role.projectId + ); + if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + /* check whether all the permissions are valid in terms of regular expressions */ + if (permissionChanges) { + if (permissionChanges.data) { + if (permissionChanges.data.subjectIds) { + for (const subjectId of permissionChanges.data.subjectIds) { + this.checkReExpIsValid(subjectId); + } + } + if (permissionChanges.data.visitIds) { + for (const visitId of permissionChanges.data.visitIds) { + this.checkReExpIsValid(visitId); + } + } + if (permissionChanges.data.fieldIds) { + for (const fieldId of permissionChanges.data.fieldIds) { + this.checkReExpIsValid(fieldId); + } + } + } + } + + /* check whether all the users exists */ + if (userChanges) { + const allRequestedUserChanges: string[] = [...userChanges.add, ...userChanges.remove]; + const testedUser: string[] = []; + for (const each of allRequestedUserChanges) { + if (!testedUser.includes(each)) { + const user = await this.db.collections.users_collection.findOne({ id: each, deleted: null }); + if (user === null) { + throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); + } else { + testedUser.push(each); + } + } + } + } if (permissionChanges === undefined) { permissionChanges = { data: { subjectIds: [], visitIds: [], fieldIds: [], uploaders: ['^.*$'], hasVersioned: false, operations: [] }, @@ -221,9 +320,10 @@ export class PermissionCore { } }; } + if (userChanges === undefined) { userChanges = { add: [], remove: [] }; } - const bulkop = db.collections.roles_collection.initializeUnorderedBulkOp(); + const bulkop = this.db.collections.roles_collection.initializeUnorderedBulkOp(); bulkop.find({ id: roleId, deleted: null }).updateOne({ $set: { permissions: permissionChanges }, $addToSet: { users: { $each: userChanges.add } } }); bulkop.find({ id: roleId, deleted: null }).updateOne({ $set: { permissions: permissionChanges }, $pullAll: { users: userChanges.remove } }); if (name) { @@ -233,7 +333,7 @@ export class PermissionCore { bulkop.find({ id: roleId, deleted: null }).updateOne({ $set: { description } }); } const result: BulkWriteResult = await bulkop.execute(); - const resultingRole = await db.collections.roles_collection.findOne({ id: roleId, deleted: null }); + const resultingRole = await this.db.collections.roles_collection.findOne({ id: roleId, deleted: null }); if (!resultingRole) { throw new GraphQLError('Role does not exist', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); } @@ -244,7 +344,7 @@ export class PermissionCore { if (permissionChanges.data?.filters) { if (permissionChanges.data.filters.length > 0) { const subqueries = translateCohort(permissionChanges.data.filters); - validSubjects = (await db.collections.data_collection.aggregate([{ + validSubjects = (await this.db.collections.data_collection.aggregate([{ $match: { $and: subqueries } }]).toArray()).map(el => el.m_subjectId); } @@ -252,7 +352,7 @@ export class PermissionCore { // update the data and field records - const dataBulkOp = db.collections.data_collection.initializeUnorderedBulkOp(); + const dataBulkOp = this.db.collections.data_collection.initializeUnorderedBulkOp(); const filters: ICombinedPermissions = { subjectIds: permissionChanges.data?.subjectIds || [], visitIds: permissionChanges.data?.visitIds || [], @@ -287,7 +387,7 @@ export class PermissionCore { }).update({ $set: { [dataTag]: false } }); - const fieldBulkOp = db.collections.field_dictionary_collection.initializeUnorderedBulkOp(); + const fieldBulkOp = this.db.collections.field_dictionary_collection.initializeUnorderedBulkOp(); const fieldIds = permissionChanges.data?.fieldIds || []; const fieldTag = `metadata.${'role:'.concat(roleId)}`; fieldBulkOp.find({ @@ -313,7 +413,44 @@ export class PermissionCore { } } - public async addRole(opt: ICreateRoleInput): Promise { + public async addRole(requester: IUserWithoutToken | undefined, studyId: string, projectId: string | undefined, roleName: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check whether user has at least provided one id */ + if (studyId === undefined && projectId === undefined) { + throw new GraphQLError('Please provide either study id or project id.', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + + /* check the requester has privilege */ + const hasPermission = await this.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.role, + atomicOperation.WRITE, + requester, + studyId, + projectId + ); + if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + /* check whether the target study or project exists */ + if (studyId && projectId) { // if both study id and project id are provided then just make sure they belong to each other + const result = await this.db.collections.projects_collection.findOne({ id: projectId, deleted: null }); + if (!result || result.studyId !== studyId) { + throw new GraphQLError('The project provided does not belong to the study provided', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + } else if (studyId) { // if only study id is provided + const study = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (!study) { + throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + } else if (projectId) { + const study = await this.db.collections.projects_collection.findOne({ id: projectId, deleted: null }); + if (!study) { + throw new GraphQLError('Project does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + } + + const opt = { createdBy: requester.id, studyId, projectId, roleName }; /* add user role */ const role: IRole = { id: uuid(), @@ -343,17 +480,22 @@ export class PermissionCore { metadata: {}, deleted: null }; - const updateResult = await db.collections.roles_collection.insertOne(role); + const updateResult = await this.db.collections.roles_collection.insertOne(role); if (updateResult.acknowledged) { return role; } else { throw new GraphQLError('Cannot create role.', { extensions: { code: errorCodes.DATABASE_ERROR } }); } } -} - -export const permissionCore = new PermissionCore(); + public checkReExpIsValid(pattern: string) { + try { + new RegExp(pattern); + } catch { + throw new GraphQLError(`${pattern} is not a valid regular expression.`, { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + } +} export function translateCohort(cohort) { const queries: Document[] = []; diff --git a/packages/itmat-cores/src/core/pubkeyCore.ts b/packages/itmat-cores/src/core/pubkeyCore.ts new file mode 100644 index 000000000..b76b16c5f --- /dev/null +++ b/packages/itmat-cores/src/core/pubkeyCore.ts @@ -0,0 +1,208 @@ +import { IPubkey, IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; +import * as pubkeycrypto from '../utils/pubkeycrypto'; +import { GraphQLError } from 'graphql'; +import { ApolloServerErrorCode } from '@apollo/server/errors'; +import { errorCodes } from '../utils/errors'; +import { UserCore } from './userCore'; +import { Mailer } from '@itmat-broker/itmat-commons'; +import { IConfiguration } from '../utils'; + +export class PubkeyCore { + db: DBType; + userCore: UserCore; + mailer: Mailer; + config: IConfiguration; + constructor(db: DBType, mailer: Mailer, config: IConfiguration) { + this.db = db; + this.userCore = new UserCore(db, mailer, config); + this.mailer = mailer; + this.config = config; + } + + public async getPubkeys(pubkeyId?: string, associatedUserId?: string) { + let queryObj; + if (pubkeyId === undefined) { + if (associatedUserId === undefined) { + queryObj = { deleted: null }; + } else { + queryObj = { deleted: null, associatedUserId: associatedUserId }; + } + } else { + queryObj = { deleted: null, id: pubkeyId }; + } + const cursor = this.db.collections.pubkeys_collection.find(queryObj, { projection: { _id: 0 } }); + return cursor.toArray(); + } + + public async keyPairGenwSignature() { + // Generate RSA key-pair with Signature for robot user + const keyPair = pubkeycrypto.rsakeygen(); + //default message = hash of the public key (SHA256) + const messageToBeSigned = pubkeycrypto.hashdigest(keyPair.publicKey); + const signature = pubkeycrypto.rsasigner(keyPair.privateKey, messageToBeSigned); + + return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, signature: signature }; + } + + public async rsaSigner(privateKey, message) { + let messageToBeSigned; + privateKey = privateKey.replace(/\\n/g, '\n'); + if (message === undefined) { + //default message = hash of the public key (SHA256) + try { + const reGenPubkey = pubkeycrypto.reGenPkfromSk(privateKey); + messageToBeSigned = pubkeycrypto.hashdigest(reGenPubkey); + } catch (error) { + throw new GraphQLError('Error: private-key incorrect!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); + } + + } else { + messageToBeSigned = message; + } + const signature = pubkeycrypto.rsasigner(privateKey, messageToBeSigned); + return { signature: signature }; + } + + public async issueAccessToken(pubkey, signature) { + // refine the public-key parameter from browser + pubkey = pubkey.replace(/\\n/g, '\n'); + + /* Validate the signature with the public key */ + if (!await pubkeycrypto.rsaverifier(pubkey, signature)) { + throw new GraphQLError('Signature vs Public key mismatched.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + const pubkeyrec = await this.db.collections.pubkeys_collection.findOne({ pubkey, deleted: null }); + if (pubkeyrec === null || pubkeyrec === undefined) { + throw new GraphQLError('This public-key has not been registered yet!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + // payload of the JWT for storing user information + const payload = { + publicKey: pubkeyrec.jwtPubkey, + associatedUserId: pubkeyrec.associatedUserId, + refreshCounter: pubkeyrec.refreshCounter, + Issuer: 'IDEA-FAST DMP' + }; + + // update the counter + const fieldsToUpdate = { + refreshCounter: (pubkeyrec.refreshCounter + 1) + }; + const updateResult = await this.db.collections.pubkeys_collection.findOneAndUpdate({ pubkey, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); + if (updateResult === null) { + throw new GraphQLError('Server error; cannot fulfil the JWT request.'); + } + // return the acccess token + const accessToken = { + accessToken: pubkeycrypto.tokengen(payload, pubkeyrec.jwtSeckey) + }; + + return accessToken; + } + + public async registerPubkey(requester: IUserWithoutToken | undefined, pubkey, signature, associatedUserId) { + // refine the public-key parameter from browser + pubkey = pubkey.replace(/\\n/g, '\n'); + const alreadyExist = await this.db.collections.pubkeys_collection.findOne({ pubkey, deleted: null }); + if (alreadyExist !== null && alreadyExist !== undefined) { + throw new GraphQLError('This public-key has already been registered.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* Check whether requester is the same as the associated user*/ + if (associatedUserId && (requester.id !== associatedUserId)) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + /* Validate the signature with the public key */ + try { + const signature_verifier = await pubkeycrypto.rsaverifier(pubkey, signature); + if (!signature_verifier) { + throw new GraphQLError('Signature vs Public-key mismatched.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + } catch (error) { + throw new GraphQLError('Error: Signature or Public-key is incorrect.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + /* Generate a public key-pair for generating and authenticating JWT access token later */ + const keypair = pubkeycrypto.rsakeygen(); + + /* Update the new public key if the user is already associated with another public key*/ + if (associatedUserId) { + const alreadyRegistered = await this.db.collections.pubkeys_collection.findOne({ associatedUserId, deleted: null }); + if (alreadyRegistered !== null && alreadyRegistered !== undefined) { + //updating the new public key. + const fieldsToUpdate = { + pubkey, + jwtPubkey: keypair.publicKey, + jwtSeckey: keypair.privateKey + }; + const updateResult = await this.db.collections.pubkeys_collection.findOneAndUpdate({ associatedUserId, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); + if (updateResult) { + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: requester.email, + subject: `[${this.config.appName}] New public-key has sucessfully registered!`, + html: ` +

+ Dear ${requester.firstname}, +

+

+ Your new public-key "${pubkey}" on ${this.config.appName} has successfully registered !
+ The old one is already wiped out! + You will need to keep your new private key secretly.
+ You will also need to sign a message (using this new public-key) to authenticate the owner of the public key.
+

+ +
+

+ The ${this.config.appName} Team. +

+ ` + }); + + return updateResult; + } else { + throw new GraphQLError('Server error; no entry or more than one entry has been updated.'); + } + } + } + + /* Register new public key (either associated with an user or not) */ + const registeredPubkey = await this.userCore.registerPubkey({ + pubkey, + jwtPubkey: keypair.publicKey, + jwtSeckey: keypair.privateKey, + associatedUserId: associatedUserId ?? null + }); + + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: requester.email, + subject: `[${this.config.appName}] Public-key Registration!`, + html: ` +

+ Dear ${requester.firstname}, +

+

+ You have successfully registered your public-key "${pubkey}" on ${this.config.appName}!
+ You will need to keep your private key secretly.
+ You will also need to sign a message (using your public-key) to authenticate the owner of the public key.
+

+ +
+

+ The ${this.config.appName} Team. +

+ ` + }); + + return registeredPubkey; + } + + +} diff --git a/packages/itmat-cores/src/core/queryCore.ts b/packages/itmat-cores/src/core/queryCore.ts new file mode 100644 index 000000000..4512f386c --- /dev/null +++ b/packages/itmat-cores/src/core/queryCore.ts @@ -0,0 +1,109 @@ +import { IPermissionManagementOptions, IProject, IQueryEntry, IUserWithoutToken, atomicOperation } from '@itmat-broker/itmat-types'; +import { v4 as uuid } from 'uuid'; +import { GraphQLError } from 'graphql'; +import { errorCodes } from '../utils/errors'; +import { DBType } from '../database/database'; +import { PermissionCore } from './permissionCore'; + +export class QueryCore { + db: DBType; + permissionCore: PermissionCore; + constructor(db: DBType) { + this.db = db; + this.permissionCore = new PermissionCore(db); + } + + public async getQueryByIdparent(requester: IUserWithoutToken | undefined, queryId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check query exists */ + const queryEntry = await this.db.collections.queries_collection.findOne({ id: queryId }, { projection: { _id: 0, claimedBy: 0 } }); + if (queryEntry === null || queryEntry === undefined) { + throw new GraphQLError('Query does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + /* check permission */ + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.query, + atomicOperation.READ, + requester, + queryEntry.studyId, + queryEntry.projectId + ); + + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.query, + atomicOperation.READ, + requester, + queryEntry.studyId + ); + if (!hasProjectLevelPermission && !hasStudyLevelPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + return queryEntry; + } + + public async getQueries(requester: IUserWithoutToken | undefined, studyId: string, projectId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check permission */ + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.query, + atomicOperation.READ, + requester, + studyId, + projectId + ); + + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.query, + atomicOperation.READ, + requester, + studyId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + const entries = await this.db.collections.queries_collection.find({ studyId: studyId, projectId: projectId }).toArray(); + return entries; + } + + public async createQuery(userId: string, queryString, studyId: string, projectId?: string): Promise { + /* check study exists */ + const studySearchResult = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (studySearchResult === null || studySearchResult === undefined) { + throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + /* check project exists */ + const project = await this.db.collections.projects_collection.findOne>({ id: projectId, deleted: null }, { projection: { patientMapping: 0 } }); + if (project === null) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check project matches study */ + if (studySearchResult.id !== project.studyId) { + throw new GraphQLError('Study and project mismatch.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + const query: IQueryEntry = { + requester: userId, + id: uuid(), + queryString: queryString, + studyId: studyId, + projectId: projectId, + status: 'QUEUED', + error: null, + cancelled: false, + data_requested: queryString.data_requested, + cohort: queryString.cohort, + new_fields: queryString.new_fields + }; + await this.db.collections.queries_collection.insertOne(query); + return query; + } + + public async getUsersQuery_NoResult(userId: string): Promise { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return db.collections.queries_collection.find({ requester: userId }, { projection: { _id: 0, claimedBy: 0, queryResult: 0 } }).toArray(); + } + +} + diff --git a/packages/itmat-cores/src/core/standardizationCore.ts b/packages/itmat-cores/src/core/standardizationCore.ts new file mode 100644 index 000000000..59c7a3fe8 --- /dev/null +++ b/packages/itmat-cores/src/core/standardizationCore.ts @@ -0,0 +1,172 @@ +import { IProject, IStandardization, IUserWithoutToken, atomicOperation } from '@itmat-broker/itmat-types'; +import { GraphQLError } from 'graphql'; +import { errorCodes } from '../utils/errors'; +import { v4 as uuid } from 'uuid'; +import { makeGenericReponse } from '../utils/responses'; +import { DBType } from '../database/database'; +import { PermissionCore } from './permissionCore'; +import { StudyCore } from './studyCore'; +import { ObjectStore } from '@itmat-broker/itmat-commons'; + +export class StandarizationCore { + db: DBType; + permissionCore: PermissionCore; + studyCore: StudyCore; + constructor(db: DBType, objStore: ObjectStore) { + this.db = db; + this.permissionCore = new PermissionCore(db); + this.studyCore = new StudyCore(db, objStore); + } + + public async getStandardization(requester: IUserWithoutToken | undefined, versionId: string | null, studyId: string, projectId?: string, type?: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + let modifiedStudyId = studyId; + /* check study exists */ + if (!studyId && !projectId) { + throw new GraphQLError('Either studyId or projectId should be provided.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + if (studyId) { + await this.studyCore.findOneStudy_throwErrorIfNotExist(studyId); + } + if (projectId) { + const project: IProject = await this.studyCore.findOneProject_throwErrorIfNotExist(projectId); + modifiedStudyId = project.studyId; + } + + /* check permission */ + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId + ); + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId, + projectId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + const study = await this.studyCore.findOneStudy_throwErrorIfNotExist(modifiedStudyId); + // get all dataVersions that are valid (before/equal the current version) + const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + if (hasStudyLevelPermission && hasStudyLevelPermission.hasVersioned && versionId === null) { + availableDataVersions.push(null); + } + const standardizations = await this.db.collections.standardizations_collection.aggregate([{ + $sort: { uploadedAt: -1 } + }, { + $match: { dataVersion: { $in: availableDataVersions } } + }, { + $match: { studyId: studyId, type: type ?? /^.*$/ } + }, { + $group: { + _id: { + type: '$type', + field: '$field' + }, + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { newRoot: '$doc' } + }, { + $match: { deleted: null } + } + ]).toArray(); + return standardizations as IStandardization[]; + } + + public async createStandardization(requester: IUserWithoutToken | undefined, studyId, standardization) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + /* check study exists */ + const studySearchResult = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (studySearchResult === null || studySearchResult === undefined) { + throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + const stdRulesWithId = [...standardization.stdRules]; + stdRulesWithId.forEach(el => { + el.id = uuid(); + }); + if (!(this.permissionCore.checkDataEntryValid(hasPermission.raw, standardization.field[0].slice(1)))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + const standardizationEntry: IStandardization = { + id: uuid(), + studyId: studyId, + type: standardization.type, + field: standardization.field, + path: standardization.path, + stdRules: stdRulesWithId || [], + joinByKeys: standardization.joinByKeys || [], + dataVersion: null, + metadata: standardization.metadata, + uploadedAt: Date.now(), + deleted: null + }; + + await this.db.collections.standardizations_collection.findOneAndUpdate({ studyId: studyId, type: standardization.type, field: standardization.field, dataVersion: null }, { + $set: { ...standardizationEntry } + }, { + upsert: true + }); + return standardizationEntry; + } + + public async deleteStandardization(requester: IUserWithoutToken | undefined, studyId, type, field) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + if (!(this.permissionCore.checkDataEntryValid(hasPermission.raw, field[0].slice(1)))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + /* check study exists */ + const studySearchResult = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (studySearchResult === null || studySearchResult === undefined) { + throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + + // check type exists + const types: string[] = await this.db.collections.standardizations_collection.distinct('type', { studyId: studyId, deleted: null }); + if (!types.includes(type)) { + throw new GraphQLError('Type does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + const result = await this.db.collections.standardizations_collection.findOneAndUpdate({ studyId: studyId, field: field, type: type, dataVersion: null }, { + $set: { + id: uuid(), + studyId: studyId, + field: field, + type: type, + dataVersion: null, + uploadedAt: Date.now(), + deleted: Date.now() + } + }, { + upsert: true + }); + return makeGenericReponse(result?.id || ''); + } +} + diff --git a/packages/itmat-cores/src/core/studyCore.ts b/packages/itmat-cores/src/core/studyCore.ts new file mode 100644 index 000000000..1f82134a2 --- /dev/null +++ b/packages/itmat-cores/src/core/studyCore.ts @@ -0,0 +1,2241 @@ +import { GraphQLError } from 'graphql'; +import { IFile, IProject, IStudy, studyType, IStudyDataVersion, IDataEntry, IDataClip, IRole, IFieldEntry, deviceTypes, IOrganisation, IUserWithoutToken, IPermissionManagementOptions, atomicOperation, userTypes, IOntologyTree, ISubjectDataRecordSummary, IQueryString, IGroupedData, enumValueType, IValueDescription } from '@itmat-broker/itmat-types'; +import { v4 as uuid } from 'uuid'; +import { errorCodes } from '../utils/errors'; +import { ICombinedPermissions, PermissionCore, translateCohort } from './permissionCore'; +import { validate } from '@ideafast/idgen'; +import type { Filter, MatchKeysAndValues } from 'mongodb'; +import { FileUpload } from 'graphql-upload-minimal'; +import crypto from 'crypto'; +import { fileSizeLimit } from '../utils/definition'; +import { IGenericResponse, makeGenericReponse } from '../utils/responses'; +import { buildPipeline, dataStandardization, translateMetadata } from '../utils/query'; +import { DBType } from '../database/database'; +import { ObjectStore } from '@itmat-broker/itmat-commons'; + +export interface CreateFieldInput { + fieldId: string; + fieldName: string + tableName: string + dataType: enumValueType + possibleValues?: IValueDescription[] + unit?: string + comments?: string + metadata: Record +} + +export interface EditFieldInput { + fieldId: string; + fieldName: string; + tableName?: string; + dataType: enumValueType; + possibleValues?: IValueDescription[] + unit?: string + comments?: string +} + +export class StudyCore { + db: DBType; + permissionCore: PermissionCore; + objStore: ObjectStore; + constructor(db: DBType, objStore: ObjectStore) { + this.db = db; + this.permissionCore = new PermissionCore(db); + this.objStore = objStore; + } + + public async findOneStudy_throwErrorIfNotExist(studyId: string): Promise { + const studySearchResult = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (studySearchResult === null || studySearchResult === undefined) { + throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + return studySearchResult; + } + + public async findOneProject_throwErrorIfNotExist(projectId: string): Promise { + const projectSearchResult = await this.db.collections.projects_collection.findOne({ id: projectId, deleted: null }); + if (projectSearchResult === null || projectSearchResult === undefined) { + throw new GraphQLError('Project does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + return projectSearchResult; + } + + public async getStudy(requester: IUserWithoutToken | undefined, studyId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* user can get study if he has readonly permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.READ, + requester, + studyId + ); + if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + const study = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (study === null || study === undefined) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + return study; + } + + public async getProject(requester: IUserWithoutToken | undefined, projectId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* get project */ // defer patientMapping since it's costly and not available to all users + const project = await this.db.collections.projects_collection.findOne({ id: projectId, deleted: null }, { projection: { patientMapping: 0 } }); + if (!project) + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + + /* check if user has permission */ + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.READ, + requester, + project.studyId, + projectId + ); + + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.READ, + requester, + project.studyId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + return project; + } + + public async getStudyFields(requester: IUserWithoutToken | undefined, studyId: string, projectId?: string, versionId?: string | null) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* user can get study if he has readonly permission */ + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId + ); + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId, + projectId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + const study = await this.findOneStudy_throwErrorIfNotExist(studyId); + const aggregatedPermissions = this.permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); + + // the processes of requiring versioned data and unversioned data are different + // check the metadata:role:**** for versioned data directly + const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + // check the regular expressions for unversioned data + if (requester.type === userTypes.ADMIN) { + if (versionId === null) { + availableDataVersions.push(null); + } + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray(); + return fieldRecords.filter(el => el.dateDeleted === null); + } + // unversioned data could not be returned by metadata filters + if (versionId === null && aggregatedPermissions.hasVersioned) { + availableDataVersions.push(null); + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { + $match: { + fieldId: { $in: aggregatedPermissions.raw.fieldIds.map((el: string) => new RegExp(el)) } + } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray(); + return fieldRecords.filter(el => el.dateDeleted === null); + } else { + // metadata filter + const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; + aggregatedPermissions.matchObj.forEach((subMetadata) => { + subqueries.push(translateMetadata(subMetadata)); + }); + const metadataFilter = { $or: subqueries }; + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { $match: metadataFilter }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }, { + $set: { metadata: null } + }]).toArray(); + return fieldRecords.filter(el => el.dateDeleted === null); + } + } + + public async getOntologyTree(requester: IUserWithoutToken | undefined, studyId: string, projectId?: string, treeName?: string, versionId?: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* get studyId by parameter or project */ + const study = await this.findOneStudy_throwErrorIfNotExist(studyId); + if (projectId) { + await this.findOneProject_throwErrorIfNotExist(projectId); + } + + // we dont filters fields of an ontology tree by fieldIds + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.ontologyTrees, + atomicOperation.READ, + requester, + studyId, + projectId + ); + + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.ontologyTrees, + atomicOperation.READ, + requester, + studyId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + // if versionId is null, we will only return trees whose data version is null + // this is a different behavior from getting fields or data + if (study.ontologyTrees === undefined) { + return []; + } else { + const trees: IOntologyTree[] = study.ontologyTrees; + if (hasStudyLevelPermission && versionId === null) { + const availableTrees: IOntologyTree[] = []; + for (let i = trees.length - 1; i >= 0; i--) { + if (trees[i].dataVersion === null + && availableTrees.filter(el => el.name === trees[i].name).length === 0) { + availableTrees.push(trees[i]); + } else { + continue; + } + } + if (treeName) { + return availableTrees.filter(el => el.name === treeName); + } else { + return availableTrees; + } + } else { + const availableTrees: IOntologyTree[] = []; + for (let i = trees.length - 1; i >= 0; i--) { + if (availableDataVersions.includes(trees[i].dataVersion || '') + && availableTrees.filter(el => el.name === trees[i].name).length === 0) { + availableTrees.push(trees[i]); + } else { + continue; + } + } + if (treeName) { + return availableTrees.filter(el => el.name === treeName); + } else { + return availableTrees; + } + } + } + } + + public async checkDataComplete(requester: IUserWithoutToken | undefined, studyId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* user can get study if he has readonly permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + // we only check data that hasnt been pushed to a new data version + const data: IDataEntry[] = await this.db.collections.data_collection.find({ + m_studyId: studyId, + m_versionId: null, + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } + }).toArray(); + const fieldMapping = (await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId } + }, { + $match: { fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } + }, { + $sort: { dateAdded: -1 } + }, { + $group: { + _id: '$fieldId', + doc: { $last: '$$ROOT' } + } + } + ]).toArray()).map(el => el['doc']).filter(eh => eh.dateDeleted === null).reduce((acc, curr) => { + acc[curr.fieldId] = curr; + return acc; + }, {}); + const summary: ISubjectDataRecordSummary[] = []; + // we will not check data whose fields are not defined, because data that the associated fields are undefined will not be returned while querying data + for (const record of data) { + let error: string | null = null; + if (fieldMapping[record.m_fieldId] !== undefined && fieldMapping[record.m_fieldId] !== null) { + switch (fieldMapping[record.m_fieldId].dataType) { + case 'dec': {// decimal + if (typeof record.value === 'number') { + if (!/^\d+(.\d+)?$/.test(record.value.toString())) { + error = `Field ${record.m_fieldId}: Cannot parse as decimal.`; + break; + } + } else { + error = `Field ${record.m_fieldId}: Cannot parse as decimal.`; + break; + } + break; + } + case 'int': {// integer + if (typeof record.value === 'number') { + if (!/^-?\d+$/.test(record.value.toString())) { + error = `Field ${record.m_fieldId}: Cannot parse as integer.`; + break; + } + } else { + error = `Field ${record.m_fieldId}: Cannot parse as integer.`; + break; + } + break; + } + case 'bool': {// boolean + if (typeof record.value !== 'boolean') { + error = `Field ${record.m_fieldId}: Cannot parse as boolean.`; + break; + } + break; + } + case 'str': { + break; + } + // 01/02/2021 00:00:00 + case 'date': { + if (typeof record.value === 'string') { + const matcher = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?/; + if (!record.value.match(matcher)) { + error = `Field ${record.m_fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; + break; + } + } else { + error = `Field ${record.m_fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; + break; + } + break; + } + case 'json': { + break; + } + case 'file': { + if (typeof record.value === 'string') { + const file = await this.db.collections.files_collection.findOne({ id: record.value }); + if (!file) { + error = `Field ${record.m_fieldId}: Cannot parse as file or file does not exist.`; + break; + } + } else { + error = `Field ${record.m_fieldId}: Cannot parse as file or file does not exist.`; + break; + } + break; + } + case 'cat': { + if (typeof record.value === 'string') { + if (!fieldMapping[record.m_fieldId].possibleValues.map((el) => el.code).includes(record.value.toString())) { + error = `Field ${record.m_fieldId}: Cannot parse as categorical, value not in value list.`; + break; + } + } else { + error = `Field ${record.m_fieldId}: Cannot parse as categorical, value not in value list.`; + break; + } + break; + } + default: { + error = `Field ${record.m_fieldId}: Invalid data Type.`; + break; + } + } + } + error && summary.push({ + subjectId: record.m_subjectId, + visitId: record.m_visitId, + fieldId: record.m_fieldId, + error: error + }); + } + + return summary; + } + + public async getDataRecords(requester: IUserWithoutToken | undefined, queryString: IQueryString, studyId: string, versionId: string | null | undefined, projectId?: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* user can get study if he has readonly permission */ + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId + ); + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + studyId, + projectId + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + const study = await this.findOneStudy_throwErrorIfNotExist(studyId); + const aggregatedPermissions = this.permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); + + let availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + let fieldRecords: IFieldEntry[] = []; + let result; + let metadataFilter; + // we obtain the data by different requests + // admin used will not filtered by metadata filters + if (requester.type === userTypes.ADMIN) { + if (versionId !== undefined) { + if (versionId === null) { + availableDataVersions.push(null); + } else if (versionId === '-1') { + availableDataVersions = availableDataVersions.length !== 0 ? [availableDataVersions[availableDataVersions.length - 1]] : []; + } else { + availableDataVersions = [versionId]; + } + } + + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray(); + if (queryString.data_requested && queryString.data_requested.length > 0) { + fieldRecords = fieldRecords.filter(el => (queryString.data_requested || []).includes(el.fieldId)); + } + const pipeline = buildPipeline(queryString, studyId, availableDataVersions, fieldRecords, undefined, true, versionId === null); + result = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); + } else { + const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; + aggregatedPermissions.matchObj.forEach((subMetadata) => { + subqueries.push(translateMetadata(subMetadata)); + }); + metadataFilter = { $or: subqueries }; + // unversioned data: metadatafilter for versioned data and all unversioned tags + if (versionId === null && aggregatedPermissions.hasVersioned) { + availableDataVersions.push(null); + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { + $match: { + $or: [ + metadataFilter, + { m_versionId: null } + ] + } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray(); + if (queryString.data_requested && queryString.data_requested?.length > 0) { + fieldRecords = fieldRecords.filter(el => (queryString.data_requested || []).includes(el.fieldId)); + } + } else if (versionId === undefined || versionId === '-1') { + if (versionId === '-1') { + availableDataVersions = availableDataVersions.length !== 0 ? [availableDataVersions[availableDataVersions.length - 1]] : []; + } + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { + $match: { + $or: [ + metadataFilter + ] + } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray(); + if (queryString.data_requested && queryString.data_requested?.length > 0) { + fieldRecords = fieldRecords.filter(el => (queryString.data_requested || []).includes(el.fieldId)); + } + } else if (versionId !== undefined) { + availableDataVersions = [versionId]; + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + }, { + $sort: { dateAdded: -1 } + }, { + $match: { + $or: [ + metadataFilter + ] + } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray(); + if (queryString.data_requested && queryString.data_requested?.length > 0) { + fieldRecords = fieldRecords.filter(el => (queryString.data_requested || []).includes(el.fieldId)); + } + } + + // TODO: placeholder for metadata filter + // if (queryString.metadata) { + // metadataFilter = { $and: queryString.metadata.map((el) => translateMetadata(el)) }; + // } + const pipeline = buildPipeline(queryString, studyId, availableDataVersions, fieldRecords, metadataFilter, false, versionId === null && aggregatedPermissions.hasVersioned); + result = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); + } + // post processing the data + // 2. update to the latest data; start from first record + const groupedResult: IGroupedData = {}; + for (let i = 0; i < result.length; i++) { + const { m_subjectId, m_visitId, m_fieldId, value } = result[i]; + if (!groupedResult[m_subjectId]) { + groupedResult[m_subjectId] = {}; + } + if (!groupedResult[m_subjectId][m_visitId]) { + groupedResult[m_subjectId][m_visitId] = {}; + } + groupedResult[m_subjectId][m_visitId][m_fieldId] = value; + } + + // 2. adjust format: 1) original(exists) 2) standardized - $name 3) grouped + // when standardized data, versionId should not be specified + const standardizations = versionId === null ? null : await this.db.collections.standardizations_collection.find({ studyId: studyId, type: queryString['format'].split('-')[1], delete: null, dataVersion: { $in: availableDataVersions } }).toArray(); + const formattedData = dataStandardization(study, fieldRecords, + groupedResult, queryString, standardizations); + return { data: formattedData }; + } + + public async getStudyProjects(study: IStudy) { + return await this.db.collections.projects_collection.find({ studyId: study.id, deleted: null }).toArray(); + } + + public async getStudyJobs(study: IStudy) { + return await this.db.collections.jobs_collection.find({ studyId: study.id }).toArray(); + } + + public async getStudyRoles(study: IStudy) { + return await this.db.collections.roles_collection.find({ studyId: study.id, projectId: undefined, deleted: null }).toArray(); + } + + public async getStudyFiles(requester: IUserWithoutToken | undefined, study: IStudy) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + study.id + ); + + if (!hasPermission) { + return []; + } + const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const fileFieldIds: string[] = (await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumValueType.FILE } + }, { $match: { fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } }, { + $sort: { dateAdded: -1 } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray()).map(el => el.fieldId); + let adds: string[] = []; + let removes: string[] = []; + // versioned data + if (requester.type === userTypes.ADMIN) { + const fileRecords = await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_fieldId: { $in: fileFieldIds } } + }]).toArray(); + adds = fileRecords.map(el => el.metadata?.add || []).flat(); + removes = fileRecords.map(el => el.metadata?.remove || []).flat(); + } else { + const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; + hasPermission.matchObj.forEach((subMetadata) => { + subqueries.push(translateMetadata(subMetadata)); + }); + const metadataFilter = { $or: subqueries }; + const versionedFileRecors = await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } + }, { + $match: metadataFilter + }]).toArray(); + + const filters: Filter[] = []; + for (const role of hasPermission.roleraw) { + if (!(role.hasVersioned)) { + continue; + } + filters.push({ + m_subjectId: { $in: role.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: role.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: role.fieldIds.map((el: string) => new RegExp(el)) }, + m_versionId: null + }); + } + let unversionedFileRecords: IDataEntry[] = []; + if (filters.length !== 0) { + unversionedFileRecords = await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_versionId: null, m_fieldId: { $in: fileFieldIds } } + }, { + $match: { $or: filters } + }]).toArray(); + } + adds = versionedFileRecors.map(el => el.metadata?.add || []).flat(); + removes = versionedFileRecors.map(el => el.metadata?.remove || []).flat(); + adds = adds.concat(unversionedFileRecords.map(el => el.metadata?.add || []).flat()); + removes = removes.concat(unversionedFileRecords.map(el => el.metadata?.remove || []).flat()); + } + return await this.db.collections.files_collection.find({ studyId: study.id, deleted: null, $or: [{ id: { $in: adds, $nin: removes } }, { description: JSON.stringify({}) }] }).sort({ uploadTime: -1 }).toArray(); + } + + public async getStudySubjects(requester: IUserWithoutToken | undefined, study: IStudy) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + study.id + ); + if (!hasPermission) { + return [[], []]; + } + const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const versionedSubjects = (await this.db.collections.data_collection.distinct('m_subjectId', { + m_studyId: study.id, + m_versionId: availableDataVersions[availableDataVersions.length - 1], + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + value: { $ne: null } + })).sort() || []; + const unVersionedSubjects = hasPermission.hasVersioned ? (await this.db.collections.data_collection.distinct('m_subjectId', { + m_studyId: study.id, + m_versionId: null, + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + value: { $ne: null } + })).sort() || [] : []; + return [versionedSubjects, unVersionedSubjects]; + } + + public async getStudyVisits(requester: IUserWithoutToken | undefined, study: IStudy) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + study.id + ); + if (!hasPermission) { + return [[], []]; + } + const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const versionedVisits = (await this.db.collections.data_collection.distinct('m_visitId', { + m_studyId: study.id, + m_versionId: availableDataVersions[availableDataVersions.length - 1], + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + value: { $ne: null } + })).sort((a, b) => parseFloat(a) - parseFloat(b)); + const unVersionedVisits = hasPermission.hasVersioned ? (await this.db.collections.data_collection.distinct('m_visitId', { + m_studyId: study.id, + m_versionId: null, + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + value: { $ne: null } + })).sort((a, b) => parseFloat(a) - parseFloat(b)) : []; + return [versionedVisits, unVersionedVisits]; + } + + public async getStudyNumOfRecords(requester: IUserWithoutToken | undefined, study: IStudy) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + study.id + ); + if (!hasPermission) { + return [0, 0]; + } + const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const numberOfVersioned: number = (await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_versionId: availableDataVersions[availableDataVersions.length - 1], value: { $ne: null } } + }, { + $match: { + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } + } + }, { + $count: 'count' + }]).toArray())[0]?.['count'] || 0; + const numberOfUnVersioned: number = hasPermission.hasVersioned ? (await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_versionId: null, value: { $ne: null } } + }, { + $match: { + m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } + } + }, { + $count: 'count' + }]).toArray())[0]?.['count'] || 0 : 0; + return [numberOfVersioned, numberOfUnVersioned]; + } + + public async getStudyCurrentDataVersion(study: IStudy) { + return study.currentDataVersion === -1 ? null : study.currentDataVersion; + } + + public async getProjectFields(requester: IUserWithoutToken | undefined, project: Omit) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + project.studyId, + project.id + ); + if (!hasProjectLevelPermission) { return []; } + // get all dataVersions that are valid (before the current version) + const study = await this.findOneStudy_throwErrorIfNotExist(project.studyId); + + // the processes of requiring versioned data and unversioned data are different + // check the metadata:role:**** for versioned data directly + // check the regular expressions for unversioned data + const availableDataVersions: string[] = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const availableTrees: IOntologyTree[] = []; + const trees: IOntologyTree[] = study.ontologyTrees || []; + for (let i = trees.length - 1; i >= 0; i--) { + if (availableDataVersions.includes(trees[i].dataVersion || '') + && availableTrees.filter(el => el.name === trees[i].name).length === 0) { + availableTrees.push(trees[i]); + } else { + continue; + } + } + if (availableTrees.length === 0) { + return []; + } + const ontologyTreeFieldIds: string[] = (availableTrees[0].routes || []).map(el => el.field[0].replace('$', '')); + let fieldRecords: IFieldEntry[] = []; + if (requester.type === userTypes.ADMIN) { + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions }, fieldId: { $in: ontologyTreeFieldIds } } + }, { + $group: { + _id: '$fieldId', + doc: { $last: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }, { + $set: { metadata: null } + }]).toArray(); + } else { + // metadata filter + const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; + hasProjectLevelPermission.matchObj.forEach((subMetadata) => { + subqueries.push(translateMetadata(subMetadata)); + }); + const metadataFilter = { $or: subqueries }; + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions }, fieldId: { $in: ontologyTreeFieldIds } } + }, { $match: metadataFilter }, { + $group: { + _id: '$fieldId', + doc: { $last: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }, { + $set: { metadata: null } + }]).toArray(); + } + return fieldRecords; + } + + public async getProjectJobs(project: Omit) { + return await this.db.collections.jobs_collection.find({ studyId: project.studyId, projectId: project.id }).toArray(); + } + + public async getProjectFiles(requester: IUserWithoutToken | undefined, project: Omit) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + project.studyId, + project.id + ); + if (!hasPermission) { + return []; + } + const study = await this.findOneStudy_throwErrorIfNotExist(project.studyId); + const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const availableTrees: IOntologyTree[] = []; + const trees: IOntologyTree[] = study.ontologyTrees || []; + for (let i = trees.length - 1; i >= 0; i--) { + if (availableDataVersions.includes(trees[i].dataVersion || '') + && availableTrees.filter(el => el.name === trees[i].name).length === 0) { + availableTrees.push(trees[i]); + } else { + continue; + } + } + if (availableTrees.length === 0) { + return []; + } + const ontologyTreeFieldIds: string[] = (availableTrees[0].routes || []).map(el => el.field[0].replace('$', '')); + const fileFieldIds: string[] = (await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumValueType.FILE } + }, { $match: { $and: [{ fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } }, { fieldId: { $in: ontologyTreeFieldIds } }] } }, { + $group: { + _id: '$fieldId', + doc: { $last: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }, { + $sort: { fieldId: 1 } + }]).toArray()).map(el => el.fieldId); + let add: string[] = []; + let remove: string[] = []; + if (Object.keys(hasPermission.matchObj).length === 0) { + (await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } + }]).toArray()).forEach(element => { + add = add.concat(element.metadata?.add || []); + remove = remove.concat(element.metadata?.remove || []); + }); + } else { + const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; + hasPermission.matchObj.forEach((subMetadata) => { + subqueries.push(translateMetadata(subMetadata)); + }); + const metadataFilter = { $or: subqueries }; + (await this.db.collections.data_collection.aggregate([{ + $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } + }, { + $match: metadataFilter + }]).toArray()).forEach(element => { + add = add.concat(element.metadata?.add || []); + remove = remove.concat(element.metadata?.remove || []); + }); + } + return await this.db.collections.files_collection.find({ $and: [{ id: { $in: add } }, { id: { $nin: remove } }] }).toArray(); + } + + public async getProjectDataVersion(project: IProject) { + const study = await this.db.collections.studies_collection.findOne({ id: project.studyId, deleted: null }); + if (study === undefined || study === null) { + return null; + } + if (study.currentDataVersion === -1) { + return null; + } + return study.dataVersions[study?.currentDataVersion]; + } + + public async getProjectSummary(requester: IUserWithoutToken | undefined, project: IProject) { + const summary = {}; + const study = await this.db.collections.studies_collection.findOne({ id: project.studyId }); + if (study === undefined || study === null || study.currentDataVersion === -1) { + return summary; + } + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* user can get study if he has readonly permission */ + const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + project.studyId + ); + const hasProjectLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + requester, + project.studyId, + project.id + ); + if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + // get all dataVersions that are valid (before the current version) + const aggregatedPermissions = this.permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); + + let metadataFilter; + + const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + // ontology trees + const availableTrees: IOntologyTree[] = []; + const trees: IOntologyTree[] = study.ontologyTrees || []; + for (let i = trees.length - 1; i >= 0; i--) { + if (availableDataVersions.includes(trees[i].dataVersion || '') + && availableTrees.filter(el => el.name === trees[i].name).length === 0) { + availableTrees.push(trees[i]); + } else { + continue; + } + } + // const ontologyTreeFieldIds = (availableTrees[0]?.routes || []).map(el => el.field[0].replace('$', '')); + + let fieldRecords; + if (requester.type === userTypes.ADMIN) { + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + }, { + $group: { + _id: '$fieldId', + doc: { $last: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }]).toArray(); + } else { + const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; + aggregatedPermissions.matchObj.forEach((subMetadata) => { + subqueries.push(translateMetadata(subMetadata)); + }); + metadataFilter = { $or: subqueries }; + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + }, { $match: metadataFilter }, { + $group: { + _id: '$fieldId', + doc: { $last: '$$ROOT' } + } + }, { + $replaceRoot: { + newRoot: '$doc' + } + }]).toArray(); + } + // fieldRecords = fieldRecords.filter(el => ontologyTreeFieldIds.includes(el.fieldId)); + const emptyQueryString: IQueryString = { + cohort: [[]], + new_fields: [] + }; + const pipeline = buildPipeline(emptyQueryString, project.studyId, [availableDataVersions[availableDataVersions.length - 1]], fieldRecords as IFieldEntry[], metadataFilter, requester.type === userTypes.ADMIN, false); + const result = await this.db.collections.data_collection.aggregate<{ m_subjectId: string, m_visitId: string, m_fieldId: string, value: unknown }>(pipeline, { allowDiskUse: true }).toArray(); + summary['subjects'] = Array.from(new Set(result.map((el) => el.m_subjectId))).sort(); + summary['visits'] = Array.from(new Set(result.map((el) => el.m_visitId))).sort((a, b) => parseFloat(a) - parseFloat(b)).sort(); + summary['standardizationTypes'] = (await this.db.collections.standardizations_collection.distinct('type', { studyId: study.id, deleted: null })).sort(); + return summary; + } + + public async getProjectPatientMapping(requester: IUserWithoutToken | undefined, project: IProject) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (!(await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, // patientMapping is not visible to project users; only to study users. + requester, + project.studyId + ))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + /* returning */ + const result = + await this.db.collections.projects_collection.findOne( + { id: project.id, deleted: null }, + { projection: { patientMapping: 1 } } + ); + if (result && result.patientMapping) { + return result.patientMapping; + } else { + return null; + } + } + + public async getProjectRoles(project: IProject) { + return await this.db.collections.roles_collection.find({ studyId: project.studyId, projectId: project.id, deleted: null }).toArray(); + } + + public async createNewStudy(requester: IUserWithoutToken | undefined, studyName: string, description: string, type: studyType): Promise { + /* check if study already exist (lowercase because S3 minio buckets cant be mixed case) */ + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + const existingStudies = await this.db.collections.studies_collection.aggregate<{ name: string }>( + [ + { $match: { deleted: null } }, + { + $group: { + _id: '', + name: { + $push: { $toLower: '$name' } + } + } + }, + { $project: { name: 1 } } + ] + ).toArray(); + + if (existingStudies[0] && existingStudies[0].name.includes(studyName.toLowerCase())) { + throw new GraphQLError(`Study "${studyName}" already exists (duplicates are case-insensitive).`); + } + + const study: IStudy = { + id: uuid(), + name: studyName, + createdBy: requester.id, + currentDataVersion: -1, + lastModified: new Date().valueOf(), + dataVersions: [], + deleted: null, + description: description, + type: type, + ontologyTrees: [], + metadata: {} + }; + await this.db.collections.studies_collection.insertOne(study); + return study; + } + + public async validateAndGenerateFieldEntry(fieldEntry: Partial, requester: IUserWithoutToken) { + // duplicates with existing fields are checked by caller function + const error: string[] = []; + const complusoryField = [ + 'fieldId', + 'fieldName', + 'dataType' + ]; + + // check missing field + for (const key of complusoryField) { + if (fieldEntry[key] === undefined && fieldEntry[key] === null) { + error.push(`${key} should not be empty.`); + } + } + // only english letters, numbers and _ are allowed in fieldIds + if (!/^[a-zA-Z0-9_]*$/.test(fieldEntry.fieldId || '')) { + error.push('FieldId should contain letters, numbers and _ only.'); + } + // data types + if (!fieldEntry.dataType || !Object.values(enumValueType).includes(fieldEntry.dataType)) { + error.push(`Data type shouldn't be ${fieldEntry.dataType}: use 'int' for integer, 'dec' for decimal, 'str' for string, 'bool' for boolean, 'date' for datetime, 'file' for FILE, 'json' for json.`); + } + // check possiblevalues to be not-empty if datatype is categorical + if (fieldEntry.dataType === enumValueType.CATEGORICAL) { + if (fieldEntry.possibleValues !== undefined && fieldEntry.possibleValues !== null) { + if (fieldEntry.possibleValues.length === 0) { + error.push(`${fieldEntry.fieldId}-${fieldEntry.fieldName}: possible values can't be empty if data type is categorical.`); + } + for (let i = 0; i < fieldEntry.possibleValues.length; i++) { + fieldEntry.possibleValues[i]['id'] = uuid(); + } + } else { + error.push(`${fieldEntry.fieldId}-${fieldEntry.fieldName}: possible values can't be empty if data type is categorical.`); + } + } + + const newField = { + fieldId: fieldEntry.fieldId, + fieldName: fieldEntry.fieldName, + tableName: fieldEntry.tableName, + dataType: fieldEntry.dataType, + possibleValues: fieldEntry.dataType === enumValueType.CATEGORICAL ? fieldEntry.possibleValues : null, + unit: fieldEntry.unit, + comments: fieldEntry.comments, + metadata: { + 'uploader:org': requester.organisation, + 'uploader:user': requester.id, + ...fieldEntry.metadata + } + }; + + return { fieldEntry: newField, error: error }; + } + + public async createNewField(requester: IUserWithoutToken | undefined, studyId: string, fieldInput: CreateFieldInput[]) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + /* user can get study if he has readonly permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + // check study exists + await this.findOneStudy_throwErrorIfNotExist(studyId); + + const response: IGenericResponse[] = []; + let isError = false; + const bulk = this.db.collections.field_dictionary_collection.initializeUnorderedBulkOp(); + // remove duplicates by fieldId + const keysToCheck = ['fieldId']; + const filteredFieldInput = fieldInput.filter( + (s => o => (k => !s.has(k) && s.add(k))(keysToCheck.map(k => o[k]).join('|')))(new Set()) + ); + // check fieldId duplicate + for (const oneFieldInput of filteredFieldInput) { + isError = false; + // check data valid + if (!(this.permissionCore.checkDataEntryValid(hasPermission.raw, oneFieldInput.fieldId))) { + isError = true; + response.push({ successful: false, code: errorCodes.NO_PERMISSION_ERROR, description: 'You do not have permissions to create this field.' }); + continue; + } + const { fieldEntry, error: thisError } = await this.validateAndGenerateFieldEntry(oneFieldInput, requester); + if (thisError.length !== 0) { + response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: `Field ${oneFieldInput.fieldId || 'fieldId not defined'}-${oneFieldInput.fieldName || 'fieldName not defined'}: ${JSON.stringify(thisError)}` }); + isError = true; + } else { + response.push({ successful: true, description: `Field ${oneFieldInput.fieldId}-${oneFieldInput.fieldName} is created successfully.` }); + } + // // construct the rest of the fields + if (!isError) { + const newFieldEntry: IFieldEntry = { + ...fieldEntry, + fieldId: oneFieldInput.fieldId, + fieldName: oneFieldInput.fieldName, + dataType: oneFieldInput.dataType, + id: uuid(), + studyId: studyId, + dataVersion: null, + dateAdded: Date.now(), + dateDeleted: null, + metadata: { + uploader: requester.id + } + }; + bulk.find({ + fieldId: fieldEntry.fieldId, + studyId: studyId, + dataVersion: null + }).upsert().updateOne({ $set: newFieldEntry }); + } + } + if (bulk.batches.length > 0) { + await bulk.execute(); + } + return response; + } + + public async editField(requester: IUserWithoutToken | undefined, studyId: string, fieldInput: EditFieldInput) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + // check fieldId exist + const searchField = await this.db.collections.field_dictionary_collection.findOne({ studyId: studyId, fieldId: fieldInput.fieldId, dateDeleted: null }); + if (!searchField) { + throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + searchField.fieldId = fieldInput.fieldId; + searchField.fieldName = fieldInput.fieldName; + searchField.dataType = fieldInput.dataType; + if (fieldInput.tableName) { + searchField.tableName = fieldInput.tableName; + } + if (fieldInput.unit) { + searchField.unit = fieldInput.unit; + } + if (fieldInput.possibleValues) { + searchField.possibleValues = fieldInput.possibleValues; + } + if (fieldInput.tableName) { + searchField.tableName = fieldInput.tableName; + } + if (fieldInput.comments) { + searchField.comments = fieldInput.comments; + } + + const { fieldEntry, error } = await this.validateAndGenerateFieldEntry(searchField, requester); + if (error.length !== 0) { + throw new GraphQLError(JSON.stringify(error), { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + const newFieldEntry = { ...fieldEntry, id: searchField.id, dateAdded: searchField.dateAdded, deleted: searchField.dateDeleted, studyId: searchField.studyId }; + await this.db.collections.field_dictionary_collection.findOneAndUpdate({ studyId: studyId, fieldId: newFieldEntry.fieldId }, { $set: newFieldEntry }); + + return newFieldEntry; + } + + public async deleteField(requester: IUserWithoutToken | undefined, studyId: string, fieldId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + if (!(await this.permissionCore.checkDataEntryValid(hasPermission.raw, fieldId))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + // check fieldId exist + const searchField = await this.db.collections.field_dictionary_collection.find({ studyId: studyId, fieldId: fieldId, dateDeleted: null }).limit(1).sort({ dateAdded: -1 }).toArray(); + if (searchField.length === 0 || searchField[0].dateDeleted !== null) { + throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + + const fieldEntry = { + id: uuid(), + studyId: studyId, + fieldId: searchField[0].fieldId, + fieldName: searchField[0].fieldName, + tableName: searchField[0].tableName, + dataType: searchField[0].dataType, + possibleValues: searchField[0].possibleValues, + unit: searchField[0].unit, + comments: searchField[0].comments, + dataVersion: null, + dateAdded: (new Date()).valueOf(), + dateDeleted: (new Date()).valueOf() + }; + await this.db.collections.field_dictionary_collection.insertOne(fieldEntry); + return searchField[0]; + } + + public async editStudy(requester: IUserWithoutToken | undefined, studyId: string, description: string): Promise { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + const res = await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId }, { $set: { description: description } }, { returnDocument: 'after' }); + if (res) { + return res; + } else { + throw new GraphQLError('Edit study failed'); + } + } + + public async uploadDataInArray(requester: IUserWithoutToken | undefined, studyId: string, data: IDataClip[]) { + // check study exists + const study = await this.findOneStudy_throwErrorIfNotExist(studyId); + + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + /* user can get study if he has readonly permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + // find the fieldsList, including those that have not been versioned, same method as getStudyFields + // get all dataVersions that are valid (before/equal the current version) + const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $sort: { dateAdded: -1 } + }, { + $match: { $or: [{ dataVersion: null }, { dataVersion: { $in: availableDataVersions } }] } + }, { + $match: { studyId: studyId } + }, { + $group: { + _id: '$fieldId', + doc: { $first: '$$ROOT' } + } + } + ]).toArray(); + // filter those that have been deleted + const fieldsList = fieldRecords.map(el => el['doc']).filter(eh => eh.dateDeleted === null); + const response = (await this.uploadOneDataClip(studyId, hasPermission.raw, fieldsList, data, requester)); + + return response; + } + + public async deleteDataRecords(requester: IUserWithoutToken | undefined, studyId: string, subjectIds: string[], visitIds: string[], fieldIds: string[]) { + // check study exists + await this.findOneStudy_throwErrorIfNotExist(studyId); + const response: IGenericResponse[] = []; + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + let validSubjects: string[]; + let validVisits: string[]; + let validFields; + // filter + if (subjectIds === undefined || subjectIds === null || subjectIds.length === 0) { + validSubjects = (await this.db.collections.data_collection.distinct('m_subjectId', { m_studyId: studyId })); + } else { + validSubjects = subjectIds; + } + if (visitIds === undefined || visitIds === null || visitIds.length === 0) { + validVisits = (await this.db.collections.data_collection.distinct('m_visitId', { m_studyId: studyId })); + } else { + validVisits = visitIds; + } + if (fieldIds === undefined || fieldIds === null || fieldIds.length === 0) { + validFields = (await this.db.collections.field_dictionary_collection.distinct('fieldId', { studyId: studyId })); + } else { + validFields = fieldIds; + } + + const bulk = this.db.collections.data_collection.initializeUnorderedBulkOp(); + for (const subjectId of validSubjects) { + for (const visitId of validVisits) { + for (const fieldId of validFields) { + if (!(await this.permissionCore.checkDataEntryValid(hasPermission.raw, fieldId, subjectId, visitId))) { + continue; + } + bulk.find({ m_studyId: studyId, m_subjectId: subjectId, m_visitId: visitId, m_fieldId: fieldId, m_versionId: null }).upsert().updateOne({ + $set: { + m_studyId: studyId, + m_subjectId: subjectId, + m_visitId: visitId, + m_versionId: null, + m_fieldId: fieldId, + value: null, + uploadedAt: (new Date()).valueOf(), + id: uuid() + } + }); + response.push({ successful: true, description: `SubjectId-${subjectId}:visitId-${visitId}:fieldId-${fieldId} is deleted.` }); + } + } + } + if (bulk.batches.length > 0) { + await bulk.execute(); + } + return response; + } + + public async createNewDataVersion(requester: IUserWithoutToken | undefined, studyId: string, dataVersion: string, tag: string) { + // check study exists + await this.findOneStudy_throwErrorIfNotExist(studyId); + + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + // check dataVersion name valid + if (!/^\d{1,3}(\.\d{1,2}){0,2}$/.test(dataVersion)) { + throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); + } + const newDataVersionId = uuid(); + const newContentId = uuid(); + + // update data + const resData = await this.db.collections.data_collection.updateMany({ + m_studyId: studyId, + m_versionId: null + }, { + $set: { + m_versionId: newDataVersionId + } + }); + // update field + const resField = await this.db.collections.field_dictionary_collection.updateMany({ + studyId: studyId, + dataVersion: null + }, { + $set: { + dataVersion: newDataVersionId + } + }); + // update standardization + const resStandardization = await this.db.collections.standardizations_collection.updateMany({ + studyId: studyId, + dataVersion: null + }, { + $set: { + dataVersion: newDataVersionId + } + }); + + // update ontology trees + const resOntologyTrees = await this.db.collections.studies_collection.updateOne({ 'id': studyId, 'deleted': null, 'ontologyTrees.dataVersion': null }, { + $set: { + 'ontologyTrees.$.dataVersion': newDataVersionId + } + }); + + if (resData.modifiedCount === 0 && resField.modifiedCount === 0 && resStandardization.modifiedCount === 0 && resOntologyTrees.modifiedCount === 0) { + return null; + } + + // insert a new version into study + const newDataVersion: IStudyDataVersion = { + id: newDataVersionId, + contentId: newContentId, // same content = same id - used in reverting data, version control + version: dataVersion, + tag: tag, + updateDate: (new Date().valueOf()).toString() + }; + await this.db.collections.studies_collection.updateOne({ id: studyId }, { + $push: { dataVersions: newDataVersion }, + $inc: { + currentDataVersion: 1 + } + }); + + // update permissions based on roles + const roles = await this.db.collections.roles_collection.find({ studyId: studyId, deleted: null }).toArray(); + for (const role of roles) { + const filters: ICombinedPermissions = { + subjectIds: role.permissions.data?.subjectIds || [], + visitIds: role.permissions.data?.visitIds || [], + fieldIds: role.permissions.data?.fieldIds || [] + }; + // deal with data filters + let validSubjects: Array | null = null; + if (role.permissions.data?.filters) { + if (role.permissions.data.filters.length > 0) { + validSubjects = []; + const subqueries = translateCohort(role.permissions.data.filters); + validSubjects = (await this.db.collections.data_collection.aggregate<{ + m_subjectId: string, m_visitId: string, m_fieldId: string, value: string | number | boolean | { [key: string]: unknown } + }>([{ + $match: { m_fieldId: { $in: role.permissions.data.filters.map(el => el.field) } } + }, + { + $sort: { uploadedAt: -1 } + }, { + $group: { + _id: { m_subjectId: '$m_subjectId', m_visitId: '$m_visitId', m_fieldId: '$m_fieldId' }, + doc: { $first: '$$ROOT' } + } + }, { + $project: { + m_subjectId: '$doc.m_subjectId', + m_visitId: '$doc.m_visitId', + m_fieldId: '$doc.m_fieldId', + value: '$doc.value', + _id: 0 + } + }, { + $match: { $and: subqueries } + }], { allowDiskUse: true }).toArray()).map(el => el.m_subjectId); + } + } + if (validSubjects === null) { + validSubjects = [/^.*$/]; + } + const tag = `metadata.${'role:'.concat(role.id)}`; + await this.db.collections.data_collection.updateMany({ + m_studyId: studyId, + m_versionId: newDataVersionId, + $and: [ + { m_subjectId: { $in: filters.subjectIds.map((el: string) => new RegExp(el)) } }, + { m_subjectId: { $in: validSubjects } } + ], + m_visitId: { $in: filters.visitIds.map((el: string) => new RegExp(el)) }, + m_fieldId: { $in: filters.fieldIds.map((el: string) => new RegExp(el)) } + }, { + $set: { [tag]: true } + }); + await this.db.collections.data_collection.updateMany({ + m_studyId: studyId, + m_versionId: newDataVersionId, + $or: [ + { m_subjectId: { $nin: filters.subjectIds.map((el: string) => new RegExp(el)) } }, + { m_subjectId: { $nin: validSubjects } }, + { m_visitId: { $nin: filters.visitIds.map((el: string) => new RegExp(el)) } }, + { m_fieldId: { $nin: filters.fieldIds.map((el: string) => new RegExp(el)) } } + ] + }, { + $set: { [tag]: false } + }); + await this.db.collections.field_dictionary_collection.updateMany({ + studyId: studyId, + dataVersion: newDataVersionId, + fieldId: { $in: filters.fieldIds.map((el: string) => new RegExp(el)) } + }, { + $set: { [tag]: true } + }); + await this.db.collections.field_dictionary_collection.updateMany({ + studyId: studyId, + dataVersion: newDataVersionId, + fieldId: { $nin: filters.fieldIds.map((el: string) => new RegExp(el)) } + }, { + $set: { [tag]: false } + }); + } + if (newDataVersion === null) { + throw new GraphQLError('No matched or modified records', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + return newDataVersion; + } + + public async uploadOneDataClip(studyId: string, permissions, fieldList: Partial[], data: IDataClip[], requester: IUserWithoutToken): Promise { + const response: IGenericResponse[] = []; + let bulk = this.db.collections.data_collection.initializeUnorderedBulkOp(); + // remove duplicates by subjectId, visitId and fieldId + const keysToCheck: Array = ['visitId', 'subjectId', 'fieldId']; + const filteredData = data.filter( + (s => o => (k => !s.has(k) && s.add(k))(keysToCheck.map(k => o[k]).join('|')))(new Set()) + ); + for (const dataClip of filteredData) { + // remove the '-' if there exists + dataClip.subjectId = dataClip.subjectId.replace('-', ''); + const fieldInDb = fieldList.filter(el => el.fieldId === dataClip.fieldId)[0]; + if (!fieldInDb) { + response.push({ successful: false, code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, description: `Field ${dataClip.fieldId}: Field Not found` }); + continue; + } + // check subjectId + if (!validate(dataClip.subjectId.substr(1) ?? '')) { + response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: `Subject ID ${dataClip.subjectId} is illegal.` }); + continue; + } + if (!(await this.permissionCore.checkDataEntryValid(permissions, dataClip.fieldId, dataClip.subjectId, dataClip.visitId))) { + response.push({ successful: false, code: errorCodes.NO_PERMISSION_ERROR, description: 'You do not have access to this field.' }); + continue; + } + // check value is valid + let error; + let parsedValue; + if (dataClip.value?.toString() === '99999') { // agreement with other WPs, 99999 refers to missing + parsedValue = '99999'; + } else { + switch (fieldInDb.dataType) { + case 'dec': {// decimal + if (typeof (dataClip.value) !== 'string') { + error = `Field ${dataClip.fieldId}: Cannot parse as decimal.`; + break; + } + if (!/^\d+(.\d+)?$/.test(dataClip.value)) { + error = `Field ${dataClip.fieldId}: Cannot parse as decimal.`; + break; + } + parsedValue = parseFloat(dataClip.value); + break; + } + case 'int': {// integer + if (typeof (dataClip.value) !== 'string') { + error = `Field ${dataClip.fieldId}: Cannot parse as integer.`; + break; + } + if (!/^-?\d+$/.test(dataClip.value)) { + error = `Field ${dataClip.fieldId}: Cannot parse as integer.`; + break; + } + parsedValue = parseInt(dataClip.value, 10); + break; + } + case 'bool': {// boolean + if (typeof (dataClip.value) !== 'string') { + error = `Field ${dataClip.fieldId}: Cannot parse as boolean.`; + break; + } + if (dataClip.value.toLowerCase() === 'true' || dataClip.value.toLowerCase() === 'false') { + parsedValue = dataClip.value.toLowerCase() === 'true'; + } else { + error = `Field ${dataClip.fieldId}: Cannot parse as boolean.`; + break; + } + break; + } + case 'str': { + if (typeof (dataClip.value) !== 'string') { + error = `Field ${dataClip.fieldId}: Cannot parse as string.`; + break; + } + parsedValue = dataClip.value.toString(); + break; + } + // 01/02/2021 00:00:00 + case 'date': { + if (typeof (dataClip.value) !== 'string') { + error = `Field ${dataClip.fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; + break; + } + const matcher = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?/; + if (!dataClip.value.match(matcher)) { + error = `Field ${dataClip.fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; + break; + } + parsedValue = dataClip.value.toString(); + break; + } + case 'json': { + parsedValue = dataClip.value; + break; + } + case 'file': { + if (!dataClip.file || typeof (dataClip.file) === 'string') { + error = `Field ${dataClip.fieldId}: Cannot parse as file.`; + break; + } + // if old file exists, delete it first + const res = await this.uploadFile(studyId, dataClip, requester, {}); + if ('code' in res && 'description' in res) { + error = `Field ${dataClip.fieldId}: Cannot parse as file.`; + break; + } else { + parsedValue = res.id; + } + break; + } + case 'cat': { + if (!fieldInDb.possibleValues) { + error = `Field ${dataClip.fieldId}: Cannot parse as categorical, possible values not defined.`; + break; + } + if (dataClip.value && !fieldInDb.possibleValues.map((el) => el.code).includes(dataClip.value?.toString())) { + error = `Field ${dataClip.fieldId}: Cannot parse as categorical, value not in value list.`; + break; + } else { + parsedValue = dataClip.value?.toString(); + } + break; + } + default: { + error = (`Field ${dataClip.fieldId}: Invalid data Type.`); + break; + } + } + } + if (error !== undefined) { + response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: error }); + continue; + } else { + response.push({ successful: true, description: `${dataClip.subjectId}-${dataClip.visitId}-${dataClip.fieldId}` }); + } + const obj = { + m_studyId: studyId, + m_versionId: null, + m_subjectId: dataClip.subjectId, + m_visitId: dataClip.visitId, + m_fieldId: dataClip.fieldId + }; + let objWithData: Partial>; + // update the file data differently + if (fieldInDb.dataType === 'file') { + const existing = await this.db.collections.data_collection.findOne(obj); + if (!existing) { + await this.db.collections.data_collection.insertOne({ + ...obj, + id: uuid(), + uploadedAt: (new Date()).valueOf(), + value: '', + metadata: { + add: [], + remove: [] + } + }); + } + + objWithData = { + ...obj, + id: uuid(), + value: '', + uploadedAt: (new Date()).valueOf(), + metadata: { + ...dataClip.metadata, + participantId: dataClip.subjectId, + add: (existing?.metadata?.add || []).concat(parsedValue), + uploader: requester.id + }, + uploadedBy: requester.id + }; + bulk.find(obj).updateOne({ $set: objWithData }); + } else { + objWithData = { + ...obj, + id: uuid(), + value: parsedValue, + uploadedAt: (new Date()).valueOf(), + metadata: { + ...dataClip.metadata, + uploader: requester.id + }, + uploadedBy: requester.id + }; + bulk.insert(objWithData); + } + if (bulk.batches.length > 999) { + await bulk.execute(); + bulk = this.db.collections.data_collection.initializeUnorderedBulkOp(); + } + } + bulk.batches.length !== 0 && await bulk.execute(); + return response; + } + + // This file uploading function will not check any metadate of the file + public async uploadFile(studyId: string, data: IDataClip, uploader: IUserWithoutToken, args: { fileLength?: number, fileHash?: string }): Promise { + if (!data.file || typeof (data.file) === 'string') { + return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'Invalid File Stream' }; + } + const study = await this.db.collections.studies_collection.findOne({ id: studyId }); + if (!study) { + return { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, description: 'Study does not exist.' }; + } + const sitesIDMarkers = (await this.db.collections.organisations_collection.find({ deleted: null }).toArray()).reduce<{ [key: string]: string | null }>((acc, curr) => { + if (curr.metadata?.siteIDMarker) { + acc[curr.metadata.siteIDMarker] = curr.shortname; + } + return acc; + }, {}); + // check file metadata + if (data.metadata) { + let parsedDescription: Record; + let startDate: number; + let endDate: number; + let deviceId: string; + let participantId: string; + try { + parsedDescription = data.metadata; + if (!parsedDescription['startDate'] || !parsedDescription['endDate'] || !parsedDescription['deviceId'] || !parsedDescription['participantId']) { + return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; + } + startDate = parseInt(parsedDescription['startDate'].toString()); + endDate = parseInt(parsedDescription['endDate'].toString()); + participantId = parsedDescription['participantId'].toString(); + deviceId = parsedDescription['deviceId'].toString(); + } catch (e) { + return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; + } + if ( + !Object.keys(sitesIDMarkers).includes(participantId.substr(0, 1)?.toUpperCase()) || + !Object.keys(deviceTypes).includes(deviceId.substr(0, 3)?.toUpperCase()) || + !validate(participantId.substr(1) ?? '') || + !validate(deviceId.substr(3) ?? '') || + !startDate || !endDate || + (new Date(endDate).setHours(0, 0, 0, 0).valueOf()) > (new Date().setHours(0, 0, 0, 0).valueOf()) + ) { + return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; + } + } else { + return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; + } + + + const file: FileUpload = await data.file; + + // check if old files exist; if so, denote it as deleted + const dataEntry = await this.db.collections.data_collection.findOne({ m_studyId: studyId, m_visitId: data.visitId, m_subjectId: data.subjectId, m_versionId: null, m_fieldId: data.fieldId }); + const oldFileId = dataEntry ? dataEntry.value : null; + return new Promise((resolve, reject) => { + (async () => { + try { + const fileEntry: Partial = { + id: uuid(), + fileName: file.filename, + studyId: studyId, + description: JSON.stringify({}), + uploadTime: `${Date.now()}`, + uploadedBy: uploader.id, + deleted: null, + metadata: (data.metadata as Record) + }; + + if (args.fileLength !== undefined && args.fileLength > fileSizeLimit) { + reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + const stream = file.createReadStream(); + const fileUri = uuid(); + const hash = crypto.createHash('sha256'); + let readBytes = 0; + + stream.pause(); + + /* if the client cancelled the request mid-stream it will throw an error */ + stream.on('error', (e) => { + reject(new GraphQLError('Upload resolver file stream failure', { extensions: { code: errorCodes.FILE_STREAM_ERROR, error: e } })); + return; + }); + + stream.on('data', (chunk) => { + readBytes += chunk.length; + if (readBytes > fileSizeLimit) { + stream.destroy(); + reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + hash.update(chunk); + }); + + + await this.objStore.uploadFile(stream, studyId, fileUri); + + // hash is optional, but should be correct if provided + const hashString = hash.digest('hex'); + if (args.fileHash && args.fileHash !== hashString) { + reject(new GraphQLError('File hash not match', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + // check if readbytes equal to filelength in parameters + if (args.fileLength !== undefined && args.fileLength.toString() !== readBytes.toString()) { + reject(new GraphQLError('File size mismatch', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); + return; + } + + fileEntry.fileSize = readBytes.toString(); + fileEntry.uri = fileUri; + fileEntry.hash = hashString; + const insertResult = await this.db.collections.files_collection.insertOne(fileEntry as IFile); + if (insertResult.acknowledged) { + // delete old file if existing + oldFileId && await this.db.collections.files_collection.findOneAndUpdate({ studyId: studyId, id: oldFileId }, { $set: { deleted: Date.now().valueOf() } }); + resolve(fileEntry as IFile); + } else { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + } + catch (error) { + reject({ code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'Missing file metadata.', error }); + return; + } + })().catch(() => { return; }); + }); + } + + public async createOntologyTree(requester: IUserWithoutToken | undefined, studyId: string, ontologyTree: Pick) { + /* check study exists */ + const study = await this.findOneStudy_throwErrorIfNotExist(studyId); + + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* user can get study if he has readonly permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.ontologyTrees, + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + // in case of old documents whose ontologyTrees are invalid + if (study.ontologyTrees === undefined || study.ontologyTrees === null) { + await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { + $set: { + ontologyTrees: [] + } + }); + } + const ontologyTreeWithId: Partial = { ...ontologyTree }; + ontologyTreeWithId.id = uuid(); + ontologyTreeWithId.routes = ontologyTreeWithId.routes || []; + ontologyTreeWithId.routes.forEach(el => { + el.id = uuid(); + el.visitRange = el.visitRange || []; + }); + await this.db.collections.studies_collection.findOneAndUpdate({ + id: studyId, deleted: null, ontologyTrees: { + $not: { + $elemMatch: { + name: ontologyTree.name, + dataVersion: null + } + } + } + }, { + $addToSet: { + ontologyTrees: ontologyTreeWithId + } + }); + await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null, ontologyTrees: { $elemMatch: { name: ontologyTreeWithId.name, dataVersion: null } } }, { + $set: { + 'ontologyTrees.$.routes': ontologyTreeWithId.routes, + 'ontologyTrees.$.dataVersion': null, + 'ontologyTrees.$.deleted': null + } + }); + return ontologyTreeWithId as IOntologyTree; + } + + public async deleteOntologyTree(requester: IUserWithoutToken | undefined, studyId: string, treeName: string) { + /* check study exists */ + await this.findOneStudy_throwErrorIfNotExist(studyId); + + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* user can get study if he has readonly permission */ + const hasPermission = await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.ontologyTrees, + atomicOperation.WRITE, + requester, + studyId + ); + if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + + const resultAdd = await this.db.collections.studies_collection.findOneAndUpdate({ + id: studyId, deleted: null, ontologyTrees: { + $not: { + $elemMatch: { + name: treeName, + dataVersion: null + } + } + } + }, { + $addToSet: { + ontologyTrees: { + id: uuid(), + name: treeName, + dataVersion: null, + deleted: Date.now().valueOf() + } + } + }); + const resultUpdate = await this.db.collections.studies_collection.findOneAndUpdate({ + id: studyId, deleted: null, ontologyTrees: { $elemMatch: { name: treeName, dataVersion: null } } + }, { + $set: { + 'ontologyTrees.$.deleted': Date.now().valueOf(), + 'ontologyTrees.$.routes': undefined + } + }); + if (resultAdd || resultUpdate) { + return makeGenericReponse(treeName); + } else { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + } + + public async createProjectForStudy(requester: IUserWithoutToken | undefined, studyId: string, projectName: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (!(await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.WRITE, + requester, + studyId + ))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + /* making sure that the study exists first */ + await this.findOneStudy_throwErrorIfNotExist(studyId); + + /* create project */ + const project: IProject = { + id: uuid(), + studyId, + createdBy: requester.id, + name: projectName, + patientMapping: {}, + lastModified: new Date().valueOf(), + deleted: null, + metadata: {} + }; + + const getListOfPatientsResult = await this.db.collections.data_collection.aggregate([ + { $match: { m_studyId: studyId } }, + { $group: { _id: null, array: { $addToSet: '$m_subjectId' } } }, + { $project: { array: 1 } } + ]).toArray(); + + if (getListOfPatientsResult === null || getListOfPatientsResult === undefined) { + throw new GraphQLError('Cannot get list of patients', { extensions: { code: errorCodes.DATABASE_ERROR } }); + } + + if (getListOfPatientsResult[0] !== undefined) { + project.patientMapping = this.createPatientIdMapping(getListOfPatientsResult[0]['array']); + } + + await this.db.collections.projects_collection.insertOne(project); + return project; + } + + public async deleteStudy(requester: IUserWithoutToken | undefined, studyId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + const study = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + if (!study) { + throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + /* PRECONDITION: CHECKED THAT STUDY INDEED EXISTS */ + const session = this.db.client.startSession(); + session.startTransaction(); + + const timestamp = new Date().valueOf(); + + try { + /* delete the study */ + await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }); + + /* delete all projects related to the study */ + await this.db.collections.projects_collection.updateMany({ studyId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }); + + /* delete all roles related to the study */ + await this.permissionCore.removeRoleFromStudyOrProject({ studyId }); + + /* delete all files belong to the study*/ + await this.db.collections.files_collection.updateMany({ studyId, deleted: null }, { $set: { deleted: timestamp } }); + + await session.commitTransaction(); + session.endSession().catch(() => { return; }); + + } catch (error) { + // If an error occurred, abort the whole transaction and + // undo any changes that might have happened + await session.abortTransaction(); + session.endSession().catch(() => { return; }); + throw error; // Rethrow so calling function sees error + } + return makeGenericReponse(studyId); + } + + public async deleteProject(requester: IUserWithoutToken | undefined, projectId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const project = await this.findOneProject_throwErrorIfNotExist(projectId); + + /* check privileges */ + if (!(await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.WRITE, + requester, + project.studyId + ))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + /* delete project */ + const timestamp = new Date().valueOf(); + + /* delete all projects related to the study */ + await this.db.collections.projects_collection.findOneAndUpdate({ id: projectId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }, { returnDocument: 'after' }); + + /* delete all roles related to the study */ + await this.permissionCore.removeRoleFromStudyOrProject({ projectId }); + return makeGenericReponse(projectId); + } + + public async setDataversionAsCurrent(requester: IUserWithoutToken | undefined, studyId: string, dataVersionId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* check privileges */ + if (!(await this.permissionCore.userHasTheNeccessaryManagementPermission( + IPermissionManagementOptions.own, + atomicOperation.WRITE, + requester, + studyId + ))) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + const study = await this.findOneStudy_throwErrorIfNotExist(studyId); + + /* check whether the dataversion exists */ + const selectedataVersionFiltered = study.dataVersions.filter((el) => el.id === dataVersionId); + if (selectedataVersionFiltered.length !== 1) { + throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); + } + + /* update the currentversion field in database */ + const versionIdsList = study.dataVersions.map((el) => el.id); + const result = await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { + $set: { currentDataVersion: versionIdsList.indexOf(dataVersionId) } + }, { + returnDocument: 'after' + }); + + if (result) { + return result; + } else { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + } + + private createPatientIdMapping(listOfPatientId: string[], prefix?: string): { [originalPatientId: string]: string } { + let rangeArray: Array = [...Array.from(listOfPatientId.keys())]; + if (prefix === undefined) { + prefix = uuid().substring(0, 10); + } + rangeArray = rangeArray.map((e) => `${prefix}${e} `); + rangeArray = this.shuffle(rangeArray); + const mapping: { [originalPatientId: string]: string } = {}; + for (let i = 0, length = listOfPatientId.length; i < length; i++) { + mapping[listOfPatientId[i]] = (rangeArray as string[])[i]; + } + return mapping; + } + + private shuffle(array: Array) { // source: Fisher–Yates Shuffle; https://bost.ocks.org/mike/shuffle/ + let currentIndex = array.length; + let temporaryValue: string | number; + let randomIndex: number; + + // While there remain elements to shuffle... + while (0 !== currentIndex) { + + // Pick a remaining element... + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex -= 1; + + // And swap it with the current element. + temporaryValue = array[currentIndex]; + array[currentIndex] = array[randomIndex]; + array[randomIndex] = temporaryValue; + } + + return array; + } +} diff --git a/packages/itmat-cores/src/core/userCore.ts b/packages/itmat-cores/src/core/userCore.ts new file mode 100644 index 000000000..41326775d --- /dev/null +++ b/packages/itmat-cores/src/core/userCore.ts @@ -0,0 +1,876 @@ +import bcrypt from 'bcrypt'; +import { GraphQLError } from 'graphql'; +import { IUser, IUserWithoutToken, userTypes, IPubkey, IProject, IStudy, IResetPasswordRequest } from '@itmat-broker/itmat-types'; +import { v4 as uuid } from 'uuid'; +import { errorCodes } from '../utils/errors'; +import { MarkOptional } from 'ts-essentials'; +import { IGenericResponse, makeGenericReponse } from '../utils/responses'; +import crypto from 'crypto'; +import * as mfa from '../utils/mfa'; +import { ApolloServerErrorCode } from '@apollo/server/errors'; +import { Logger, Mailer } from '@itmat-broker/itmat-commons'; +import tmp from 'tmp'; +import QRCode from 'qrcode'; +import { UpdateFilter } from 'mongodb'; +import { DBType } from '../database/database'; +import { IConfiguration } from '../utils'; + + +export interface CreateUserInput { + username: string, + firstname: string, + lastname: string, + email: string, + emailNotificationsActivated?: boolean, + password: string, + description?: string, + organisation: string, + metadata: Record & { logPermission: boolean } +} + +export interface EditUserInput { + id: string, + username?: string, + type?: userTypes, + firstname?: string, + lastname?: string, + email?: string, + emailNotificationsActivated?: boolean, + emailNotificationsStatus?: unknown, + password?: string, + description?: string, + organisation?: string, + expiredAt?: number, + metadata?: unknown +} + +export class UserCore { + db: DBType; + mailer: Mailer; + config: IConfiguration; + emailConfig: IEmailConfig; + constructor(db: DBType, mailer: Mailer, config: IConfiguration) { + this.db = db; + this.mailer = mailer; + this.config = config; + this.emailConfig = { + appName: config.appName, + nodemailer: { + auth: { + user: config.nodemailer.auth.user + } + }, + adminEmail: config.adminEmail + }; + + } + + public async getOneUser_throwErrorIfNotExists(username: string): Promise { + const user = await this.db.collections.users_collection.findOne({ deleted: null, username }); + if (user === undefined || user === null) { + throw new GraphQLError('User does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); + } + return user; + } + + public async getUsers(userId?: string) { + // everyone is allowed to see all the users in the app. But only admin can access certain fields, like emails, etc - see resolvers for User type. + const queryObj = userId === undefined ? { deleted: null } : { deleted: null, id: userId }; + return await this.db.collections.users_collection.find(queryObj, { projection: { _id: 0 } }).toArray(); + } + + public async validateResetPassword(token: string, encryptedEmail: string) { + /* decrypt email */ + const salt = makeAESKeySalt(token); + const iv = makeAESIv(token); + let email; + try { + email = await decryptEmail(this.config.aesSecret, encryptedEmail, salt, iv); + } catch (e) { + throw new GraphQLError('Token is not valid.'); + } + + /* check whether username and token is valid */ + /* not changing password too in one step (using findOneAndUpdate) because bcrypt is costly */ + const TIME_NOW = new Date().valueOf(); + const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; + const user: IUserWithoutToken | null = await this.db.collections.users_collection.findOne({ + email, + resetPasswordRequests: { + $elemMatch: { + id: token, + timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, + used: false + } + }, + deleted: null + }); + if (!user) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + return makeGenericReponse(); + } + + public async recoverSessionExpireTime() { + return makeGenericReponse(); + } + + public async getUserAccess(requester: IUserWithoutToken | undefined, user: IUserWithoutToken) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* only admin can access this field */ + if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + /* if requested user is admin, then he has access to all studies */ + if (user.type === userTypes.ADMIN) { + const allprojects: IProject[] = await this.db.collections.projects_collection.find({ deleted: null }).toArray(); + const allstudies: IStudy[] = await this.db.collections.studies_collection.find({ deleted: null }).toArray(); + return { id: `user_access_obj_user_id_${user.id}`, projects: allprojects, studies: allstudies }; + } + + /* if requested user is not admin, find all the roles a user has */ + const roles = await this.db.collections.roles_collection.find({ users: user.id, deleted: null }).toArray(); + const init: { projects: string[], studies: string[] } = { projects: [], studies: [] }; + const studiesAndProjectThatUserCanSee: { projects: string[], studies: string[] } = roles.reduce( + (a, e) => { + if (e.projectId) { + a.projects.push(e.projectId); + } else { + a.studies.push(e.studyId); + } + return a; + }, init + ); + + const projects = await this.db.collections.projects_collection.find({ + $or: [ + { id: { $in: studiesAndProjectThatUserCanSee.projects }, deleted: null }, + { studyId: { $in: studiesAndProjectThatUserCanSee.studies }, deleted: null } + ] + }).toArray(); + const studies = await this.db.collections.studies_collection.find({ id: { $in: studiesAndProjectThatUserCanSee.studies }, deleted: null }).toArray(); + return { id: `user_access_obj_user_id_${user.id}`, projects, studies }; + } + + public async getUserUsername(requester: IUserWithoutToken | undefined, user: IUserWithoutToken) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* only admin can access this field */ + if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + return user.username; + } + + public getUserDescription(requester: IUserWithoutToken | undefined, user: IUserWithoutToken) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* only admin can access this field */ + if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + return user.description; + } + + public async getUserEmail(requester: IUserWithoutToken | undefined, user: IUserWithoutToken) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + /* only admin can access this field */ + if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + return user.email; + } + + public async requestExpiryDate(username?: string, email?: string) { + /* double-check user existence */ + const queryObj = email ? { deleted: null, email } : { deleted: null, username }; + const user: IUser | null = await this.db.collections.users_collection.findOne(queryObj); + if (!user) { + /* even user is null. send successful response: they should know that a user doesn't exist */ + await new Promise(resolve => setTimeout(resolve, Math.random() * 6000)); + return makeGenericReponse(); + } + /* send email to the DMP admin mailing-list */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ + config: this.emailConfig, + userEmail: user.email, + username: user.username + })); + + /* send email to client */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoClient({ + config: this.emailConfig, + to: user.email, + username: user.username + })); + + return makeGenericReponse(); + } + + public async requestUsernameOrResetPassword(forgotUsername: boolean, forgotPassword: boolean, origin: unknown, email?: string, username?: string) { + /* checking the args are right */ + if ((forgotUsername && !email) // should provide email if no username + || (forgotUsername && username) // should not provide username if it's forgotten.. + || (!email && !username)) { + throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); + } else if (email && username) { + // TO_DO : better client erro + /* only provide email if no username */ + throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); + } + + /* check user existence */ + const queryObj = email ? { deleted: null, email } : { deleted: null, username }; + const user = await this.db.collections.users_collection.findOne(queryObj); + if (!user) { + /* even user is null. send successful response: they should know that a user doesn't exist */ + await new Promise(resolve => setTimeout(resolve, Math.random() * 6000)); + return makeGenericReponse(); + } + + if (forgotPassword) { + /* make link to change password */ + const passwordResetToken = uuid(); + const resetPasswordRequest: IResetPasswordRequest = { + id: passwordResetToken, + timeOfRequest: new Date().valueOf(), + used: false + }; + const invalidateAllTokens = await this.db.collections.users_collection.findOneAndUpdate( + queryObj, + { + $set: { + 'resetPasswordRequests.$[].used': true + } + } + ); + if (invalidateAllTokens === null) { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + const updateResult = await this.db.collections.users_collection.findOneAndUpdate( + queryObj, + { + $push: { + resetPasswordRequests: resetPasswordRequest + } + } + ); + if (updateResult === null) { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + + /* send email to client */ + await this.mailer.sendMail(await formatEmailForForgottenPassword({ + config: this.emailConfig, + aesSecret: this.config.aesSecret, + to: user.email, + resetPasswordToken: passwordResetToken, + username: user.username, + firstname: user.firstname, + origin: origin + })); + } else { + /* send email to client */ + await this.mailer.sendMail(formatEmailForFogettenUsername({ + config: this.emailConfig, + to: user.email, + username: user.username + })); + } + return makeGenericReponse(); + } + + public async login(request: Express.Request, username: string, password: string, totp: string, requestexpirydate?: boolean) { + // const { req }: { req: Express.Request } = context; + const result = await this.db.collections.users_collection.findOne({ deleted: null, username: username }); + if (!result) { + throw new GraphQLError('User does not exist.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + const passwordMatched = await bcrypt.compare(password, result.password); + if (!passwordMatched) { + throw new GraphQLError('Incorrect password.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + // validate the TOTP + const totpValidated = mfa.verifyTOTP(totp, result.otpSecret); + if (!totpValidated) { + if (process.env['NODE_ENV'] === 'development') + console.warn('Incorrect One-Time password. Continuing in development ...'); + else + throw new GraphQLError('Incorrect One-Time password.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + /* validate if account expired */ + if (result.expiredAt < Date.now() && result.type === userTypes.STANDARD) { + if (requestexpirydate) { + /* send email to the DMP admin mailing-list */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ + config: this.emailConfig, + userEmail: result.email, + username: result.username + })); + /* send email to client */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoClient({ + config: this.emailConfig, + to: result.email, + username: result.username + })); + throw new GraphQLError('New expiry date has been requested! Wait for ADMIN to approve.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + throw new GraphQLError('Account Expired. Please request a new expiry date!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + const filteredResult: IUserWithoutToken = { ...result }; + + return new Promise((resolve, reject) => { + request.login(filteredResult, (err: unknown) => { + if (err) { + Logger.error(err); + reject(new GraphQLError('Cannot log in. Please try again later.')); + return; + } + resolve(filteredResult); + }); + }); + } + public async logout(request: Express.Request) { + const requester = request.user; + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + return new Promise((resolve) => { + request.logout((err) => { + if (err) { + Logger.error(err); + throw new GraphQLError('Cannot log out'); + } else { + resolve(makeGenericReponse(requester.id)); + } + }); + }); + } + + public async createUser(user: CreateUserInput) { + /* check email is valid form */ + if (!/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(user.email)) { + throw new GraphQLError('Email is not the right format.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + /* check password validity */ + if (user.password && !passwordIsGoodEnough(user.password)) { + throw new GraphQLError('Password has to be at least 8 character long.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + /* check that username and password dont have space */ + if (user.username.indexOf(' ') !== -1 || user.password.indexOf(' ') !== -1) { + throw new GraphQLError('Username or password cannot have spaces.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + const alreadyExist = await this.db.collections.users_collection.findOne({ username: user.username, deleted: null }); // since bycrypt is CPU expensive let's check the username is not taken first + if (alreadyExist !== null && alreadyExist !== undefined) { + throw new GraphQLError('User already exists.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + /* check if email has been used to register */ + const emailExist = await this.db.collections.users_collection.findOne({ email: user.email, deleted: null }); + if (emailExist !== null && emailExist !== undefined) { + throw new GraphQLError('This email has been registered. Please sign-in or register with another email!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + /* randomly generate a secret for Time-based One Time Password*/ + const otpSecret = mfa.generateSecret(); + const hashedPassword: string = await bcrypt.hash(user.password, this.config.bcrypt.saltround); + const createdAt = Date.now(); + const expiredAt = Date.now() + 86400 * 1000 /* millisec per day */ * 90; + const entry: IUser = { + id: uuid(), + username: user.username, + otpSecret, + type: userTypes.STANDARD, + description: user.description ?? '', + organisation: user.organisation, + firstname: user.firstname, + lastname: user.lastname, + password: hashedPassword, + email: user.email, + emailNotificationsActivated: user.emailNotificationsActivated ?? false, + emailNotificationsStatus: { expiringNotification: false }, + createdAt, + expiredAt, + resetPasswordRequests: [], + metadata: user.metadata, + deleted: null + }; + + const result = await this.db.collections.users_collection.insertOne(entry); + if (result.acknowledged) { + const cleared: MarkOptional = { ...entry }; + delete cleared['password']; + delete cleared['otpSecret']; + /* send email to the registered user */ + // get QR Code for the otpSecret. + const oauth_uri = `otpauth://totp/${this.config.appName}:${user.username}?secret=${otpSecret}&issuer=Data%20Science%20Institute`; + const tmpobj = tmp.fileSync({ mode: 0o644, prefix: 'qrcodeimg-', postfix: '.png' }); + + QRCode.toFile(tmpobj.name, oauth_uri, {}, function (err) { + if (err) throw new GraphQLError(err.message); + }); + + const attachments = [{ filename: 'qrcode.png', path: tmpobj.name, cid: 'qrcode_cid' }]; + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: user.email, + subject: `[${this.config.appName}] Registration Successful`, + html: ` +

+ Dear ${user.firstname}, +

+

+ Welcome to the ${this.config.appName} data portal!
+ Your username is ${user.username}.
+

+

+ To login you will need to use a MFA authenticator app for one time passcode (TOTP).
+ Scan the QRCode below in your MFA application of choice to configure it:
+ QR code
+ If you need to type the token in use ${otpSecret.toLowerCase()} +

+
+

+ The ${this.config.appName} Team. +

+ `, + attachments: attachments + }); + tmpobj.removeCallback(); + return makeGenericReponse(); + } else { + throw new GraphQLError('Database error', { extensions: { code: errorCodes.DATABASE_ERROR } }); + } + } + + public async deleteUser(requester: IUserWithoutToken | undefined, userId: string) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + // user (admin type) cannot delete itself + if (requester.id === userId) { + throw new GraphQLError('User cannot delete itself'); + } + + if (requester.type !== userTypes.ADMIN) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + + const session = this.db.client.startSession(); + session.startTransaction(); + try { + /* delete the user */ + await this.db.collections.users_collection.findOneAndUpdate({ id: userId, deleted: null }, { $set: { deleted: new Date().valueOf(), password: 'DeletedUserDummyPassword' } }, { returnDocument: 'after', projection: { deleted: 1 } }); + + /* delete all user records in roles related to the study */ + await this.db.collections.roles_collection.updateMany( + { + deleted: null, + users: userId + }, + { + $pull: { users: { _id: userId } } + } + ); + + await session.commitTransaction(); + session.endSession().catch(() => { return; }); + return makeGenericReponse(userId); + } catch (error) { + // If an error occurred, abort the whole transaction and + // undo any changes that might have happened + await session.abortTransaction(); + session.endSession().catch(() => { return; }); + throw new GraphQLError(`Database error: ${JSON.stringify(error)}`); + } + } + + public async resetPassword(encryptedEmail: string, token: string, newPassword: string) { + /* check password validity */ + if (!passwordIsGoodEnough(newPassword)) { + throw new GraphQLError('Password has to be at least 8 character long.'); + } + + /* check that username and password dont have space */ + if (newPassword.indexOf(' ') !== -1) { + throw new GraphQLError('Password cannot have spaces.'); + } + + /* decrypt email */ + if (token.length < 16) { + throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); + } + const salt = makeAESKeySalt(token); + const iv = makeAESIv(token); + let email; + try { + email = await decryptEmail(this.config.aesSecret, encryptedEmail, salt, iv); + } catch (e) { + throw new GraphQLError('Token is not valid.'); + } + + /* check whether username and token is valid */ + /* not changing password too in one step (using findOneAndUpdate) because bcrypt is costly */ + const TIME_NOW = new Date().valueOf(); + const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; + const user: IUserWithoutToken | null = await this.db.collections.users_collection.findOne({ + email, + resetPasswordRequests: { + $elemMatch: { + id: token, + timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, + used: false + } + }, + deleted: null + }); + if (!user) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + + /* randomly generate a secret for Time-based One Time Password*/ + const otpSecret = mfa.generateSecret(); + + /* all ok; change the user's password */ + const hashedPw = await bcrypt.hash(newPassword, this.config.bcrypt.saltround); + const updateResult = await this.db.collections.users_collection.findOneAndUpdate( + { + id: user.id, + resetPasswordRequests: { + $elemMatch: { + id: token, + timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, + used: false + } + } + }, + { $set: { 'password': hashedPw, 'otpSecret': otpSecret, 'resetPasswordRequests.$.used': true } }); + if (updateResult === null) { + throw new GraphQLError(errorCodes.DATABASE_ERROR); + } + + /* need to log user out of all sessions */ + // TO_DO + + /* send email to the registered user */ + // get QR Code for the otpSecret. + const oauth_uri = `otpauth://totp/${this.config.appName}:${user.username}?secret=${otpSecret}&issuer=Data%20Science%20Institute`; + const tmpobj = tmp.fileSync({ mode: 0o644, prefix: 'qrcodeimg-', postfix: '.png' }); + + QRCode.toFile(tmpobj.name, oauth_uri, {}, function (err) { + if (err) throw new GraphQLError(err.message); + }); + + const attachments = [{ filename: 'qrcode.png', path: tmpobj.name, cid: 'qrcode_cid' }]; + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: email, + subject: `[${this.config.appName}] Password reset`, + html: ` +

+ Dear ${user.firstname}, +

+

+ Your password on ${this.config.appName} is now reset!
+ You will need to update your MFA application for one-time passcode.
+

+

+ To update your MFA authenticator app you can scan the QRCode below to configure it:
+ QR code
+ If you need to type the token in use ${otpSecret.toLowerCase()} +

+
+

+ The ${this.config.appName} Team. +

+ `, + attachments: attachments + }); + tmpobj.removeCallback(); + return makeGenericReponse(); + } + + public async editUser(requester: IUserWithoutToken | undefined, user: EditUserInput) { + if (!requester) { + throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + } + const { id, username, type, firstname, lastname, email, emailNotificationsActivated, emailNotificationsStatus, password, description, organisation, expiredAt, metadata }: { + id: string, username?: string, type?: userTypes, firstname?: string, lastname?: string, email?: string, emailNotificationsActivated?: boolean, emailNotificationsStatus?: unknown, password?: string, description?: string, organisation?: string, expiredAt?: number, metadata?: unknown + } = user; + if (password !== undefined && requester.id !== id) { // only the user themself can reset password + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + if (password && !passwordIsGoodEnough(password)) { + throw new GraphQLError('Password has to be at least 8 character long.'); + } + if (requester.type !== userTypes.ADMIN && requester.id !== id) { + throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); + } + let result; + if (requester.type === userTypes.ADMIN) { + result = await this.db.collections.users_collection.findOne({ id, deleted: null }); // just an extra guard before going to bcrypt cause bcrypt is CPU intensive. + if (result === null || result === undefined) { + throw new GraphQLError('User not found'); + } + } + + const fieldsToUpdate: UpdateFilter = { + type, + firstname, + lastname, + username, + email, + emailNotificationsActivated, + emailNotificationsStatus, + password, + description, + organisation, + expiredAt, + metadata + }; + + /* check email is valid form */ + if (email && !/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { + throw new GraphQLError('User not updated: Email is not the right format.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + if (requester.type !== userTypes.ADMIN && ( + type || firstname || lastname || username || description || organisation + )) { + throw new GraphQLError('User not updated: Non-admin users are only authorised to change their password, email or email notification.'); + } + + if (password) { fieldsToUpdate['password'] = await bcrypt.hash(password, this.config.bcrypt.saltround); } + for (const each of (Object.keys(fieldsToUpdate) as Array)) { + if (fieldsToUpdate[each] === undefined) { + delete fieldsToUpdate[each]; + } + } + if (expiredAt) { + fieldsToUpdate['emailNotificationsStatus'] = { + expiringNotification: false + }; + } + const updateResult = await this.db.collections.users_collection.findOneAndUpdate({ id, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); + if (updateResult) { + // New expiry date has been updated successfully. + if (expiredAt && result) { + /* send email to client */ + await this.mailer.sendMail(formatEmailRequestExpiryDateNotification({ + config: this.emailConfig, + to: result.email, + username: result.username + })); + } + return updateResult; + } else { + throw new GraphQLError('Server error; no entry or more than one entry has been updated.'); + } + } + + public async registerPubkey(pubkeyobj: { pubkey: string, associatedUserId: string | null, jwtPubkey: string, jwtSeckey: string }): Promise { + const { pubkey, associatedUserId, jwtPubkey, jwtSeckey } = pubkeyobj; + const entry: IPubkey = { + id: uuid(), + pubkey, + associatedUserId, + jwtPubkey, + jwtSeckey, + refreshCounter: 0, + deleted: null + }; + + const result = await this.db.collections.pubkeys_collection.insertOne(entry); + if (result.acknowledged) { + return entry; + } else { + throw new GraphQLError('Database error', { extensions: { code: errorCodes.DATABASE_ERROR } }); + } + } +} + +export function makeAESKeySalt(str: string): string { + return str; +} + +export function makeAESIv(str: string): string { + if (str.length < 16) { throw new Error('IV cannot be less than 16 bytes long.'); } + return str.slice(0, 16); +} + +export async function encryptEmail(aesSecret: string, email: string, keySalt: string, iv: string) { + const algorithm = 'aes-256-cbc'; + return new Promise((resolve, reject) => { + crypto.scrypt(aesSecret, keySalt, 32, (err, derivedKey) => { + if (err) reject(err); + const cipher = crypto.createCipheriv(algorithm, derivedKey, iv); + let encoded = cipher.update(email, 'utf8', 'hex'); + encoded += cipher.final('hex'); + resolve(encoded); + }); + }); + +} + +export async function decryptEmail(aesSecret: string, encryptedEmail: string, keySalt: string, iv: string) { + const algorithm = 'aes-256-cbc'; + return new Promise((resolve, reject) => { + crypto.scrypt(aesSecret, keySalt, 32, (err, derivedKey) => { + if (err) reject(err); + try { + const decipher = crypto.createDecipheriv(algorithm, derivedKey, iv); + let decoded = decipher.update(encryptedEmail, 'hex', 'utf8'); + decoded += decipher.final('utf-8'); + resolve(decoded); + } catch (e) { + reject(e); + } + }); + }); +} + +interface IEmailConfig { + appName: string; + nodemailer: { + auth: { + user: string; + }; + }; + adminEmail: string; +} + +async function formatEmailForForgottenPassword({ config, username, aesSecret, firstname, to, resetPasswordToken, origin }: { config: IEmailConfig, aesSecret: string, resetPasswordToken: string, to: string, username: string, firstname: string, origin: unknown }) { + const keySalt = makeAESKeySalt(resetPasswordToken); + const iv = makeAESIv(resetPasswordToken); + const encryptedEmail = await encryptEmail(aesSecret, to, keySalt, iv); + + const link = `${origin}/reset/${encryptedEmail}/${resetPasswordToken}`; + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] password reset`, + html: ` +

+ Dear ${firstname}, +

+

+ Your username is ${username}. +

+

+ You can reset you password by click the following link (active for 1 hour):
+ ${link} +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailForFogettenUsername({ config, username, to }: { config: IEmailConfig, username: string, to: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] password reset`, + html: ` +

+ Dear user, +

+

+ Your username is ${username}. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailRequestExpiryDatetoClient({ config, username, to }: { config: IEmailConfig, username: string, to: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] New expiry date has been requested!`, + html: ` +

+ Dear user, +

+

+ New expiry date for your ${username} account has been requested. + You will get a notification email once the request is approved. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailRequestExpiryDatetoAdmin({ config, username, userEmail }: { config: IEmailConfig, username: string, userEmail: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to: `${config.adminEmail}`, + subject: `[${config.appName}] New expiry date has been requested from ${username} account!`, + html: ` +

+ Dear ADMINs, +

+

+ A expiry date request from the ${username} account (whose email address is ${userEmail}) has been submitted. + Please approve or deny the request ASAP. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailRequestExpiryDateNotification({ config, username, to }: { config: IEmailConfig, username: string, to: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] New expiry date has been updated!`, + html: ` +

+ Dear user, +

+

+ New expiry date for your ${username} account has been updated. + You now can log in as normal. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function passwordIsGoodEnough(pw: string): boolean { + if (pw.length < 8) { + return false; + } + return true; +} diff --git a/packages/itmat-cores/src/database/database.ts b/packages/itmat-cores/src/database/database.ts new file mode 100644 index 000000000..261e5179d --- /dev/null +++ b/packages/itmat-cores/src/database/database.ts @@ -0,0 +1,38 @@ +import type { IDataEntry, IFieldEntry, IFile, IJobEntry, ILogEntry, IOrganisation, IProject, IPubkey, IQueryEntry, IRole, IStudy, IUser, IStandardization } from '@itmat-broker/itmat-types'; +import { Database as DatabaseBase, IDatabaseBaseConfig } from '@itmat-broker/itmat-commons'; +import type { Collection } from 'mongodb'; + +export interface IDatabaseConfig extends IDatabaseBaseConfig { + collections: { + users_collection: string, + jobs_collection: string, + studies_collection: string, + projects_collection: string, + queries_collection: string, + field_dictionary_collection: string, + roles_collection: string, + files_collection: string, + organisations_collection: string, + log_collection: string, + pubkeys_collection: string, + data_collection: string, + standardizations_collection: string, + }; +} + +export interface IDatabaseCollectionConfig { + users_collection: Collection, + jobs_collection: Collection, + studies_collection: Collection, + projects_collection: Collection, + queries_collection: Collection, + field_dictionary_collection: Collection, + roles_collection: Collection, + files_collection: Collection, + organisations_collection: Collection, + log_collection: Collection, + pubkeys_collection: Collection, + data_collection: Collection, + standardizations_collection: Collection, +} +export type DBType = DatabaseBase; diff --git a/packages/itmat-cores/src/declare.d.ts b/packages/itmat-cores/src/declare.d.ts new file mode 100644 index 000000000..744ba13ec --- /dev/null +++ b/packages/itmat-cores/src/declare.d.ts @@ -0,0 +1 @@ +declare module 'graphql-type-json'; diff --git a/packages/itmat-cores/src/index.ts b/packages/itmat-cores/src/index.ts new file mode 100644 index 000000000..a2e4263f3 --- /dev/null +++ b/packages/itmat-cores/src/index.ts @@ -0,0 +1,15 @@ +export * from './core/fileCore'; +export * from './core/jobCore'; +export * from './core/logCore'; +export * from './core/organisationCore'; +export * from './core/permissionCore'; +export * from './core/pubkeyCore'; +export * from './core/queryCore'; +export * from './core/standardizationCore'; +export * from './core/studyCore'; +export * from './core/userCore'; +export * from './rest/fileDownload'; +export * from './authentication/pubkeyAuthentication'; +export * from './log/logPlugin'; +export * from './database/database'; +export * from './utils'; \ No newline at end of file diff --git a/packages/itmat-cores/src/log/logPlugin.ts b/packages/itmat-cores/src/log/logPlugin.ts new file mode 100644 index 000000000..2a684ea2e --- /dev/null +++ b/packages/itmat-cores/src/log/logPlugin.ts @@ -0,0 +1,121 @@ +import { v4 as uuid } from 'uuid'; +import { LOG_TYPE, LOG_ACTION, LOG_STATUS, USER_AGENT, userTypes } from '@itmat-broker/itmat-types'; +import { GraphQLRequestContextWillSendResponse } from '@apollo/server'; +import { ApolloServerContext } from '../utils/ApolloServerContext'; +import { DBType } from '../database/database'; + +// only requests in white list will be recorded +export const logActionRecordWhiteList = Object.keys(LOG_ACTION); + +// only requests in white list will be recorded +export const logActionShowWhiteList = Object.keys(LOG_ACTION); + +export class LogPlugin { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + + public async serverWillStartLogPlugin(): Promise { + await this.db.collections.log_collection.insertOne({ + id: uuid(), + requesterName: userTypes.SYSTEM, + requesterType: userTypes.SYSTEM, + logType: LOG_TYPE.SYSTEM_LOG, + actionType: LOG_ACTION.startSERVER, + actionData: JSON.stringify({}), + time: Date.now(), + status: LOG_STATUS.SUCCESS, + errors: '', + userAgent: USER_AGENT.OTHER + }); + return null; + } + + public async requestDidStartLogPlugin(requestContext: GraphQLRequestContextWillSendResponse): Promise { + if (!requestContext.operationName || !logActionRecordWhiteList.includes(requestContext.operationName)) { + return null; + } + if (LOG_ACTION[requestContext.operationName] === undefined || LOG_ACTION[requestContext.operationName] === null) { + return null; + } + const variables = requestContext.request.variables ?? {}; // Add null check + await this.db.collections.log_collection.insertOne({ + id: uuid(), + requesterName: requestContext.contextValue?.req?.user?.username ?? 'NA', + requesterType: requestContext.contextValue?.req?.user?.type ?? userTypes.SYSTEM, + userAgent: (requestContext.contextValue.req?.headers['user-agent'] as string)?.startsWith('Mozilla') ? USER_AGENT.MOZILLA : USER_AGENT.OTHER, + logType: LOG_TYPE.REQUEST_LOG, + actionType: LOG_ACTION[requestContext.operationName], + actionData: JSON.stringify(ignoreFieldsHelper(variables, requestContext.operationName)), // Use the null-checked variables + time: Date.now(), + status: requestContext.errors === undefined ? LOG_STATUS.SUCCESS : LOG_STATUS.FAIL, + errors: requestContext.errors === undefined ? '' : requestContext.errors[0].message + }); + return null; + } +} + +type LogOperationName = 'login' | 'createUser' | 'registerPubkey' | 'issueAccessToken' | 'editUser' | 'uploadDataInArray' | 'uploadFile' | string; + +interface LoginData { + passpord?: string; + totp?: string; +} + +interface CreateUserData { + user?: { + password?: string; + }; +} + +interface registerPubkeyData { + signature?: string; +} + +interface EditUserData { + user?: { + password?: string; + }; +} + +interface UploadDataInArrayData { + data?: { + value?: string; + file?: string; + metadata?: string; + }[]; +} + +interface UploadFileData { + file?: string; +} + +type DataObj = LoginData | CreateUserData | registerPubkeyData | EditUserData | UploadDataInArrayData | UploadFileData; + +function ignoreFieldsHelper(dataObj: DataObj, operationName: LogOperationName) { + if (operationName === 'login') { + delete dataObj['password']; + delete dataObj['totp']; + } else if (operationName === 'createUser') { + delete dataObj['user']['password']; + } else if (operationName === 'registerPubkey') { + delete dataObj['signature']; + } else if (operationName === 'issueAccessToken') { + delete dataObj['signature']; + } else if (operationName === 'editUser') { + delete dataObj['user']['password']; + } else if (operationName === 'uploadDataInArray') { + if (Array.isArray(dataObj['data'])) { + for (let i = 0; i < dataObj['data'].length; i++) { + // only keep the fieldId + delete dataObj['data'][i].value; + delete dataObj['data'][i].file; + delete dataObj['data'][i].metadata; + } + } + } else if (operationName === 'uploadFile') { + delete dataObj['file']; + } + return dataObj; +} \ No newline at end of file diff --git a/packages/itmat-cores/src/rest/fileDownload.ts b/packages/itmat-cores/src/rest/fileDownload.ts new file mode 100644 index 000000000..896811582 --- /dev/null +++ b/packages/itmat-cores/src/rest/fileDownload.ts @@ -0,0 +1,80 @@ +import { Request, Response } from 'express'; +import { atomicOperation, IUser } from '@itmat-broker/itmat-types'; +import jwt from 'jsonwebtoken'; +import { userRetrieval } from '../authentication/pubkeyAuthentication'; +import { ApolloServerErrorCode } from '@apollo/server/errors'; +import { GraphQLError } from 'graphql'; +import { PermissionCore } from '../core/permissionCore'; +import { DBType } from '../database/database'; +import { ObjectStore } from '@itmat-broker/itmat-commons'; + +export class FileDownloadController { + private _permissionCore: PermissionCore; + private _db: DBType; + private _objStore: ObjectStore; + + constructor(db: DBType, objStore: ObjectStore) { + this._db = db; + this._permissionCore = new PermissionCore(db); + this._objStore = objStore; + } + + public fileDownloadController = (req: Request, res: Response) => { + (async () => { + const requester = req.user as IUser; + const requestedFile = req.params['fileId']; + const token = req.headers.authorization || ''; + let associatedUser = requester; + if ((token !== '') && (req.user === undefined)) { + // get the decoded payload ignoring signature, no symmetric secret or asymmetric key needed + const decodedPayload = jwt.decode(token); + // obtain the public-key of the robot user in the JWT payload + let pubkey: string; + if (decodedPayload !== null && !(typeof decodedPayload === 'string')) { + pubkey = decodedPayload['publicKey']; + } else { + throw new GraphQLError('JWT verification failed.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + // verify the JWT + jwt.verify(token, pubkey, function (error) { + if (error) { + throw new GraphQLError('JWT verification failed.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); + } + }); + associatedUser = await userRetrieval(this._db, pubkey); + } else if (!requester) { + res.status(403).json({ error: 'Please log in.' }); + return; + } + try { + /* download file */ + const file = await this._db.collections.files_collection.findOne({ id: requestedFile, deleted: null }); + if (!file) { + res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); + return; + } + + // check target field exists + const hasStudyLevelPermission = await this._permissionCore.userHasTheNeccessaryDataPermission( + atomicOperation.READ, + associatedUser, + file.studyId + ); + if (!hasStudyLevelPermission) { + res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); + return; + } + + const stream = await this._objStore.downloadFile(file.studyId, file.uri); + res.set('Content-Type', 'application/octet-stream'); + res.set('Content-Type', 'application/download'); + res.set('Content-Disposition', `attachment; filename="${file.fileName}"`); + stream.pipe(res, { end: true }); + return; + } catch (e) { + res.status(500).json(e); + return; + } + })().catch(() => { return; }); + }; +} diff --git a/packages/itmat-cores/src/utils/ApolloServerContext.ts b/packages/itmat-cores/src/utils/ApolloServerContext.ts new file mode 100644 index 000000000..bb72427bb --- /dev/null +++ b/packages/itmat-cores/src/utils/ApolloServerContext.ts @@ -0,0 +1,4 @@ +export interface ApolloServerContext { + req: Express.Request; + token?: string; +} \ No newline at end of file diff --git a/packages/itmat-cores/src/utils/configManager.ts b/packages/itmat-cores/src/utils/configManager.ts new file mode 100644 index 000000000..de7d0c91d --- /dev/null +++ b/packages/itmat-cores/src/utils/configManager.ts @@ -0,0 +1,43 @@ +import merge from 'deepmerge'; +import fs from 'fs-extra'; +import path from 'path'; +import { IObjectStoreConfig, IDatabaseBaseConfig, Logger } from '@itmat-broker/itmat-commons'; +import configDefaults from '../../config/config.sample.json'; +import { IServerConfig } from './server.js'; +import chalk from 'chalk'; +import SMTPTransport from 'nodemailer/lib/smtp-transport'; + +export interface IConfiguration extends IServerConfig { + appName: string; + database: IDatabaseBaseConfig; + objectStore: IObjectStoreConfig; + nodemailer: SMTPTransport.Options & { auth: { user: string, pass: string } }; + aesSecret: string; + sessionsSecret: string; + adminEmail: string; + aeEndpoint: string; +} + +export class ConfigurationManager { + + public static expand(configurationFiles: string[]): IConfiguration { + + let config = configDefaults; + Logger.log('Applied default configuration.'); + + configurationFiles.forEach((configurationFile) => { + try { + if (fs.existsSync(configurationFile)) { + const content = fs.readFileSync(configurationFile, 'utf8'); + config = merge(config, JSON.parse(content)); + Logger.log(`Applied configuration from ${path.resolve(configurationFile)}.`); + } + } catch (e) { + Logger.error(chalk.red('Cannot parse configuration file.')); + } + }); + + return config; + } + +} diff --git a/packages/itmat-interface/src/utils/definition.ts b/packages/itmat-cores/src/utils/definition.ts similarity index 100% rename from packages/itmat-interface/src/utils/definition.ts rename to packages/itmat-cores/src/utils/definition.ts diff --git a/packages/itmat-interface/src/graphql/errors.ts b/packages/itmat-cores/src/utils/errors.ts similarity index 100% rename from packages/itmat-interface/src/graphql/errors.ts rename to packages/itmat-cores/src/utils/errors.ts diff --git a/packages/itmat-cores/src/utils/index.ts b/packages/itmat-cores/src/utils/index.ts new file mode 100644 index 000000000..89f72431e --- /dev/null +++ b/packages/itmat-cores/src/utils/index.ts @@ -0,0 +1,13 @@ +export * from './ApolloServerContext'; +export * from './configManager'; +export * from './definition'; +export * from './errors'; +export * from './mfa'; +export * from './noop'; +export * from './pubkeycrypto'; +export * from './pubsub'; +export * from './query'; +export * from './regrex'; +export * from './responses'; +export * from './server'; +export * from './userLoginUtils'; \ No newline at end of file diff --git a/packages/itmat-interface/src/utils/mfa.ts b/packages/itmat-cores/src/utils/mfa.ts similarity index 100% rename from packages/itmat-interface/src/utils/mfa.ts rename to packages/itmat-cores/src/utils/mfa.ts diff --git a/packages/itmat-interface/src/utils/noop.ts b/packages/itmat-cores/src/utils/noop.ts similarity index 100% rename from packages/itmat-interface/src/utils/noop.ts rename to packages/itmat-cores/src/utils/noop.ts diff --git a/packages/itmat-interface/src/utils/pubkeycrypto.ts b/packages/itmat-cores/src/utils/pubkeycrypto.ts similarity index 100% rename from packages/itmat-interface/src/utils/pubkeycrypto.ts rename to packages/itmat-cores/src/utils/pubkeycrypto.ts diff --git a/packages/itmat-cores/src/utils/pubsub.ts b/packages/itmat-cores/src/utils/pubsub.ts new file mode 100644 index 000000000..92305d19b --- /dev/null +++ b/packages/itmat-cores/src/utils/pubsub.ts @@ -0,0 +1,8 @@ +import { PubSub } from 'graphql-subscriptions'; + +export const pubsub = new PubSub(); + +export const subscriptionEvents = { + JOB_STATUS_CHANGE: 'JOB_STATUS_CHANGE', + NEW_JOB: 'NEW_JOB' +}; diff --git a/packages/itmat-interface/src/utils/query.ts b/packages/itmat-cores/src/utils/query.ts similarity index 99% rename from packages/itmat-interface/src/utils/query.ts rename to packages/itmat-cores/src/utils/query.ts index 1eff6422e..2e561680e 100644 --- a/packages/itmat-interface/src/utils/query.ts +++ b/packages/itmat-cores/src/utils/query.ts @@ -1,6 +1,6 @@ import { IStudy, IFieldEntry, IStandardization, ICohortSelection, IQueryString, IGroupedData, StandardizationFilterOptionParameters, StandardizationFilterOptions } from '@itmat-broker/itmat-types'; import { Filter } from 'mongodb'; -import { QueryMatcher } from '../graphql/core/permissionCore'; +import { QueryMatcher } from '../core/permissionCore'; /* queryString: format: string # returned foramt: raw, standardized, grouped, summary diff --git a/packages/itmat-interface/src/utils/regrex.ts b/packages/itmat-cores/src/utils/regrex.ts similarity index 100% rename from packages/itmat-interface/src/utils/regrex.ts rename to packages/itmat-cores/src/utils/regrex.ts diff --git a/packages/itmat-cores/src/utils/responses.ts b/packages/itmat-cores/src/utils/responses.ts new file mode 100644 index 000000000..c342319ce --- /dev/null +++ b/packages/itmat-cores/src/utils/responses.ts @@ -0,0 +1,16 @@ +export interface IGenericResponse { + successful: boolean; + id?: string; + code?: string; + description?: string; +} + +export function makeGenericReponse(id?: string, successful?: boolean, code?: string, description?: string): IGenericResponse { + const res: IGenericResponse = { + id: id ?? undefined, + successful: successful ?? true, + code: code ?? undefined, + description: description ?? undefined + }; + return res; +} diff --git a/packages/itmat-cores/src/utils/server.ts b/packages/itmat-cores/src/utils/server.ts new file mode 100644 index 000000000..41f6b5685 --- /dev/null +++ b/packages/itmat-cores/src/utils/server.ts @@ -0,0 +1,7 @@ +import { IServerBaseConfig } from '@itmat-broker/itmat-commons'; + +export interface IServerConfig extends IServerBaseConfig { + bcrypt: { + saltround: number + }; +} diff --git a/packages/itmat-cores/src/utils/userLoginUtils.ts b/packages/itmat-cores/src/utils/userLoginUtils.ts new file mode 100644 index 000000000..1821ea850 --- /dev/null +++ b/packages/itmat-cores/src/utils/userLoginUtils.ts @@ -0,0 +1,27 @@ +import { IUser, IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; + +export class UserLoginUtils { + db: DBType; + constructor(db: DBType) { + this.db = db; + this.serialiseUser = this.serialiseUser.bind(this); + this.deserialiseUser = this.deserialiseUser.bind(this); + } + + public serialiseUser(user: Express.User, done: (__unused__err: unknown, __unused__id: string) => void) { + done(null, (user as IUser).username); + } + + public deserialiseUser(username: string, done: (__unused__err: unknown, __unused__id: IUserWithoutToken | null) => void) { + this._getUser(username) + .then(user => { + done(null, user); + }) + .catch(() => { return; }); + } + + private async _getUser(username: string): Promise { + return await this.db.collections.users_collection.findOne({ deleted: null, username }, { projection: { _id: 0, deleted: 0, password: 0 } }); + } +} diff --git a/packages/itmat-cores/tsconfig.json b/packages/itmat-cores/tsconfig.json new file mode 100644 index 000000000..2bc65d594 --- /dev/null +++ b/packages/itmat-cores/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} \ No newline at end of file diff --git a/packages/itmat-cores/tsconfig.lib.json b/packages/itmat-cores/tsconfig.lib.json new file mode 100644 index 000000000..472dc6121 --- /dev/null +++ b/packages/itmat-cores/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [ + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "jest.config.ts", + "**/*.spec.ts", + "**/*.test.ts" + ] +} \ No newline at end of file diff --git a/packages/itmat-cores/tsconfig.spec.json b/packages/itmat-cores/tsconfig.spec.json new file mode 100644 index 000000000..e85ec32ef --- /dev/null +++ b/packages/itmat-cores/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "jest.config.ts", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.d.ts" + ] +} \ No newline at end of file diff --git a/packages/itmat-interface/src/database/database.ts b/packages/itmat-interface/src/database/database.ts index 68b27117c..89168519c 100644 --- a/packages/itmat-interface/src/database/database.ts +++ b/packages/itmat-interface/src/database/database.ts @@ -1,39 +1,4 @@ -import type { IDataEntry, IFieldEntry, IFile, IJobEntry, ILogEntry, IOrganisation, IProject, IPubkey, IQueryEntry, IRole, IStudy, IUser, IStandardization } from '@itmat-broker/itmat-types'; import { Database as DatabaseBase, IDatabaseBaseConfig } from '@itmat-broker/itmat-commons'; -import type { Collection } from 'mongodb'; - -export interface IDatabaseConfig extends IDatabaseBaseConfig { - collections: { - users_collection: string, - jobs_collection: string, - studies_collection: string, - projects_collection: string, - queries_collection: string, - field_dictionary_collection: string, - roles_collection: string, - files_collection: string, - organisations_collection: string, - log_collection: string, - pubkeys_collection: string, - data_collection: string, - standardizations_collection: string, - }; -} - -export interface IDatabaseCollectionConfig { - users_collection: Collection, - jobs_collection: Collection, - studies_collection: Collection, - projects_collection: Collection, - queries_collection: Collection, - field_dictionary_collection: Collection, - roles_collection: Collection, - files_collection: Collection, - organisations_collection: Collection, - log_collection: Collection, - pubkeys_collection: Collection, - data_collection: Collection, - standardizations_collection: Collection, -} +import { IDatabaseCollectionConfig } from '@itmat-broker/itmat-cores'; export const db = new DatabaseBase(); diff --git a/packages/itmat-interface/src/emailer/emailer.ts b/packages/itmat-interface/src/emailer/emailer.ts index 0c72604fc..7c0782d8a 100644 --- a/packages/itmat-interface/src/emailer/emailer.ts +++ b/packages/itmat-interface/src/emailer/emailer.ts @@ -1,16 +1,4 @@ -import nodemailer, { SendMailOptions } from 'nodemailer'; import appConfig from '../utils/configManager'; - -class Mailer { - private readonly _client: nodemailer.Transporter; - - constructor(config: Parameters[0]) { - this._client = nodemailer.createTransport(config); - } - - public async sendMail(mail: SendMailOptions): Promise { - await this._client.sendMail(mail); - } -} +import { Mailer } from '@itmat-broker/itmat-commons'; export const mailer = new Mailer(appConfig.nodemailer); diff --git a/packages/itmat-interface/src/graphql/core/fieldCore.ts b/packages/itmat-interface/src/graphql/core/fieldCore.ts deleted file mode 100644 index fb3c67d68..000000000 --- a/packages/itmat-interface/src/graphql/core/fieldCore.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { IFieldEntry, enumValueType, IUserWithoutToken } from '@itmat-broker/itmat-types'; -import { db } from '../../database/database'; -import { v4 as uuid } from 'uuid'; -import { Document, Filter } from 'mongodb'; -export class FieldCore { - public async getFieldsOfStudy(studyId: string, detailed: boolean, getOnlyTheseFields?: string[]): Promise { - /* ASSUMING projectId and studyId match*/ - /* if detailed=false, only returns the fieldid in an array */ - /* constructing queryObj; if projectId is provided then only those in the approved fields are returned */ - let queryObj: Filter = { studyId }; - if (getOnlyTheseFields) { // if both study id and project id are provided then just make sure they belong to each other - queryObj = { studyId, fieldId: { $in: getOnlyTheseFields } }; - } - - const aggregatePipeline: Document[] = [ - { $match: queryObj } - ]; - /* if detailed=false, only returns the fieldid in an array */ - if (detailed === false) { - aggregatePipeline.push({ $group: { _id: null, array: { $addToSet: '$fieldId' } } }); - } - - const cursor = db.collections.field_dictionary_collection.aggregate(aggregatePipeline); - return cursor.toArray(); - } - -} - -export function validateAndGenerateFieldEntry(fieldEntry: Partial, requester: IUserWithoutToken) { - // duplicates with existing fields are checked by caller function - const error: string[] = []; - const complusoryField = [ - 'fieldId', - 'fieldName', - 'dataType' - ]; - - // check missing field - for (const key of complusoryField) { - if (fieldEntry[key] === undefined && fieldEntry[key] === null) { - error.push(`${key} should not be empty.`); - } - } - // only english letters, numbers and _ are allowed in fieldIds - if (!/^[a-zA-Z0-9_]*$/.test(fieldEntry.fieldId || '')) { - error.push('FieldId should contain letters, numbers and _ only.'); - } - // data types - if (!fieldEntry.dataType || !Object.values(enumValueType).includes(fieldEntry.dataType)) { - error.push(`Data type shouldn't be ${fieldEntry.dataType}: use 'int' for integer, 'dec' for decimal, 'str' for string, 'bool' for boolean, 'date' for datetime, 'file' for FILE, 'json' for json.`); - } - // check possiblevalues to be not-empty if datatype is categorical - if (fieldEntry.dataType === enumValueType.CATEGORICAL) { - if (fieldEntry.possibleValues !== undefined && fieldEntry.possibleValues !== null) { - if (fieldEntry.possibleValues.length === 0) { - error.push(`${fieldEntry.fieldId}-${fieldEntry.fieldName}: possible values can't be empty if data type is categorical.`); - } - for (let i = 0; i < fieldEntry.possibleValues.length; i++) { - fieldEntry.possibleValues[i]['id'] = uuid(); - } - } else { - error.push(`${fieldEntry.fieldId}-${fieldEntry.fieldName}: possible values can't be empty if data type is categorical.`); - } - } - - const newField = { - fieldId: fieldEntry.fieldId, - fieldName: fieldEntry.fieldName, - tableName: fieldEntry.tableName, - dataType: fieldEntry.dataType, - possibleValues: fieldEntry.dataType === enumValueType.CATEGORICAL ? fieldEntry.possibleValues : null, - unit: fieldEntry.unit, - comments: fieldEntry.comments, - metadata: { - 'uploader:org': requester.organisation, - 'uploader:user': requester.id, - ...fieldEntry.metadata - } - }; - - return { fieldEntry: newField, error: error }; -} - -export const fieldCore = Object.freeze(new FieldCore()); diff --git a/packages/itmat-interface/src/graphql/core/jobCore.ts b/packages/itmat-interface/src/graphql/core/jobCore.ts deleted file mode 100644 index 7a9a4fd94..000000000 --- a/packages/itmat-interface/src/graphql/core/jobCore.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IJobEntry } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { db } from '../../database/database'; - -export class JobCore { - public async createJob(userId: string, jobType: string, files: string[], studyId: string, projectId?: string, jobId?: string): Promise { - const job: IJobEntry = { - requester: userId, - id: jobId || uuid(), - studyId, - jobType, - projectId, - requestTime: new Date().valueOf(), - receivedFiles: files, - status: 'QUEUED', - error: null, - cancelled: false - }; - await db.collections.jobs_collection.insertOne(job); - return job; - } -} - -export const jobCore = Object.freeze(new JobCore()); diff --git a/packages/itmat-interface/src/graphql/core/queryCore.ts b/packages/itmat-interface/src/graphql/core/queryCore.ts deleted file mode 100644 index 270e8029e..000000000 --- a/packages/itmat-interface/src/graphql/core/queryCore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IQueryEntry } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { db } from '../../database/database'; - -export class QueryCore { - public async createQuery(args): Promise { - const query: IQueryEntry = { - requester: args.query.userId, - id: uuid(), - queryString: args.query.queryString, - studyId: args.query.studyId, - projectId: args.query.projectId, - status: 'QUEUED', - error: null, - cancelled: false, - data_requested: args.query.queryString.data_requested, - cohort: args.query.queryString.cohort, - new_fields: args.query.queryString.new_fields - }; - await db.collections.queries_collection.insertOne(query); - return query; - } - - public async getUsersQuery_NoResult(userId: string): Promise { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - return db.collections.queries_collection.find({ requester: userId }, { projection: { _id: 0, claimedBy: 0, queryResult: 0 } }).toArray(); - } - -} - -export const queryCore = Object.freeze(new QueryCore()); diff --git a/packages/itmat-interface/src/graphql/core/studyCore.ts b/packages/itmat-interface/src/graphql/core/studyCore.ts deleted file mode 100644 index a375c8413..000000000 --- a/packages/itmat-interface/src/graphql/core/studyCore.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { GraphQLError } from 'graphql'; -import { IFile, IProject, IStudy, studyType, IStudyDataVersion, IDataEntry, IDataClip, IRole, IFieldEntry, deviceTypes, IOrganisation, IUserWithoutToken } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { db } from '../../database/database'; -import { errorCodes } from '../errors'; -import { ICombinedPermissions, PermissionCore, permissionCore, translateCohort } from './permissionCore'; -import { validate } from '@ideafast/idgen'; -import type { MatchKeysAndValues } from 'mongodb'; -import { objStore } from '../../objStore/objStore'; -import { FileUpload } from 'graphql-upload-minimal'; -import crypto from 'crypto'; -import { fileSizeLimit } from '../../utils/definition'; -import { IGenericResponse } from '../responses'; -export class StudyCore { - constructor(private readonly localPermissionCore: PermissionCore) { } - - public async findOneStudy_throwErrorIfNotExist(studyId: string): Promise { - const studySearchResult = await db.collections.studies_collection.findOne({ id: studyId, deleted: null }); - if (studySearchResult === null || studySearchResult === undefined) { - throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - return studySearchResult; - } - - public async findOneProject_throwErrorIfNotExist(projectId: string): Promise { - const projectSearchResult = await db.collections.projects_collection.findOne({ id: projectId, deleted: null }); - if (projectSearchResult === null || projectSearchResult === undefined) { - throw new GraphQLError('Project does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - return projectSearchResult; - } - - public async createNewStudy(studyName: string, description: string, type: studyType, requestedBy: string): Promise { - /* check if study already exist (lowercase because S3 minio buckets cant be mixed case) */ - const existingStudies = await db.collections.studies_collection.aggregate<{ name: string }>( - [ - { $match: { deleted: null } }, - { - $group: { - _id: '', - name: { - $push: { $toLower: '$name' } - } - } - }, - { $project: { name: 1 } } - ] - ).toArray(); - - if (existingStudies[0] && existingStudies[0].name.includes(studyName.toLowerCase())) { - throw new GraphQLError(`Study "${studyName}" already exists (duplicates are case-insensitive).`); - } - - const study: IStudy = { - id: uuid(), - name: studyName, - createdBy: requestedBy, - currentDataVersion: -1, - lastModified: new Date().valueOf(), - dataVersions: [], - deleted: null, - description: description, - type: type, - ontologyTrees: [], - metadata: {} - }; - await db.collections.studies_collection.insertOne(study); - return study; - } - - public async editStudy(studyId: string, description: string): Promise { - const res = await db.collections.studies_collection.findOneAndUpdate({ id: studyId }, { $set: { description: description } }, { returnDocument: 'after' }); - if (res) { - return res; - } else { - throw new GraphQLError('Edit study failed'); - } - } - - public async createNewDataVersion(studyId: string, tag: string, dataVersion: string): Promise { - const newDataVersionId = uuid(); - const newContentId = uuid(); - - // update data - const resData = await db.collections.data_collection.updateMany({ - m_studyId: studyId, - m_versionId: null - }, { - $set: { - m_versionId: newDataVersionId - } - }); - // update field - const resField = await db.collections.field_dictionary_collection.updateMany({ - studyId: studyId, - dataVersion: null - }, { - $set: { - dataVersion: newDataVersionId - } - }); - // update standardization - const resStandardization = await db.collections.standardizations_collection.updateMany({ - studyId: studyId, - dataVersion: null - }, { - $set: { - dataVersion: newDataVersionId - } - }); - - // update ontology trees - const resOntologyTrees = await db.collections.studies_collection.updateOne({ 'id': studyId, 'deleted': null, 'ontologyTrees.dataVersion': null }, { - $set: { - 'ontologyTrees.$.dataVersion': newDataVersionId - } - }); - - if (resData.modifiedCount === 0 && resField.modifiedCount === 0 && resStandardization.modifiedCount === 0 && resOntologyTrees.modifiedCount === 0) { - return null; - } - - // insert a new version into study - const newDataVersion: IStudyDataVersion = { - id: newDataVersionId, - contentId: newContentId, // same content = same id - used in reverting data, version control - version: dataVersion, - tag: tag, - updateDate: (new Date().valueOf()).toString() - }; - await db.collections.studies_collection.updateOne({ id: studyId }, { - $push: { dataVersions: newDataVersion }, - $inc: { - currentDataVersion: 1 - } - }); - - // update permissions based on roles - const roles = await db.collections.roles_collection.find({ studyId: studyId, deleted: null }).toArray(); - for (const role of roles) { - const filters: ICombinedPermissions = { - subjectIds: role.permissions.data?.subjectIds || [], - visitIds: role.permissions.data?.visitIds || [], - fieldIds: role.permissions.data?.fieldIds || [] - }; - // deal with data filters - let validSubjects: Array | null = null; - if (role.permissions.data?.filters) { - if (role.permissions.data.filters.length > 0) { - validSubjects = []; - const subqueries = translateCohort(role.permissions.data.filters); - validSubjects = (await db.collections.data_collection.aggregate<{ - m_subjectId: string, m_visitId: string, m_fieldId: string, value: string | number | boolean | { [key: string]: unknown } - }>([{ - $match: { m_fieldId: { $in: role.permissions.data.filters.map(el => el.field) } } - }, - { - $sort: { uploadedAt: -1 } - }, { - $group: { - _id: { m_subjectId: '$m_subjectId', m_visitId: '$m_visitId', m_fieldId: '$m_fieldId' }, - doc: { $first: '$$ROOT' } - } - }, { - $project: { - m_subjectId: '$doc.m_subjectId', - m_visitId: '$doc.m_visitId', - m_fieldId: '$doc.m_fieldId', - value: '$doc.value', - _id: 0 - } - }, { - $match: { $and: subqueries } - }], { allowDiskUse: true }).toArray()).map(el => el.m_subjectId); - } - } - if (validSubjects === null) { - validSubjects = [/^.*$/]; - } - const tag = `metadata.${'role:'.concat(role.id)}`; - await db.collections.data_collection.updateMany({ - m_studyId: studyId, - m_versionId: newDataVersionId, - $and: [ - { m_subjectId: { $in: filters.subjectIds.map((el: string) => new RegExp(el)) } }, - { m_subjectId: { $in: validSubjects } } - ], - m_visitId: { $in: filters.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: filters.fieldIds.map((el: string) => new RegExp(el)) } - }, { - $set: { [tag]: true } - }); - await db.collections.data_collection.updateMany({ - m_studyId: studyId, - m_versionId: newDataVersionId, - $or: [ - { m_subjectId: { $nin: filters.subjectIds.map((el: string) => new RegExp(el)) } }, - { m_subjectId: { $nin: validSubjects } }, - { m_visitId: { $nin: filters.visitIds.map((el: string) => new RegExp(el)) } }, - { m_fieldId: { $nin: filters.fieldIds.map((el: string) => new RegExp(el)) } } - ] - }, { - $set: { [tag]: false } - }); - await db.collections.field_dictionary_collection.updateMany({ - studyId: studyId, - dataVersion: newDataVersionId, - fieldId: { $in: filters.fieldIds.map((el: string) => new RegExp(el)) } - }, { - $set: { [tag]: true } - }); - await db.collections.field_dictionary_collection.updateMany({ - studyId: studyId, - dataVersion: newDataVersionId, - fieldId: { $nin: filters.fieldIds.map((el: string) => new RegExp(el)) } - }, { - $set: { [tag]: false } - }); - } - return newDataVersion; - } - - public async uploadOneDataClip(studyId: string, permissions, fieldList: Partial[], data: IDataClip[], requester: IUserWithoutToken): Promise { - const response: IGenericResponse[] = []; - let bulk = db.collections.data_collection.initializeUnorderedBulkOp(); - // remove duplicates by subjectId, visitId and fieldId - const keysToCheck: Array = ['visitId', 'subjectId', 'fieldId']; - const filteredData = data.filter( - (s => o => (k => !s.has(k) && s.add(k))(keysToCheck.map(k => o[k]).join('|')))(new Set()) - ); - for (const dataClip of filteredData) { - // remove the '-' if there exists - dataClip.subjectId = dataClip.subjectId.replace('-', ''); - const fieldInDb = fieldList.filter(el => el.fieldId === dataClip.fieldId)[0]; - if (!fieldInDb) { - response.push({ successful: false, code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, description: `Field ${dataClip.fieldId}: Field Not found` }); - continue; - } - // check subjectId - if (!validate(dataClip.subjectId.substr(1) ?? '')) { - response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: `Subject ID ${dataClip.subjectId} is illegal.` }); - continue; - } - if (!(await permissionCore.checkDataEntryValid(permissions, dataClip.fieldId, dataClip.subjectId, dataClip.visitId))) { - response.push({ successful: false, code: errorCodes.NO_PERMISSION_ERROR, description: 'You do not have access to this field.' }); - continue; - } - // check value is valid - let error; - let parsedValue; - if (dataClip.value?.toString() === '99999') { // agreement with other WPs, 99999 refers to missing - parsedValue = '99999'; - } else { - switch (fieldInDb.dataType) { - case 'dec': {// decimal - if (typeof (dataClip.value) !== 'string') { - error = `Field ${dataClip.fieldId}: Cannot parse as decimal.`; - break; - } - if (!/^\d+(.\d+)?$/.test(dataClip.value)) { - error = `Field ${dataClip.fieldId}: Cannot parse as decimal.`; - break; - } - parsedValue = parseFloat(dataClip.value); - break; - } - case 'int': {// integer - if (typeof (dataClip.value) !== 'string') { - error = `Field ${dataClip.fieldId}: Cannot parse as integer.`; - break; - } - if (!/^-?\d+$/.test(dataClip.value)) { - error = `Field ${dataClip.fieldId}: Cannot parse as integer.`; - break; - } - parsedValue = parseInt(dataClip.value, 10); - break; - } - case 'bool': {// boolean - if (typeof (dataClip.value) !== 'string') { - error = `Field ${dataClip.fieldId}: Cannot parse as boolean.`; - break; - } - if (dataClip.value.toLowerCase() === 'true' || dataClip.value.toLowerCase() === 'false') { - parsedValue = dataClip.value.toLowerCase() === 'true'; - } else { - error = `Field ${dataClip.fieldId}: Cannot parse as boolean.`; - break; - } - break; - } - case 'str': { - if (typeof (dataClip.value) !== 'string') { - error = `Field ${dataClip.fieldId}: Cannot parse as string.`; - break; - } - parsedValue = dataClip.value.toString(); - break; - } - // 01/02/2021 00:00:00 - case 'date': { - if (typeof (dataClip.value) !== 'string') { - error = `Field ${dataClip.fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; - break; - } - const matcher = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?/; - if (!dataClip.value.match(matcher)) { - error = `Field ${dataClip.fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; - break; - } - parsedValue = dataClip.value.toString(); - break; - } - case 'json': { - parsedValue = dataClip.value; - break; - } - case 'file': { - if (!dataClip.file || typeof (dataClip.file) === 'string') { - error = `Field ${dataClip.fieldId}: Cannot parse as file.`; - break; - } - // if old file exists, delete it first - const res = await this.uploadFile(studyId, dataClip, requester, {}); - if ('code' in res && 'description' in res) { - error = `Field ${dataClip.fieldId}: Cannot parse as file.`; - break; - } else { - parsedValue = res.id; - } - break; - } - case 'cat': { - if (!fieldInDb.possibleValues) { - error = `Field ${dataClip.fieldId}: Cannot parse as categorical, possible values not defined.`; - break; - } - if (dataClip.value && !fieldInDb.possibleValues.map((el) => el.code).includes(dataClip.value?.toString())) { - error = `Field ${dataClip.fieldId}: Cannot parse as categorical, value not in value list.`; - break; - } else { - parsedValue = dataClip.value?.toString(); - } - break; - } - default: { - error = (`Field ${dataClip.fieldId}: Invalid data Type.`); - break; - } - } - } - if (error !== undefined) { - response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: error }); - continue; - } else { - response.push({ successful: true, description: `${dataClip.subjectId}-${dataClip.visitId}-${dataClip.fieldId}` }); - } - const obj = { - m_studyId: studyId, - m_versionId: null, - m_subjectId: dataClip.subjectId, - m_visitId: dataClip.visitId, - m_fieldId: dataClip.fieldId - }; - let objWithData: Partial>; - // update the file data differently - if (fieldInDb.dataType === 'file') { - const existing = await db.collections.data_collection.findOne(obj); - if (!existing) { - await db.collections.data_collection.insertOne({ - ...obj, - id: uuid(), - uploadedAt: (new Date()).valueOf(), - value: '', - metadata: { - add: [], - remove: [] - } - }); - } - - objWithData = { - ...obj, - id: uuid(), - value: '', - uploadedAt: (new Date()).valueOf(), - metadata: { - ...dataClip.metadata, - participantId: dataClip.subjectId, - add: (existing?.metadata?.add || []).concat(parsedValue), - uploader: requester.id - }, - uploadedBy: requester.id - }; - bulk.find(obj).updateOne({ $set: objWithData }); - } else { - objWithData = { - ...obj, - id: uuid(), - value: parsedValue, - uploadedAt: (new Date()).valueOf(), - metadata: { - ...dataClip.metadata, - uploader: requester.id - }, - uploadedBy: requester.id - }; - bulk.insert(objWithData); - } - if (bulk.batches.length > 999) { - await bulk.execute(); - bulk = db.collections.data_collection.initializeUnorderedBulkOp(); - } - } - bulk.batches.length !== 0 && await bulk.execute(); - return response; - } - - // This file uploading function will not check any metadate of the file - public async uploadFile(studyId: string, data: IDataClip, uploader: IUserWithoutToken, args: { fileLength?: number, fileHash?: string }): Promise { - if (!data.file || typeof (data.file) === 'string') { - return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'Invalid File Stream' }; - } - const study = await db.collections.studies_collection.findOne({ id: studyId }); - if (!study) { - return { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, description: 'Study does not exist.' }; - } - const sitesIDMarkers = (await db.collections.organisations_collection.find({ deleted: null }).toArray()).reduce<{ [key: string]: string | null }>((acc, curr) => { - if (curr.metadata?.siteIDMarker) { - acc[curr.metadata.siteIDMarker] = curr.shortname; - } - return acc; - }, {}); - // check file metadata - if (data.metadata) { - let parsedDescription: Record; - let startDate: number; - let endDate: number; - let deviceId: string; - let participantId: string; - try { - parsedDescription = data.metadata; - if (!parsedDescription['startDate'] || !parsedDescription['endDate'] || !parsedDescription['deviceId'] || !parsedDescription['participantId']) { - return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; - } - startDate = parseInt(parsedDescription['startDate'].toString()); - endDate = parseInt(parsedDescription['endDate'].toString()); - participantId = parsedDescription['participantId'].toString(); - deviceId = parsedDescription['deviceId'].toString(); - } catch (e) { - return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; - } - if ( - !Object.keys(sitesIDMarkers).includes(participantId.substr(0, 1)?.toUpperCase()) || - !Object.keys(deviceTypes).includes(deviceId.substr(0, 3)?.toUpperCase()) || - !validate(participantId.substr(1) ?? '') || - !validate(deviceId.substr(3) ?? '') || - !startDate || !endDate || - (new Date(endDate).setHours(0, 0, 0, 0).valueOf()) > (new Date().setHours(0, 0, 0, 0).valueOf()) - ) { - return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; - } - } else { - return { code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'File description is invalid' }; - } - - - const file: FileUpload = await data.file; - - // check if old files exist; if so, denote it as deleted - const dataEntry = await db.collections.data_collection.findOne({ m_studyId: studyId, m_visitId: data.visitId, m_subjectId: data.subjectId, m_versionId: null, m_fieldId: data.fieldId }); - const oldFileId = dataEntry ? dataEntry.value : null; - return new Promise((resolve, reject) => { - (async () => { - try { - const fileEntry: Partial = { - id: uuid(), - fileName: file.filename, - studyId: studyId, - description: JSON.stringify({}), - uploadTime: `${Date.now()}`, - uploadedBy: uploader.id, - deleted: null, - metadata: (data.metadata as Record) - }; - - if (args.fileLength !== undefined && args.fileLength > fileSizeLimit) { - reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - const stream = file.createReadStream(); - const fileUri = uuid(); - const hash = crypto.createHash('sha256'); - let readBytes = 0; - - stream.pause(); - - /* if the client cancelled the request mid-stream it will throw an error */ - stream.on('error', (e) => { - reject(new GraphQLError('Upload resolver file stream failure', { extensions: { code: errorCodes.FILE_STREAM_ERROR, error: e } })); - return; - }); - - stream.on('data', (chunk) => { - readBytes += chunk.length; - if (readBytes > fileSizeLimit) { - stream.destroy(); - reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - hash.update(chunk); - }); - - - await objStore.uploadFile(stream, studyId, fileUri); - - // hash is optional, but should be correct if provided - const hashString = hash.digest('hex'); - if (args.fileHash && args.fileHash !== hashString) { - reject(new GraphQLError('File hash not match', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - // check if readbytes equal to filelength in parameters - if (args.fileLength !== undefined && args.fileLength.toString() !== readBytes.toString()) { - reject(new GraphQLError('File size mismatch', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - fileEntry.fileSize = readBytes.toString(); - fileEntry.uri = fileUri; - fileEntry.hash = hashString; - const insertResult = await db.collections.files_collection.insertOne(fileEntry as IFile); - if (insertResult.acknowledged) { - // delete old file if existing - oldFileId && await db.collections.files_collection.findOneAndUpdate({ studyId: studyId, id: oldFileId }, { $set: { deleted: Date.now().valueOf() } }); - resolve(fileEntry as IFile); - } else { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - } - catch (error) { - reject({ code: errorCodes.CLIENT_MALFORMED_INPUT, description: 'Missing file metadata.', error }); - return; - } - })().catch(() => { return; }); - }); - } - - public async createProjectForStudy(studyId: string, projectName: string, requestedBy: string): Promise { - const project: IProject = { - id: uuid(), - studyId, - createdBy: requestedBy, - name: projectName, - patientMapping: {}, - lastModified: new Date().valueOf(), - deleted: null, - metadata: {} - }; - - const getListOfPatientsResult = await db.collections.data_collection.aggregate([ - { $match: { m_studyId: studyId } }, - { $group: { _id: null, array: { $addToSet: '$m_subjectId' } } }, - { $project: { array: 1 } } - ]).toArray(); - - if (getListOfPatientsResult === null || getListOfPatientsResult === undefined) { - throw new GraphQLError('Cannot get list of patients', { extensions: { code: errorCodes.DATABASE_ERROR } }); - } - - if (getListOfPatientsResult[0] !== undefined) { - project.patientMapping = this.createPatientIdMapping(getListOfPatientsResult[0]['array']); - } - - await db.collections.projects_collection.insertOne(project); - return project; - } - - public async deleteStudy(studyId: string): Promise { - /* PRECONDITION: CHECKED THAT STUDY INDEED EXISTS */ - const session = db.client.startSession(); - session.startTransaction(); - - const timestamp = new Date().valueOf(); - - try { - /* delete the study */ - await db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }); - - /* delete all projects related to the study */ - await db.collections.projects_collection.updateMany({ studyId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }); - - /* delete all roles related to the study */ - await this.localPermissionCore.removeRoleFromStudyOrProject({ studyId }); - - /* delete all files belong to the study*/ - await db.collections.files_collection.updateMany({ studyId, deleted: null }, { $set: { deleted: timestamp } }); - - await session.commitTransaction(); - session.endSession().catch(() => { return; }); - - } catch (error) { - // If an error occurred, abort the whole transaction and - // undo any changes that might have happened - await session.abortTransaction(); - session.endSession().catch(() => { return; }); - throw error; // Rethrow so calling function sees error - } - } - - public async deleteProject(projectId: string): Promise { - const timestamp = new Date().valueOf(); - - /* delete all projects related to the study */ - await db.collections.projects_collection.findOneAndUpdate({ id: projectId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }, { returnDocument: 'after' }); - - /* delete all roles related to the study */ - await this.localPermissionCore.removeRoleFromStudyOrProject({ projectId }); - } - - private createPatientIdMapping(listOfPatientId: string[], prefix?: string): { [originalPatientId: string]: string } { - let rangeArray: Array = [...Array.from(listOfPatientId.keys())]; - if (prefix === undefined) { - prefix = uuid().substring(0, 10); - } - rangeArray = rangeArray.map((e) => `${prefix}${e} `); - rangeArray = this.shuffle(rangeArray); - const mapping: { [originalPatientId: string]: string } = {}; - for (let i = 0, length = listOfPatientId.length; i < length; i++) { - mapping[listOfPatientId[i]] = (rangeArray as string[])[i]; - } - return mapping; - } - - private shuffle(array: Array) { // source: Fisher–Yates Shuffle; https://bost.ocks.org/mike/shuffle/ - let currentIndex = array.length; - let temporaryValue: string | number; - let randomIndex: number; - - // While there remain elements to shuffle... - while (0 !== currentIndex) { - - // Pick a remaining element... - randomIndex = Math.floor(Math.random() * currentIndex); - currentIndex -= 1; - - // And swap it with the current element. - temporaryValue = array[currentIndex]; - array[currentIndex] = array[randomIndex]; - array[randomIndex] = temporaryValue; - } - - return array; - } -} - -export const studyCore = Object.freeze(new StudyCore(permissionCore)); diff --git a/packages/itmat-interface/src/graphql/core/userCore.ts b/packages/itmat-interface/src/graphql/core/userCore.ts deleted file mode 100644 index 5dcca782a..000000000 --- a/packages/itmat-interface/src/graphql/core/userCore.ts +++ /dev/null @@ -1,129 +0,0 @@ -import bcrypt from 'bcrypt'; -import { db } from '../../database/database'; -import config from '../../utils/configManager'; -import { GraphQLError } from 'graphql'; -import { IUser, IUserWithoutToken, userTypes, IOrganisation, IPubkey } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { errorCodes } from '../errors'; -import { MarkOptional } from 'ts-essentials'; - -export class UserCore { - public async getOneUser_throwErrorIfNotExists(username: string): Promise { - const user = await db.collections.users_collection.findOne({ deleted: null, username }); - if (user === undefined || user === null) { - throw new GraphQLError('User does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - return user; - } - - public async createUser(user: { password: string, otpSecret: string, username: string, organisation: string, type: userTypes, description: string, firstname: string, lastname: string, email: string, emailNotificationsActivated: boolean, metadata }): Promise { - const { password, otpSecret, organisation, username, type, description, firstname, lastname, email, emailNotificationsActivated, metadata } = user; - const hashedPassword: string = await bcrypt.hash(password, config.bcrypt.saltround); - const createdAt = Date.now(); - const expiredAt = Date.now() + 86400 * 1000 /* millisec per day */ * 90; - const entry: IUser = { - id: uuid(), - username, - otpSecret, - type, - description, - organisation, - firstname, - lastname, - password: hashedPassword, - email, - emailNotificationsActivated, - emailNotificationsStatus: { expiringNotification: false }, - createdAt, - expiredAt, - resetPasswordRequests: [], - metadata, - deleted: null - }; - - const result = await db.collections.users_collection.insertOne(entry); - if (result.acknowledged) { - const cleared: MarkOptional = { ...entry }; - delete cleared['password']; - delete cleared['otpSecret']; - return cleared; - } else { - throw new GraphQLError('Database error', { extensions: { code: errorCodes.DATABASE_ERROR } }); - } - } - - public async deleteUser(userId: string): Promise { - const session = db.client.startSession(); - session.startTransaction(); - try { - /* delete the user */ - await db.collections.users_collection.findOneAndUpdate({ id: userId, deleted: null }, { $set: { deleted: new Date().valueOf(), password: 'DeletedUserDummyPassword' } }, { returnDocument: 'after', projection: { deleted: 1 } }); - - /* delete all user records in roles related to the study */ - await db.collections.roles_collection.updateMany( - { - deleted: null, - users: userId - }, - { - $pull: { users: { _id: userId } } - } - ); - - await session.commitTransaction(); - session.endSession().catch(() => { return; }); - } catch (error) { - // If an error occurred, abort the whole transaction and - // undo any changes that might have happened - await session.abortTransaction(); - session.endSession().catch(() => { return; }); - throw new GraphQLError(`Database error: ${JSON.stringify(error)}`); - } - } - - public async createOrganisation(org: { name: string, shortname: string | null, containOrg: string | null, metadata }): Promise { - const { name, shortname, containOrg, metadata } = org; - const entry: IOrganisation = { - id: uuid(), - name, - shortname, - containOrg, - deleted: null, - metadata: metadata?.siteIDMarker ? { - siteIDMarker: metadata.siteIDMarker - } : {} - }; - const result = await db.collections.organisations_collection.findOneAndUpdate({ name: name, deleted: null }, { - $set: entry - }, { - upsert: true - }); - if (result) { - return entry; - } else { - throw new GraphQLError('Database error', { extensions: { code: errorCodes.DATABASE_ERROR } }); - } - } - - public async registerPubkey(pubkeyobj: { pubkey: string, associatedUserId: string | null, jwtPubkey: string, jwtSeckey: string }): Promise { - const { pubkey, associatedUserId, jwtPubkey, jwtSeckey } = pubkeyobj; - const entry: IPubkey = { - id: uuid(), - pubkey, - associatedUserId, - jwtPubkey, - jwtSeckey, - refreshCounter: 0, - deleted: null - }; - - const result = await db.collections.pubkeys_collection.insertOne(entry); - if (result.acknowledged) { - return entry; - } else { - throw new GraphQLError('Database error', { extensions: { code: errorCodes.DATABASE_ERROR } }); - } - } -} - -export const userCore = Object.freeze(new UserCore()); diff --git a/packages/itmat-interface/src/graphql/pubsub.ts b/packages/itmat-interface/src/graphql/pubsub.ts index 92305d19b..d86d23f85 100644 --- a/packages/itmat-interface/src/graphql/pubsub.ts +++ b/packages/itmat-interface/src/graphql/pubsub.ts @@ -1,8 +1,3 @@ import { PubSub } from 'graphql-subscriptions'; export const pubsub = new PubSub(); - -export const subscriptionEvents = { - JOB_STATUS_CHANGE: 'JOB_STATUS_CHANGE', - NEW_JOB: 'NEW_JOB' -}; diff --git a/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts index b9006be91..62d22769a 100644 --- a/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts @@ -1,318 +1,21 @@ -import { GraphQLError } from 'graphql'; -import { IFile, IOrganisation, atomicOperation, IPermissionManagementOptions, IDataEntry } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; import { FileUpload } from 'graphql-upload-minimal'; +import { DMPResolversMap } from './context'; import { db } from '../../database/database'; +import { FileCore } from '@itmat-broker/itmat-cores'; import { objStore } from '../../objStore/objStore'; -import { permissionCore } from '../core/permissionCore'; -import { errorCodes } from '../errors'; -import { makeGenericReponse } from '../responses'; -import crypto from 'crypto'; -import { validate } from '@ideafast/idgen'; -import { deviceTypes } from '@itmat-broker/itmat-types'; -import { fileSizeLimit } from '../../utils/definition'; -import type { MatchKeysAndValues } from 'mongodb'; -import { studyCore } from '../core/studyCore'; -import { DMPResolversMap } from './context'; -// default visitId for file data -const targetVisitId = '0'; +const fileCore = Object.freeze(new FileCore(db, objStore)); + export const fileResolvers: DMPResolversMap = { Query: { }, Mutation: { // this API has the same functions as uploading file data via clinical APIs - uploadFile: async (parent, args: { fileLength?: bigint, studyId: string, file: Promise, description: string, hash?: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - // get the target fieldId of this file - const study = await studyCore.findOneStudy_throwErrorIfNotExist(args.studyId); - - const hasStudyLevelSubjectPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - args.studyId - ); - const hasStudyLevelStudyDataPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.WRITE, - requester, - args.studyId - ); - if (!hasStudyLevelSubjectPermission && !hasStudyLevelStudyDataPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - let targetFieldId: string; - let isStudyLevel = false; - // obtain sitesIDMarker from db - const sitesIDMarkers = (await db.collections.organisations_collection.find({ deleted: null }).toArray()).reduce>((acc, curr) => { - if (curr.metadata?.siteIDMarker) { - acc[curr.metadata.siteIDMarker] = curr.shortname; - } - return acc; - }, {}); - // if the description object is empty, then the file is study-level data - // otherwise, a subjectId must be provided in the description object - // we will check other properties in the decription object (deviceId, startDate, endDate) - const parsedDescription = JSON.parse(args.description); - if (!parsedDescription) { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - if (!parsedDescription.participantId) { - isStudyLevel = true; - } else { - isStudyLevel = false; - if (!Object.keys(sitesIDMarkers).includes(parsedDescription.participantId?.substr(0, 1)?.toUpperCase())) { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - // check deviceId, startDate, endDate if necessary - if (parsedDescription.deviceId && parsedDescription.startDate && parsedDescription.endDate) { - if (!Object.keys(deviceTypes).includes(parsedDescription.deviceId?.substr(0, 3)?.toUpperCase()) || - !validate(parsedDescription.participantId?.substr(1) ?? '') || - !validate(parsedDescription.deviceId.substr(3) ?? '')) { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - } else { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - // if the targetFieldId is in the description object; then use the fieldId, otherwise, infer it from the device types - if (parsedDescription.fieldId) { - targetFieldId = parsedDescription.fieldId; - } else { - const device = parsedDescription.deviceId?.slice(0, 3); - targetFieldId = `Device_${deviceTypes[device].replace(/ /g, '_')}`; - } - // check fieldId exists - if ((await db.collections.field_dictionary_collection.find({ studyId: study.id, fieldId: targetFieldId, dateDeleted: null }).sort({ dateAdded: -1 }).limit(1).toArray()).length === 0) { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - // check field permission - if (!permissionCore.checkDataEntryValid(await permissionCore.combineUserDataPermissions(atomicOperation.WRITE, requester, args.studyId, undefined), targetFieldId, parsedDescription.participantId, targetVisitId)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - } - - const file = await args.file; - const fileNameParts = file.filename.split('.'); - - return new Promise((resolve, reject) => { - (async () => { - try { - let fileName: string = file.filename; - let metadata: Record = {}; - if (!isStudyLevel) { - const matcher = /(.{1})(.{6})-(.{3})(.{6})-(\d{8})-(\d{8})\.(.*)/; - let startDate; - let endDate; - let participantId; - let deviceId; - // check description first, then filename - if (args.description) { - const parsedDescription = JSON.parse(args.description); - startDate = parseInt(parsedDescription.startDate); - endDate = parseInt(parsedDescription.endDate); - participantId = parsedDescription.participantId.toString(); - deviceId = parsedDescription.deviceId.toString(); - } else if (matcher.test(file.filename)) { - const particles = file.filename.split('-'); - participantId = particles[0]; - deviceId = particles[1]; - startDate = particles[2]; - endDate = particles[3]; - } else { - reject(new GraphQLError('Missing file description', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - - try { - if ( - !Object.keys(sitesIDMarkers).includes(participantId.substr(0, 1)?.toUpperCase()) || - !Object.keys(deviceTypes).includes(deviceId.substr(0, 3)?.toUpperCase()) || - !validate(participantId.substr(1) ?? '') || - !validate(deviceId.substr(3) ?? '') || - !startDate || !endDate || - (new Date(endDate).setHours(0, 0, 0, 0).valueOf()) > (new Date().setHours(0, 0, 0, 0).valueOf()) - ) { - reject(new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - } catch (e) { - reject(new GraphQLError('Missing file description', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - const typedStartDate = new Date(startDate); - const formattedStartDate = typedStartDate.getFullYear() + `${typedStartDate.getMonth() + 1}`.padStart(2, '0') + `${typedStartDate.getDate()}`.padStart(2, '0'); - const typedEndDate = new Date(endDate); - const formattedEndDate = typedEndDate.getFullYear() + `${typedEndDate.getMonth() + 1}`.padStart(2, '0') + `${typedEndDate.getDate()}`.padStart(2, '0'); - fileName = `${parsedDescription.participantId.toUpperCase()}-${parsedDescription.deviceId.toUpperCase()}-${formattedStartDate}-${formattedEndDate}.${fileNameParts[fileNameParts.length - 1]}`; - metadata = { - participantId: parsedDescription.participantId, - deviceId: parsedDescription.deviceId, - startDate: parsedDescription.startDate, // should be in milliseconds - endDate: parsedDescription.endDate, - tup: parsedDescription.tup - }; - } - - if (args.fileLength !== undefined && args.fileLength > fileSizeLimit) { - reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - const stream = file.createReadStream(); - const fileUri = uuid(); - const hash = crypto.createHash('sha256'); - let readBytes = 0; - - stream.pause(); - - /* if the client cancelled the request mid-stream it will throw an error */ - stream.on('error', (e) => { - reject(new GraphQLError('Upload resolver file stream failure', { extensions: { code: errorCodes.FILE_STREAM_ERROR, error: e } })); - return; - }); - - stream.on('data', (chunk) => { - readBytes += chunk.length; - if (readBytes > fileSizeLimit) { - stream.destroy(); - reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - hash.update(chunk); - }); - - await objStore.uploadFile(stream, args.studyId, fileUri); - - const hashString = hash.digest('hex'); - if (args.hash && args.hash !== hashString) { - reject(new GraphQLError('File hash not match', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - - // check if readbytes equal to filelength in parameters - if (args.fileLength !== undefined && args.fileLength.toString() !== readBytes.toString()) { - reject(new GraphQLError('File size mismatch', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); - return; - } - const fileEntry: IFile = { - id: uuid(), - fileName: fileName, - studyId: args.studyId, - description: args.description, - uploadTime: `${Date.now()}`, - uploadedBy: requester.id, - deleted: null, - metadata: metadata, - fileSize: readBytes.toString(), - uri: fileUri, - hash: hashString - }; - fileEntry.fileSize = readBytes.toString(); - fileEntry.uri = fileUri; - fileEntry.hash = hashString; - if (!isStudyLevel) { - await db.collections.data_collection.insertOne({ - id: uuid(), - m_studyId: args.studyId, - m_subjectId: parsedDescription.participantId, - m_versionId: null, - m_visitId: targetVisitId, - m_fieldId: targetFieldId, - value: '', - uploadedAt: (new Date()).valueOf(), - metadata: { - 'uploader:user': requester.id, - 'add': [fileEntry.id], - 'remove': [] - } - }); - } - const insertResult = await db.collections.files_collection.insertOne(fileEntry); - if (insertResult.acknowledged) { - resolve(fileEntry); - } else { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - - } catch (error) { - reject(new GraphQLError('General upload error', { extensions: { code: errorCodes.UNQUALIFIED_ERROR, error } })); - } - })().catch((e) => reject(e)); - }); + uploadFile: async (_parent, args: { fileLength?: bigint, studyId: string, file: Promise, description: string, hash?: string }, context) => { + return await fileCore.uploadFile(context.req.user, args.studyId, args.file, args.description, args.hash, args.fileLength); }, - deleteFile: async (parent, args: { fileId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const file = await db.collections.files_collection.findOne({ deleted: null, id: args.fileId }); - - if (!file) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - file.studyId - ); - if (!hasStudyLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const parsedDescription = JSON.parse(file.description); - if (Object.keys(parsedDescription).length === 0) { - await db.collections.files_collection.findOneAndUpdate({ deleted: null, id: args.fileId }, { $set: { deleted: Date.now().valueOf() } }); - return makeGenericReponse(); - } - const device = parsedDescription.deviceId.slice(0, 3); - if (!Object.keys(deviceTypes).includes(device)) { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - const targetFieldId = `Device_${(deviceTypes[device] as string).replace(/ /g, '_')}`; - if (!permissionCore.checkDataEntryValid(hasStudyLevelPermission.raw, targetFieldId, parsedDescription.participantId, targetVisitId)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - // update data record - const obj = { - m_studyId: file.studyId, - m_subjectId: parsedDescription.participantId, - m_versionId: null, - m_visitId: targetVisitId, - m_fieldId: targetFieldId - }; - const existing = await db.collections.data_collection.findOne(obj); - if (!existing) { - await db.collections.data_collection.insertOne({ - ...obj, - id: uuid(), - uploadedAt: (new Date()).valueOf(), - value: '', - metadata: { - add: [], - remove: [] - } - }); - } - const objWithData: Partial> = { - ...obj, - id: uuid(), - value: '', - uploadedAt: (new Date()).valueOf(), - metadata: { - 'uploader:user': requester.id, - 'add': existing?.metadata?.add ?? [], - 'remove': (existing?.metadata?.remove || []).concat(args.fileId) - } - }; - const updateResult = await db.collections.data_collection.updateOne(obj, { $set: objWithData }, { upsert: true }); - - // const updateResult = await db.collections.files_collection.updateOne({ deleted: null, id: args.fileId }, { $set: { deleted: new Date().valueOf() } }); - if (updateResult.modifiedCount === 1 || updateResult.upsertedCount === 1) { - return makeGenericReponse(); - } else { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } + deleteFile: async (_parent, args: { fileId: string }, context) => { + return await fileCore.deleteFile(context.req.user, args.fileId); } }, Subscription: {} diff --git a/packages/itmat-interface/src/graphql/resolvers/index.ts b/packages/itmat-interface/src/graphql/resolvers/index.ts index b215912e8..a48fcdacc 100644 --- a/packages/itmat-interface/src/graphql/resolvers/index.ts +++ b/packages/itmat-interface/src/graphql/resolvers/index.ts @@ -7,11 +7,11 @@ import { userResolvers } from './userResolvers'; import { organisationResolvers } from './organisationResolvers'; import { pubkeyResolvers } from './pubkeyResolvers'; import { GraphQLError } from 'graphql'; -import { errorCodes } from '../errors'; import { logResolvers } from './logResolvers'; import { standardizationResolvers } from './standardizationResolvers'; import { IResolvers } from '@graphql-tools/utils'; import { DMPResolver } from './context'; +import { errorCodes } from '@itmat-broker/itmat-cores'; const modules = [ studyResolvers, diff --git a/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts index 6fcddb22a..933da1f88 100644 --- a/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts @@ -1,84 +1,17 @@ -import { GraphQLError } from 'graphql'; import { withFilter } from 'graphql-subscriptions'; -import { IJobEntryForQueryCuration, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { db } from '../../database/database'; -import { errorCodes } from '../errors'; -import { pubsub, subscriptionEvents } from '../pubsub'; -import { permissionCore } from '../core/permissionCore'; -import { studyCore } from '../core/studyCore'; +import { pubsub } from '../pubsub'; import { DMPResolversMap } from './context'; +import { db } from '../../database/database'; +import { JobCore, subscriptionEvents } from '@itmat-broker/itmat-cores'; +import { objStore } from '../../objStore/objStore'; -enum JOB_TYPE { - FIELD_INFO_UPLOAD = 'FIELD_INFO_UPLOAD', - DATA_UPLOAD_CSV = 'DATA_UPLOAD_CSV', - DATA_UPLOAD_JSON = 'DATA_UPLOAD_JSON', - QUERY_EXECUTION = 'QUERY_EXECUTION', - DATA_EXPORT = 'DATA_EXPORT' -} +const jobCore = Object.freeze(new JobCore(db, objStore)); export const jobResolvers: DMPResolversMap = { Query: {}, Mutation: { - createQueryCurationJob: async (parent, args: { queryId: string[], studyId: string, projectId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check permission */ - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.job, - atomicOperation.WRITE, - requester, - args.studyId - ); - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.job, - atomicOperation.WRITE, - requester, - args.studyId, - args.projectId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - /* check study exists */ - await studyCore.findOneStudy_throwErrorIfNotExist(args.studyId); - - /* check if project exists */ - const projectExist = await db.collections.projects_collection.findOne({ id: args.projectId }); - if (!projectExist) { - throw new GraphQLError('Project does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - - /* check if the query exists */ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const queryExist = await db.collections.queries_collection.findOne({ id: args.queryId[0] }); - if (!queryExist) { - throw new GraphQLError('Query does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - - const job: IJobEntryForQueryCuration = { - id: uuid(), - jobType: JOB_TYPE.QUERY_EXECUTION, - studyId: args.studyId, - requester: requester.id, - requestTime: new Date().valueOf(), - receivedFiles: [], - error: null, - status: 'QUEUED', - cancelled: false, - data: { - queryId: args.queryId, - projectId: args.projectId, - studyId: args.studyId - } - }; - const result = await db.collections.jobs_collection.insertOne(job); - if (!result.acknowledged) { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - return job; + createQueryCurationJob: async (_parent, { queryId, studyId, projectId }: { queryId: string[], studyId: string, projectId: string }, context) => { + return await jobCore.createQueryCurationJob(context.req.user, queryId, studyId, projectId); } }, Subscription: { diff --git a/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts index 47603877a..2fbe85ea0 100644 --- a/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts @@ -1,61 +1,17 @@ -import { db } from '../../database/database'; -import { ILogEntry, LOG_ACTION, LOG_STATUS, LOG_TYPE, userTypes } from '@itmat-broker/itmat-types'; -import { GraphQLError } from 'graphql'; -import { errorCodes } from '../errors'; +import { LOG_ACTION, LOG_STATUS, LOG_TYPE, userTypes } from '@itmat-broker/itmat-types'; import { DMPResolversMap } from './context'; -import { Filter } from 'mongodb'; +import { db } from '../../database/database'; +import { LogCore } from '@itmat-broker/itmat-cores'; + +const logCore = Object.freeze(new LogCore(db)); export const logResolvers: DMPResolversMap = { Query: { - getLogs: async (parent, args: { requesterName: string, requesterType: userTypes, logType: LOG_TYPE, actionType: LOG_ACTION, status: LOG_STATUS }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* only admin can access this field */ - if (!(requester.type === userTypes.ADMIN) && !(requester.metadata?.logPermission === true)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - const queryObj: Filter = {}; - for (const prop in args) { - if (args[prop] !== undefined) { - queryObj[prop] = args[prop]; - } - } - const logData = await db.collections.log_collection.find(queryObj, { projection: { _id: 0 } }).limit(1000).sort('time', -1).toArray(); - // log information decoration - for (const i in logData) { - logData[i].actionData = JSON.stringify(await logDecorationHelper(logData[i].actionData, logData[i].actionType)); - } - - return logData; + getLogs: async (_parent, args: { requesterName: string, requesterType: userTypes, logType: LOG_TYPE, actionType: LOG_ACTION, status: LOG_STATUS }, context) => { + return await logCore.getLogs(context.req.user, args.requesterName, args.requesterType, args.logType, args.actionType, args.status); } } }; -// fields that carry sensitive information will be hidden -export const hiddenFields = { - LOGIN_USER: ['password', 'totp'], - UPLOAD_FILE: ['file', 'description'] -}; -async function logDecorationHelper(actionData: string, actionType: string) { - const obj = JSON.parse(actionData) ?? {}; - if (Object.keys(hiddenFields).includes(actionType)) { - for (let i = 0; i < hiddenFields[actionType as keyof typeof hiddenFields].length; i++) { - delete obj[hiddenFields[actionType as keyof typeof hiddenFields][i]]; - } - } - if (actionType === LOG_ACTION.getStudy) { - const studyId = obj['studyId']; - const study = await db.collections.studies_collection.findOne({ id: studyId, deleted: null }); - if (study === null || study === undefined) { - obj['name'] = ''; - } - else { - obj['name'] = study.name; - } - } - return obj; -} + diff --git a/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts index 1b8525376..9bfa5bbf9 100644 --- a/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts @@ -1,16 +1,23 @@ -import { IOrganisation } from '@itmat-broker/itmat-types'; -import { db } from '../../database/database'; import { DMPResolversMap } from './context'; +import { db } from '../../database/database'; +import { OrganisationCore } from '@itmat-broker/itmat-cores'; + +const organisationCore = Object.freeze(new OrganisationCore(db)); export const organisationResolvers: DMPResolversMap = { Query: { - getOrganisations: async (parent, args: { organisationId?: string }) => { + getOrganisations: async (_parent, args: { organisationId?: string }) => { // everyone is allowed to see all organisations in the app. - const queryObj = args.organisationId === undefined ? { deleted: null } : { deleted: null, id: args.organisationId }; - const cursor = db.collections.organisations_collection.find(queryObj, { projection: { _id: 0 } }); - return cursor.toArray(); + return await organisationCore.getOrganisations(args.organisationId); + } + }, + Mutation: { + createOrganisation: async (parent, { name, shortname, containOrg, metadata }: { name: string, shortname: string, containOrg: string, metadata: unknown }, context) => { + return await organisationCore.createOrganisation(context.req.user, { name, shortname, containOrg, metadata }); + }, + deleteOrganisation: async (parent, { id }: { id: string }, context) => { + return await organisationCore.deleteOrganisation(context.req.user, id); } }, - Mutation: {}, Subscription: {} }; diff --git a/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts index f1fe4ea07..6b47ae845 100644 --- a/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts @@ -1,179 +1,33 @@ -import { GraphQLError } from 'graphql'; -import { IRole, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; -import { db } from '../../database/database'; -import { permissionCore } from '../core/permissionCore'; -import { studyCore } from '../core/studyCore'; -import { errorCodes } from '../errors'; -import { makeGenericReponse } from '../responses'; +import { IRole, IDataPermission, IManagementPermission } from '@itmat-broker/itmat-types'; import { DMPResolversMap } from './context'; -import { Filter } from 'mongodb'; +import { db } from '../../database/database'; +import { PermissionCore } from '@itmat-broker/itmat-cores'; + +const permissionCore = Object.freeze(new PermissionCore(db)); export const permissionResolvers: DMPResolversMap = { Query: { - getGrantedPermissions: async (parent, { studyId, projectId }: { studyId?: string, projectId?: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const matchClause: Filter = { users: requester.id }; - if (studyId) - matchClause.studyId = studyId; - if (projectId) - matchClause.projectId = { $in: [projectId, undefined] }; - const aggregationPipeline = [ - { $match: matchClause } - // { $group: { _id: requester.id, arrArrPrivileges: { $addToSet: '$permissions' } } }, - // { $project: { arrPrivileges: { $reduce: { input: '$arrArrPrivileges', initialValue: [], in: { $setUnion: ['$$this', '$$value'] } } } } } - ]; - - const grantedPermissions = { - studies: await db.collections.roles_collection.aggregate(aggregationPipeline).toArray(), - projects: await db.collections.roles_collection.aggregate(aggregationPipeline).toArray() - }; - return grantedPermissions; + getGrantedPermissions: async (_parent, { studyId, projectId }: { studyId?: string, projectId?: string }, context) => { + return await permissionCore.getGrantedPermissions(context.req.user, studyId, projectId); } }, StudyOrProjectUserRole: { users: async (role: IRole) => { - const listOfUsers = role.users; - return await (db.collections.users_collection.find({ id: { $in: listOfUsers } }, { projection: { _id: 0, password: 0, email: 0 } }).toArray()); + return permissionCore.getUsersOfRole(role); } }, Mutation: { - addRole: async (parent, args: { studyId: string, projectId?: string, roleName: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const { studyId, projectId, roleName } = args; - - /* check whether user has at least provided one id */ - if (studyId === undefined && projectId === undefined) { - throw new GraphQLError('Please provide either study id or project id.', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - - /* check the requester has privilege */ - const hasPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.role, - atomicOperation.WRITE, - requester, - args.studyId, - args.projectId - ); - if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - /* check whether the target study or project exists */ - if (studyId && projectId) { // if both study id and project id are provided then just make sure they belong to each other - const result = await studyCore.findOneProject_throwErrorIfNotExist(projectId); - if (result.studyId !== studyId) { - throw new GraphQLError('The project provided does not belong to the study provided', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - } else if (studyId) { // if only study id is provided - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - } else if (projectId) { - await studyCore.findOneProject_throwErrorIfNotExist(projectId); - } - - const result = await permissionCore.addRole({ createdBy: requester.id, studyId, projectId, roleName }); - return result; + addRole: async (_parent, args: { studyId: string, projectId?: string, roleName: string }, context) => { + return await permissionCore.addRole(context.req.user, args.studyId, args.projectId, args.roleName); }, - editRole: async (parent, args: { roleId: string, name?: string, description?: string, userChanges?: { add: string[], remove: string[] }, permissionChanges }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const { roleId, name, permissionChanges, userChanges } = args; - - const role = await db.collections.roles_collection.findOne({ id: roleId, deleted: null }); - if (role === null) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* check the requester has privilege */ - const hasPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.role, - atomicOperation.WRITE, - requester, - role.studyId, - role.projectId - ); - if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - /* check whether all the permissions are valid in terms of regular expressions */ - if (permissionChanges) { - if (permissionChanges.data) { - if (permissionChanges.data.subjectIds) { - for (const subjectId of permissionChanges.data.subjectIds) { - checkReExpIsValid(subjectId); - } - } - if (permissionChanges.data.visitIds) { - for (const visitId of permissionChanges.data.visitIds) { - checkReExpIsValid(visitId); - } - } - if (permissionChanges.data.fieldIds) { - for (const fieldId of permissionChanges.data.fieldIds) { - checkReExpIsValid(fieldId); - } - } - } - } - - /* check whether all the users exists */ - if (userChanges) { - const allRequestedUserChanges: string[] = [...userChanges.add, ...userChanges.remove]; - const testedUser: string[] = []; - for (const each of allRequestedUserChanges) { - if (!testedUser.includes(each)) { - const user = await db.collections.users_collection.findOne({ id: each, deleted: null }); - if (user === null) { - throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); - } else { - testedUser.push(each); - } - } - } - } - - /* edit the role */ - const modifiedRole = await permissionCore.editRoleFromStudyOrProject(roleId, name, args.description, permissionChanges, userChanges); - return modifiedRole; + editRole: async (_parent, args: { roleId: string, name?: string, description?: string, userChanges?: { add: string[], remove: string[] }, permissionChanges?: { data?: IDataPermission, manage?: IManagementPermission } }, context) => { + return await permissionCore.editRole(context.req.user, args.roleId, args.name, args.description, args.permissionChanges, args.userChanges); }, - removeRole: async (parent, args: { roleId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const { roleId } = args; - - const role = await db.collections.roles_collection.findOne({ id: roleId, deleted: null }); - if (role === null) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* check permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.role, - atomicOperation.WRITE, - requester, - role.studyId, - role.projectId - ); - if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - /* remove the role */ - await permissionCore.removeRole(roleId); - return makeGenericReponse(roleId); + removeRole: async (_parent, args: { roleId: string }, context) => { + return await permissionCore.removeRole(context.req.user, args.roleId); } }, Subscription: {} }; -function checkReExpIsValid(pattern: string) { - try { - new RegExp(pattern); - } catch { - throw new GraphQLError(`${pattern} is not a valid regular expression.`, { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } -} + diff --git a/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts index fca6fe267..67179fda5 100644 --- a/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts @@ -1,204 +1,33 @@ -import { ApolloServerErrorCode } from '@apollo/server/errors'; -import { GraphQLError } from 'graphql'; -import { mailer } from '../../emailer/emailer'; -import { IPubkey } from '@itmat-broker/itmat-types'; -//import { v4 as uuid } from 'uuid'; +import { DMPResolversMap } from './context'; import { db } from '../../database/database'; +import { PubkeyCore } from '@itmat-broker/itmat-cores'; import config from '../../utils/configManager'; -import { userCore } from '../core/userCore'; -import { errorCodes } from '../errors'; -//import { makeGenericReponse, IGenericResponse } from '../responses'; -import * as pubkeycrypto from '../../utils/pubkeycrypto'; -import { DMPResolversMap } from './context'; +import { mailer } from '../../emailer/emailer'; + +const pubkeyCore = Object.freeze(new PubkeyCore(db, mailer, config)); export const pubkeyResolvers: DMPResolversMap = { Query: { - getPubkeys: async (parent, args: { pubkeyId?: string, associatedUserId?: string }) => { - // a user is allowed to obtain his/her registered public key. - let queryObj; - if (args.pubkeyId === undefined) { - if (args.associatedUserId === undefined) { - queryObj = { deleted: null }; - } else { - queryObj = { deleted: null, associatedUserId: args.associatedUserId }; - } - } else { - queryObj = { deleted: null, id: args.pubkeyId }; - } - const cursor = db.collections.pubkeys_collection.find(queryObj, { projection: { _id: 0 } }); - return cursor.toArray(); + getPubkeys: async (_parent, args: { pubkeyId?: string, associatedUserId?: string }) => { + return pubkeyCore.getPubkeys(args.pubkeyId, args.associatedUserId); } }, Mutation: { keyPairGenwSignature: async () => { - // Generate RSA key-pair with Signature for robot user - const keyPair = pubkeycrypto.rsakeygen(); - //default message = hash of the public key (SHA256) - const messageToBeSigned = pubkeycrypto.hashdigest(keyPair.publicKey); - const signature = pubkeycrypto.rsasigner(keyPair.privateKey, messageToBeSigned); - - return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, signature: signature }; + return await pubkeyCore.keyPairGenwSignature(); }, - rsaSigner: async (parent, { privateKey, message }: { privateKey: string, message: string }) => { - let messageToBeSigned; - privateKey = privateKey.replace(/\\n/g, '\n'); - if (message === undefined) { - //default message = hash of the public key (SHA256) - try { - const reGenPubkey = pubkeycrypto.reGenPkfromSk(privateKey); - messageToBeSigned = pubkeycrypto.hashdigest(reGenPubkey); - } catch (error) { - throw new GraphQLError('Error: private-key incorrect!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); - } - - } else { - messageToBeSigned = message; - } - const signature = pubkeycrypto.rsasigner(privateKey, messageToBeSigned); - return { signature: signature }; + rsaSigner: async (_parent, { privateKey, message }: { privateKey: string, message: string }) => { + return pubkeyCore.rsaSigner(privateKey, message); }, - issueAccessToken: async (parent, { pubkey, signature }: { pubkey: string, signature: string }) => { - // refine the public-key parameter from browser - pubkey = pubkey.replace(/\\n/g, '\n'); - - /* Validate the signature with the public key */ - if (!await pubkeycrypto.rsaverifier(pubkey, signature)) { - throw new GraphQLError('Signature vs Public key mismatched.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - const pubkeyrec = await db.collections.pubkeys_collection.findOne({ pubkey, deleted: null }); - if (pubkeyrec === null || pubkeyrec === undefined) { - throw new GraphQLError('This public-key has not been registered yet!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - // payload of the JWT for storing user information - const payload = { - publicKey: pubkeyrec.jwtPubkey, - associatedUserId: pubkeyrec.associatedUserId, - refreshCounter: pubkeyrec.refreshCounter, - Issuer: 'IDEA-FAST DMP' - }; - - // update the counter - const fieldsToUpdate = { - refreshCounter: (pubkeyrec.refreshCounter + 1) - }; - const updateResult = await db.collections.pubkeys_collection.findOneAndUpdate({ pubkey, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); - if (updateResult === null) { - throw new GraphQLError('Server error; cannot fulfil the JWT request.'); - } - // return the acccess token - const accessToken = { - accessToken: pubkeycrypto.tokengen(payload, pubkeyrec.jwtSeckey) - }; - - return accessToken; + issueAccessToken: async (_parent, { pubkey, signature }: { pubkey: string, signature: string }) => { + return pubkeyCore.issueAccessToken(pubkey, signature); }, - registerPubkey: async (parent, { pubkey, signature, associatedUserId }: { pubkey: string, signature: string, associatedUserId: string }, context) => { - // refine the public-key parameter from browser - pubkey = pubkey.replace(/\\n/g, '\n'); - const alreadyExist = await db.collections.pubkeys_collection.findOne({ pubkey, deleted: null }); - if (alreadyExist !== null && alreadyExist !== undefined) { - throw new GraphQLError('This public-key has already been registered.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* Check whether requester is the same as the associated user*/ - if (associatedUserId && (requester.id !== associatedUserId)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* Validate the signature with the public key */ - try { - const signature_verifier = await pubkeycrypto.rsaverifier(pubkey, signature); - if (!signature_verifier) { - throw new GraphQLError('Signature vs Public-key mismatched.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - } catch (error) { - throw new GraphQLError('Error: Signature or Public-key is incorrect.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - /* Generate a public key-pair for generating and authenticating JWT access token later */ - const keypair = pubkeycrypto.rsakeygen(); - - /* Update the new public key if the user is already associated with another public key*/ - if (associatedUserId) { - const alreadyRegistered = await db.collections.pubkeys_collection.findOne({ associatedUserId, deleted: null }); - if (alreadyRegistered !== null && alreadyRegistered !== undefined) { - //updating the new public key. - const fieldsToUpdate = { - pubkey, - jwtPubkey: keypair.publicKey, - jwtSeckey: keypair.privateKey - }; - const updateResult = await db.collections.pubkeys_collection.findOneAndUpdate({ associatedUserId, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); - if (updateResult) { - await mailer.sendMail({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to: requester.email, - subject: `[${config.appName}] New public-key has sucessfully registered!`, - html: ` -

- Dear ${requester.firstname}, -

-

- Your new public-key "${pubkey}" on ${config.appName} has successfully registered !
- The old one is already wiped out! - You will need to keep your new private key secretly.
- You will also need to sign a message (using this new public-key) to authenticate the owner of the public key.
-

- -
-

- The ${config.appName} Team. -

- ` - }); - - return updateResult; - } else { - throw new GraphQLError('Server error; no entry or more than one entry has been updated.'); - } - } - } - - /* Register new public key (either associated with an user or not) */ - const registeredPubkey = await userCore.registerPubkey({ - pubkey, - jwtPubkey: keypair.publicKey, - jwtSeckey: keypair.privateKey, - associatedUserId: associatedUserId ?? null - }); - - await mailer.sendMail({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to: requester.email, - subject: `[${config.appName}] Public-key Registration!`, - html: ` -

- Dear ${requester.firstname}, -

-

- You have successfully registered your public-key "${pubkey}" on ${config.appName}!
- You will need to keep your private key secretly.
- You will also need to sign a message (using your public-key) to authenticate the owner of the public key.
-

- -
-

- The ${config.appName} Team. -

- ` - }); - - return registeredPubkey; + registerPubkey: async (_parent, { pubkey, signature, associatedUserId }: { pubkey: string, signature: string, associatedUserId: string }, context) => { + return await pubkeyCore.registerPubkey(context.req.user, pubkey, signature, associatedUserId); } }, diff --git a/packages/itmat-interface/src/graphql/resolvers/queryResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/queryResolvers.ts index dea8183a8..e234b04e9 100644 --- a/packages/itmat-interface/src/graphql/resolvers/queryResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/queryResolvers.ts @@ -1,88 +1,21 @@ -import { IProject, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; -import { queryCore } from '../core/queryCore'; -import { permissionCore } from '../core/permissionCore'; -import { GraphQLError } from 'graphql'; -import { errorCodes } from '../errors'; -import { db } from '../../database/database'; import { DMPResolversMap } from './context'; +import { db } from '../../database/database'; +import { QueryCore } from '@itmat-broker/itmat-cores'; + +const queryCore = Object.freeze(new QueryCore(db)); export const queryResolvers: DMPResolversMap = { Query: { - getQueryById: async (parent, args: { queryId: string }, context) => { - const queryId = args.queryId; - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check query exists */ - const queryEntry = await db.collections.queries_collection.findOne({ id: queryId }, { projection: { _id: 0, claimedBy: 0 } }); - if (queryEntry === null || queryEntry === undefined) { - throw new GraphQLError('Query does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - /* check permission */ - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.query, - atomicOperation.READ, - requester, - queryEntry.studyId, - queryEntry.projectId - ); - - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.query, - atomicOperation.READ, - requester, - queryEntry.studyId - ); - if (!hasProjectLevelPermission && !hasStudyLevelPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - return queryEntry; + getQueryById: async (_parent, args: { queryId: string }, context) => { + return await queryCore.getQueryByIdparent(context.req.user, args.queryId); }, - getQueries: async (parent, args: { studyId: string, projectId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check permission */ - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.query, - atomicOperation.READ, - requester, - args.studyId, - args.projectId - ); - - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.query, - atomicOperation.READ, - requester, - args.studyId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const entries = await db.collections.queries_collection.find({ studyId: args.studyId, projectId: args.projectId }).toArray(); - return entries; + getQueries: async (_parent, args: { studyId: string, projectId: string }, context) => { + return queryCore.getQueries(context.req.user, args.studyId, args.projectId); } }, Mutation: { - createQuery: async (parent, args: { query: { userId: string, queryString, studyId: string, projectId?: string } }) => { - /* check study exists */ - const studySearchResult = await db.collections.studies_collection.findOne({ id: args.query.studyId, deleted: null }); - if (studySearchResult === null || studySearchResult === undefined) { - throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - /* check project exists */ - const project = await db.collections.projects_collection.findOne>({ id: args.query.projectId, deleted: null }, { projection: { patientMapping: 0 } }); - if (project === null) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check project matches study */ - if (studySearchResult.id !== project.studyId) { - throw new GraphQLError('Study and project mismatch.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - - const entry = await queryCore.createQuery(args); - return entry; + createQuery: async (_parent, args: { query: { userId: string, queryString, studyId: string, projectId?: string } }) => { + return queryCore.createQuery(args.query.userId, args.query.queryString, args.query.studyId, args.query.projectId); } }, Subscription: {} diff --git a/packages/itmat-interface/src/graphql/resolvers/standardizationResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/standardizationResolvers.ts index 112a2854e..3d71e53b1 100644 --- a/packages/itmat-interface/src/graphql/resolvers/standardizationResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/standardizationResolvers.ts @@ -1,174 +1,23 @@ -import { IProject, IStandardization, atomicOperation } from '@itmat-broker/itmat-types'; -import { permissionCore } from '../core/permissionCore'; -import { studyCore } from '../core/studyCore'; -import { GraphQLError } from 'graphql'; -import { errorCodes } from '../errors'; -import { db } from '../../database/database'; -import { v4 as uuid } from 'uuid'; -import { makeGenericReponse } from '../responses'; import { DMPResolversMap } from './context'; +import { db } from '../../database/database'; +import { StandarizationCore } from '@itmat-broker/itmat-cores'; +import { objStore } from '../../objStore/objStore'; + +const standardizationCore = Object.freeze(new StandarizationCore(db, objStore)); export const standardizationResolvers: DMPResolversMap = { Query: { - getStandardization: async (parent, { studyId, projectId, type, versionId }: { studyId: string, projectId: string, type?: string, versionId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - let modifiedStudyId = studyId; - /* check study exists */ - if (!studyId && !projectId) { - throw new GraphQLError('Either studyId or projectId should be provided.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - if (studyId) { - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - } - if (projectId) { - const project: IProject = await studyCore.findOneProject_throwErrorIfNotExist(projectId); - modifiedStudyId = project.studyId; - } - - /* check permission */ - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId - ); - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId, - projectId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const study = await studyCore.findOneStudy_throwErrorIfNotExist(modifiedStudyId); - // get all dataVersions that are valid (before/equal the current version) - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - if (hasStudyLevelPermission && hasStudyLevelPermission.hasVersioned && versionId === null) { - availableDataVersions.push(null); - } - const standardizations = await db.collections.standardizations_collection.aggregate([{ - $sort: { uploadedAt: -1 } - }, { - $match: { dataVersion: { $in: availableDataVersions } } - }, { - $match: { studyId: studyId, type: type ?? /^.*$/ } - }, { - $group: { - _id: { - type: '$type', - field: '$field' - }, - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { newRoot: '$doc' } - }, { - $match: { deleted: null } - } - ]).toArray(); - return standardizations as IStandardization[]; + getStandardization: async (_parent, { studyId, projectId, type, versionId }: { studyId: string, projectId: string, type?: string, versionId: string }, context) => { + return await standardizationCore.getStandardization(context.req.user, versionId, studyId, projectId, type); } }, Mutation: { - createStandardization: async (parent, { studyId, standardization }: { studyId: string, standardization }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* check study exists */ - const studySearchResult = await db.collections.studies_collection.findOne({ id: studyId, deleted: null }); - if (studySearchResult === null || studySearchResult === undefined) { - throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - const stdRulesWithId = [...standardization.stdRules]; - stdRulesWithId.forEach(el => { - el.id = uuid(); - }); - if (!(permissionCore.checkDataEntryValid(hasPermission.raw, standardization.field[0].slice(1)))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - const standardizationEntry: IStandardization = { - id: uuid(), - studyId: studyId, - type: standardization.type, - field: standardization.field, - path: standardization.path, - stdRules: stdRulesWithId || [], - joinByKeys: standardization.joinByKeys || [], - dataVersion: null, - metadata: standardization.metadata, - uploadedAt: Date.now(), - deleted: null - }; - - await db.collections.standardizations_collection.findOneAndUpdate({ studyId: studyId, type: standardization.type, field: standardization.field, dataVersion: null }, { - $set: { ...standardizationEntry } - }, { - upsert: true - }); - return standardizationEntry; + createStandardization: async (_parent, { studyId, standardization }: { studyId: string, standardization }, context) => { + return standardizationCore.createStandardization(context.req.user, studyId, standardization); }, - deleteStandardization: async (parent, { studyId, type, field }: { studyId: string, type: string, field: string[] }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - if (!(permissionCore.checkDataEntryValid(hasPermission.raw, field[0].slice(1)))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - /* check study exists */ - const studySearchResult = await db.collections.studies_collection.findOne({ id: studyId, deleted: null }); - if (studySearchResult === null || studySearchResult === undefined) { - throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - - // check type exists - const types: string[] = await db.collections.standardizations_collection.distinct('type', { studyId: studyId, deleted: null }); - if (!types.includes(type)) { - throw new GraphQLError('Type does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - const result = await db.collections.standardizations_collection.findOneAndUpdate({ studyId: studyId, field: field, type: type, dataVersion: null }, { - $set: { - id: uuid(), - studyId: studyId, - field: field, - type: type, - dataVersion: null, - uploadedAt: Date.now(), - deleted: Date.now() - } - }, { - upsert: true - }); - return makeGenericReponse(result?.id || ''); + deleteStandardization: async (_parent, { studyId, type, field }: { studyId: string, type: string, field: string[] }, context) => { + return standardizationCore.deleteStandardization(context.req.user, studyId, type, field); } }, Subscription: {} }; - -// function checkFieldEqual(fieldA: string[], fieldB: string[]) { -// return fieldA.length === fieldB.length && fieldA.every((value, index) => { -// return value === fieldB[index]; -// }); -// } diff --git a/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts index 5df66725e..f2369165a 100644 --- a/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts @@ -1,1617 +1,132 @@ -import { GraphQLError } from 'graphql'; import { IProject, IStudy, - IFieldEntry, studyType, IDataClip, - ISubjectDataRecordSummary, - IOntologyTree, - userTypes, - atomicOperation, - IPermissionManagementOptions, - IDataEntry, - enumValueType, - IQueryString, - IGroupedData, - IValueDescription + IOntologyTree } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { db } from '../../database/database'; -import { permissionCore } from '../core/permissionCore'; -import { validateAndGenerateFieldEntry } from '../core/fieldCore'; -import { studyCore } from '../core/studyCore'; -import { errorCodes } from '../errors'; -import { IGenericResponse, makeGenericReponse } from '../responses'; -import { buildPipeline, translateMetadata } from '../../utils/query'; -import { dataStandardization } from '../../utils/query'; +import { CreateFieldInput, EditFieldInput, StudyCore } from '@itmat-broker/itmat-cores'; import { DMPResolversMap } from './context'; -import { Filter } from 'mongodb'; - -interface CreateFieldInput { - fieldId: string; - fieldName: string - tableName: string - dataType: enumValueType - possibleValues?: IValueDescription[] - unit?: string - comments?: string - metadata: Record -} +import { db } from '../../database/database'; +import { objStore } from '../../objStore/objStore'; -interface EditFieldInput { - fieldId: string; - fieldName: string; - tableName?: string; - dataType: enumValueType; - possibleValues?: IValueDescription[] - unit?: string - comments?: string -} +const studyCore = Object.freeze(new StudyCore(db, objStore)); export const studyResolvers: DMPResolversMap = { Query: { - getStudy: async (parent, args: { studyId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const studyId: string = args.studyId; - - /* user can get study if he has readonly permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.READ, - requester, - studyId - ); - if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const study = await db.collections.studies_collection.findOne({ id: studyId, deleted: null }); - if (study === null || study === undefined) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - return study; + getStudy: async (_parent, args: { studyId: string }, context) => { + return studyCore.getStudy(context.req.user, args.studyId); }, - getProject: async (parent, args: { projectId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const projectId: string = args.projectId; - - /* get project */ // defer patientMapping since it's costly and not available to all users - const project = await db.collections.projects_collection.findOne({ id: projectId, deleted: null }, { projection: { patientMapping: 0 } }); - if (!project) - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - - /* check if user has permission */ - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.READ, - requester, - project.studyId, - projectId - ); - - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.READ, - requester, - project.studyId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - return project; + getProject: async (_parent, args: { projectId: string }, context) => { + return await studyCore.getProject(context.req.user, args.projectId); }, - getStudyFields: async (parent, { studyId, projectId, versionId }: { studyId: string, projectId?: string, versionId?: string | null }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* user can get study if he has readonly permission */ - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId - ); - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId, - projectId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const study = await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - const aggregatedPermissions = permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); - - // the processes of requiring versioned data and unversioned data are different - // check the metadata:role:**** for versioned data directly - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - // check the regular expressions for unversioned data - if (requester.type === userTypes.ADMIN) { - if (versionId === null) { - availableDataVersions.push(null); - } - const fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - return fieldRecords.filter(el => el.dateDeleted === null); - } - // unversioned data could not be returned by metadata filters - if (versionId === null && aggregatedPermissions.hasVersioned) { - availableDataVersions.push(null); - const fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { - $match: { - fieldId: { $in: aggregatedPermissions.raw.fieldIds.map((el: string) => new RegExp(el)) } - } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - return fieldRecords.filter(el => el.dateDeleted === null); - } else { - // metadata filter - const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; - aggregatedPermissions.matchObj.forEach((subMetadata) => { - subqueries.push(translateMetadata(subMetadata)); - }); - const metadataFilter = { $or: subqueries }; - const fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { $match: metadataFilter }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }, { - $set: { metadata: null } - }]).toArray(); - return fieldRecords.filter(el => el.dateDeleted === null); - } + getStudyFields: async (_parent, { studyId, projectId, versionId }: { studyId: string, projectId?: string, versionId?: string | null }, context) => { + return await studyCore.getStudyFields(context.req.user, studyId, projectId, versionId); }, - getOntologyTree: async (parent, { studyId, projectId, treeName, versionId }: { studyId: string, projectId?: string, treeName?: string, versionId?: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* get studyId by parameter or project */ - const study = await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - if (projectId) { - await studyCore.findOneProject_throwErrorIfNotExist(projectId); - } - - // we dont filters fields of an ontology tree by fieldIds - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.ontologyTrees, - atomicOperation.READ, - requester, - studyId, - projectId - ); - - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.ontologyTrees, - atomicOperation.READ, - requester, - studyId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - // if versionId is null, we will only return trees whose data version is null - // this is a different behavior from getting fields or data - if (study.ontologyTrees === undefined) { - return []; - } else { - const trees: IOntologyTree[] = study.ontologyTrees; - if (hasStudyLevelPermission && versionId === null) { - const availableTrees: IOntologyTree[] = []; - for (let i = trees.length - 1; i >= 0; i--) { - if (trees[i].dataVersion === null - && availableTrees.filter(el => el.name === trees[i].name).length === 0) { - availableTrees.push(trees[i]); - } else { - continue; - } - } - if (treeName) { - return availableTrees.filter(el => el.name === treeName); - } else { - return availableTrees; - } - } else { - const availableTrees: IOntologyTree[] = []; - for (let i = trees.length - 1; i >= 0; i--) { - if (availableDataVersions.includes(trees[i].dataVersion || '') - && availableTrees.filter(el => el.name === trees[i].name).length === 0) { - availableTrees.push(trees[i]); - } else { - continue; - } - } - if (treeName) { - return availableTrees.filter(el => el.name === treeName); - } else { - return availableTrees; - } - } - } + getOntologyTree: async (_parent, { studyId, projectId, treeName, versionId }: { studyId: string, projectId?: string, treeName?: string, versionId?: string }, context) => { + return await studyCore.getOntologyTree(context.req.user, studyId, projectId, treeName, versionId); }, - checkDataComplete: async (parent, { studyId }: { studyId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* user can get study if he has readonly permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - // we only check data that hasnt been pushed to a new data version - const data: IDataEntry[] = await db.collections.data_collection.find({ - m_studyId: studyId, - m_versionId: null, - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } - }).toArray(); - const fieldMapping = (await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId } - }, { - $match: { fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } - }, { - $sort: { dateAdded: -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - } - ]).toArray()).map(el => el['doc']).filter(eh => eh.dateDeleted === null).reduce((acc, curr) => { - acc[curr.fieldId] = curr; - return acc; - }, {}); - const summary: ISubjectDataRecordSummary[] = []; - // we will not check data whose fields are not defined, because data that the associated fields are undefined will not be returned while querying data - for (const record of data) { - let error: string | null = null; - if (fieldMapping[record.m_fieldId] !== undefined && fieldMapping[record.m_fieldId] !== null) { - switch (fieldMapping[record.m_fieldId].dataType) { - case 'dec': {// decimal - if (typeof record.value === 'number') { - if (!/^\d+(.\d+)?$/.test(record.value.toString())) { - error = `Field ${record.m_fieldId}: Cannot parse as decimal.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as decimal.`; - break; - } - break; - } - case 'int': {// integer - if (typeof record.value === 'number') { - if (!/^-?\d+$/.test(record.value.toString())) { - error = `Field ${record.m_fieldId}: Cannot parse as integer.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as integer.`; - break; - } - break; - } - case 'bool': {// boolean - if (typeof record.value !== 'boolean') { - error = `Field ${record.m_fieldId}: Cannot parse as boolean.`; - break; - } - break; - } - case 'str': { - break; - } - // 01/02/2021 00:00:00 - case 'date': { - if (typeof record.value === 'string') { - const matcher = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?/; - if (!record.value.match(matcher)) { - error = `Field ${record.m_fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; - break; - } - break; - } - case 'json': { - break; - } - case 'file': { - if (typeof record.value === 'string') { - const file = await db.collections.files_collection.findOne({ id: record.value }); - if (!file) { - error = `Field ${record.m_fieldId}: Cannot parse as file or file does not exist.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as file or file does not exist.`; - break; - } - break; - } - case 'cat': { - if (typeof record.value === 'string') { - if (!fieldMapping[record.m_fieldId].possibleValues.map((el) => el.code).includes(record.value.toString())) { - error = `Field ${record.m_fieldId}: Cannot parse as categorical, value not in value list.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as categorical, value not in value list.`; - break; - } - break; - } - default: { - error = `Field ${record.m_fieldId}: Invalid data Type.`; - break; - } - } - } - error && summary.push({ - subjectId: record.m_subjectId, - visitId: record.m_visitId, - fieldId: record.m_fieldId, - error: error - }); - } - - return summary; + checkDataComplete: async (_parent, { studyId }: { studyId: string }, context) => { + return await studyCore.checkDataComplete(context.req.user, studyId); }, - getDataRecords: async (parent, { studyId, queryString, versionId, projectId }: { queryString, studyId: string, versionId: string | null | undefined, projectId?: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* user can get study if he has readonly permission */ - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId - ); - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId, - projectId - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const study = await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - const aggregatedPermissions = permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); - - let availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - let fieldRecords: IFieldEntry[] = []; - let result; - let metadataFilter; - // we obtain the data by different requests - // admin used will not filtered by metadata filters - if (requester.type === userTypes.ADMIN) { - if (versionId !== undefined) { - if (versionId === null) { - availableDataVersions.push(null); - } else if (versionId === '-1') { - availableDataVersions = availableDataVersions.length !== 0 ? [availableDataVersions[availableDataVersions.length - 1]] : []; - } else { - availableDataVersions = [versionId]; - } - } - - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - if (queryString.data_requested?.length > 0) { - fieldRecords = fieldRecords.filter(el => queryString.data_requested.includes(el.fieldId)); - } - const pipeline = buildPipeline(queryString, studyId, availableDataVersions, fieldRecords, undefined, true, versionId === null); - result = await db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - } else { - const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; - aggregatedPermissions.matchObj.forEach((subMetadata) => { - subqueries.push(translateMetadata(subMetadata)); - }); - metadataFilter = { $or: subqueries }; - // unversioned data: metadatafilter for versioned data and all unversioned tags - if (versionId === null && aggregatedPermissions.hasVersioned) { - availableDataVersions.push(null); - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { - $match: { - $or: [ - metadataFilter, - { m_versionId: null } - ] - } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - if (queryString.data_requested?.length > 0) { - fieldRecords = fieldRecords.filter(el => queryString.data_requested.includes(el.fieldId)); - } - } else if (versionId === undefined || versionId === '-1') { - if (versionId === '-1') { - availableDataVersions = availableDataVersions.length !== 0 ? [availableDataVersions[availableDataVersions.length - 1]] : []; - } - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { - $match: { - $or: [ - metadataFilter - ] - } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - if (queryString.data_requested?.length > 0) { - fieldRecords = fieldRecords.filter(el => queryString.data_requested.includes(el.fieldId)); - } - } else if (versionId !== undefined) { - availableDataVersions = [versionId]; - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { dateAdded: -1 } - }, { - $match: { - $or: [ - metadataFilter - ] - } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - if (queryString.data_requested?.length > 0) { - fieldRecords = fieldRecords.filter(el => queryString.data_requested.includes(el.fieldId)); - } - } - - // TODO: placeholder for metadata filter - // if (queryString.metadata) { - // metadataFilter = { $and: queryString.metadata.map((el) => translateMetadata(el)) }; - // } - const pipeline = buildPipeline(queryString, studyId, availableDataVersions, fieldRecords, metadataFilter, false, versionId === null && aggregatedPermissions.hasVersioned); - result = await db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - } - // post processing the data - // 2. update to the latest data; start from first record - const groupedResult: IGroupedData = {}; - for (let i = 0; i < result.length; i++) { - const { m_subjectId, m_visitId, m_fieldId, value } = result[i]; - if (!groupedResult[m_subjectId]) { - groupedResult[m_subjectId] = {}; - } - if (!groupedResult[m_subjectId][m_visitId]) { - groupedResult[m_subjectId][m_visitId] = {}; - } - groupedResult[m_subjectId][m_visitId][m_fieldId] = value; - } - - // 2. adjust format: 1) original(exists) 2) standardized - $name 3) grouped - // when standardized data, versionId should not be specified - const standardizations = versionId === null ? null : await db.collections.standardizations_collection.find({ studyId: studyId, type: queryString['format'].split('-')[1], delete: null, dataVersion: { $in: availableDataVersions } }).toArray(); - const formattedData = dataStandardization(study, fieldRecords, - groupedResult, queryString, standardizations); - return { data: formattedData }; + getDataRecords: async (_parent, { studyId, queryString, versionId, projectId }: { queryString, studyId: string, versionId: string | null | undefined, projectId?: string }, context) => { + return await studyCore.getDataRecords(context.req.user, queryString, studyId, versionId, projectId); } }, Study: { projects: async (study: IStudy) => { - return await db.collections.projects_collection.find({ studyId: study.id, deleted: null }).toArray(); + return await studyCore.getStudyProjects(study); }, jobs: async (study: IStudy) => { - return await db.collections.jobs_collection.find({ studyId: study.id }).toArray(); + return await studyCore.getStudyJobs(study); }, roles: async (study: IStudy) => { - return await db.collections.roles_collection.find({ studyId: study.id, projectId: undefined, deleted: null }).toArray(); + return await studyCore.getStudyRoles(study); }, - files: async (study: IStudy, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - study.id - ); - - if (!hasPermission) { - return []; - } - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const fileFieldIds: string[] = (await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumValueType.FILE } - }, { $match: { fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } }, { - $sort: { dateAdded: -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray()).map(el => el.fieldId); - let adds: string[] = []; - let removes: string[] = []; - // versioned data - if (requester.type === userTypes.ADMIN) { - const fileRecords = await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_fieldId: { $in: fileFieldIds } } - }]).toArray(); - adds = fileRecords.map(el => el.metadata?.add || []).flat(); - removes = fileRecords.map(el => el.metadata?.remove || []).flat(); - } else { - const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; - hasPermission.matchObj.forEach((subMetadata) => { - subqueries.push(translateMetadata(subMetadata)); - }); - const metadataFilter = { $or: subqueries }; - const versionedFileRecors = await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } - }, { - $match: metadataFilter - }]).toArray(); - - const filters: Filter[] = []; - for (const role of hasPermission.roleraw) { - if (!(role.hasVersioned)) { - continue; - } - filters.push({ - m_subjectId: { $in: role.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: role.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: role.fieldIds.map((el: string) => new RegExp(el)) }, - m_versionId: null - }); - } - let unversionedFileRecords: IDataEntry[] = []; - if (filters.length !== 0) { - unversionedFileRecords = await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: null, m_fieldId: { $in: fileFieldIds } } - }, { - $match: { $or: filters } - }]).toArray(); - } - adds = versionedFileRecors.map(el => el.metadata?.add || []).flat(); - removes = versionedFileRecors.map(el => el.metadata?.remove || []).flat(); - adds = adds.concat(unversionedFileRecords.map(el => el.metadata?.add || []).flat()); - removes = removes.concat(unversionedFileRecords.map(el => el.metadata?.remove || []).flat()); - } - return await db.collections.files_collection.find({ studyId: study.id, deleted: null, $or: [{ id: { $in: adds, $nin: removes } }, { description: JSON.stringify({}) }] }).sort({ uploadTime: -1 }).toArray(); + files: async (study: IStudy, _args: never, context) => { + return await studyCore.getStudyFiles(context.req.user, study); }, - subjects: async (study: IStudy, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - study.id - ); - if (!hasPermission) { - return [[], []]; - } - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const versionedSubjects = (await db.collections.data_collection.distinct('m_subjectId', { - m_studyId: study.id, - m_versionId: availableDataVersions[availableDataVersions.length - 1], - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } - })).sort() || []; - const unVersionedSubjects = hasPermission.hasVersioned ? (await db.collections.data_collection.distinct('m_subjectId', { - m_studyId: study.id, - m_versionId: null, - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } - })).sort() || [] : []; - return [versionedSubjects, unVersionedSubjects]; + subjects: async (study: IStudy, _args: never, context) => { + return await studyCore.getStudySubjects(context.req.user, study); }, - visits: async (study: IStudy, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - study.id - ); - if (!hasPermission) { - return [[], []]; - } - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const versionedVisits = (await db.collections.data_collection.distinct('m_visitId', { - m_studyId: study.id, - m_versionId: availableDataVersions[availableDataVersions.length - 1], - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } - })).sort((a, b) => parseFloat(a) - parseFloat(b)); - const unVersionedVisits = hasPermission.hasVersioned ? (await db.collections.data_collection.distinct('m_visitId', { - m_studyId: study.id, - m_versionId: null, - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } - })).sort((a, b) => parseFloat(a) - parseFloat(b)) : []; - return [versionedVisits, unVersionedVisits]; + visits: async (study: IStudy, _args: never, context) => { + return await studyCore.getStudyVisits(context.req.user, study); }, - numOfRecords: async (study: IStudy, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - study.id - ); - if (!hasPermission) { - return [0, 0]; - } - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const numberOfVersioned: number = (await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: availableDataVersions[availableDataVersions.length - 1], value: { $ne: null } } - }, { - $match: { - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } - } - }, { - $count: 'count' - }]).toArray())[0]?.['count'] || 0; - const numberOfUnVersioned: number = hasPermission.hasVersioned ? (await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: null, value: { $ne: null } } - }, { - $match: { - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } - } - }, { - $count: 'count' - }]).toArray())[0]?.['count'] || 0 : 0; - return [numberOfVersioned, numberOfUnVersioned]; + numOfRecords: async (study: IStudy, _args: never, context) => { + return await studyCore.getStudyNumOfRecords(context.req.user, study); }, currentDataVersion: async (study: IStudy) => { - return study.currentDataVersion === -1 ? null : study.currentDataVersion; + return studyCore.getStudyCurrentDataVersion(study); } }, Project: { - fields: async (project: Omit, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - project.studyId, - project.id - ); - if (!hasProjectLevelPermission) { return []; } - // get all dataVersions that are valid (before the current version) - const study = await studyCore.findOneStudy_throwErrorIfNotExist(project.studyId); - - // the processes of requiring versioned data and unversioned data are different - // check the metadata:role:**** for versioned data directly - // check the regular expressions for unversioned data - const availableDataVersions: string[] = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const availableTrees: IOntologyTree[] = []; - const trees: IOntologyTree[] = study.ontologyTrees || []; - for (let i = trees.length - 1; i >= 0; i--) { - if (availableDataVersions.includes(trees[i].dataVersion || '') - && availableTrees.filter(el => el.name === trees[i].name).length === 0) { - availableTrees.push(trees[i]); - } else { - continue; - } - } - if (availableTrees.length === 0) { - return []; - } - const ontologyTreeFieldIds: string[] = (availableTrees[0].routes || []).map(el => el.field[0].replace('$', '')); - let fieldRecords: IFieldEntry[] = []; - if (requester.type === userTypes.ADMIN) { - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions }, fieldId: { $in: ontologyTreeFieldIds } } - }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }, { - $set: { metadata: null } - }]).toArray(); - } else { - // metadata filter - const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; - hasProjectLevelPermission.matchObj.forEach((subMetadata) => { - subqueries.push(translateMetadata(subMetadata)); - }); - const metadataFilter = { $or: subqueries }; - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions }, fieldId: { $in: ontologyTreeFieldIds } } - }, { $match: metadataFilter }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }, { - $set: { metadata: null } - }]).toArray(); - } - return fieldRecords; + fields: async (project: Omit, _args: never, context) => { + return await studyCore.getProjectFields(context.req.user, project); }, jobs: async (project: Omit) => { - return await db.collections.jobs_collection.find({ studyId: project.studyId, projectId: project.id }).toArray(); + return await studyCore.getProjectJobs(project); }, - files: async (project: Omit, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - project.studyId, - project.id - ); - if (!hasPermission) { - return []; - } - const study = await studyCore.findOneStudy_throwErrorIfNotExist(project.studyId); - const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const availableTrees: IOntologyTree[] = []; - const trees: IOntologyTree[] = study.ontologyTrees || []; - for (let i = trees.length - 1; i >= 0; i--) { - if (availableDataVersions.includes(trees[i].dataVersion || '') - && availableTrees.filter(el => el.name === trees[i].name).length === 0) { - availableTrees.push(trees[i]); - } else { - continue; - } - } - if (availableTrees.length === 0) { - return []; - } - const ontologyTreeFieldIds: string[] = (availableTrees[0].routes || []).map(el => el.field[0].replace('$', '')); - const fileFieldIds: string[] = (await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumValueType.FILE } - }, { $match: { $and: [{ fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } }, { fieldId: { $in: ontologyTreeFieldIds } }] } }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray()).map(el => el.fieldId); - let add: string[] = []; - let remove: string[] = []; - if (Object.keys(hasPermission.matchObj).length === 0) { - (await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } - }]).toArray()).forEach(element => { - add = add.concat(element.metadata?.add || []); - remove = remove.concat(element.metadata?.remove || []); - }); - } else { - const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; - hasPermission.matchObj.forEach((subMetadata) => { - subqueries.push(translateMetadata(subMetadata)); - }); - const metadataFilter = { $or: subqueries }; - (await db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } - }, { - $match: metadataFilter - }]).toArray()).forEach(element => { - add = add.concat(element.metadata?.add || []); - remove = remove.concat(element.metadata?.remove || []); - }); - } - return await db.collections.files_collection.find({ $and: [{ id: { $in: add } }, { id: { $nin: remove } }] }).toArray(); + files: async (project: Omit, _args: never, context) => { + return await studyCore.getProjectFiles(context.req.user, project); }, dataVersion: async (project: IProject) => { - const study = await db.collections.studies_collection.findOne({ id: project.studyId, deleted: null }); - if (study === undefined || study === null) { - return null; - } - if (study.currentDataVersion === -1) { - return null; - } - return study.dataVersions[study?.currentDataVersion]; + return await studyCore.getProjectDataVersion(project); }, - summary: async (project: IProject, args: never, context) => { - const summary = {}; - const study = await db.collections.studies_collection.findOne({ id: project.studyId }); - if (study === undefined || study === null || study.currentDataVersion === -1) { - return summary; - } - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* user can get study if he has readonly permission */ - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - project.studyId - ); - const hasProjectLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - project.studyId, - project.id - ); - if (!hasStudyLevelPermission && !hasProjectLevelPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - // get all dataVersions that are valid (before the current version) - const aggregatedPermissions = permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); - - let metadataFilter; - - const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - // ontology trees - const availableTrees: IOntologyTree[] = []; - const trees: IOntologyTree[] = study.ontologyTrees || []; - for (let i = trees.length - 1; i >= 0; i--) { - if (availableDataVersions.includes(trees[i].dataVersion || '') - && availableTrees.filter(el => el.name === trees[i].name).length === 0) { - availableTrees.push(trees[i]); - } else { - continue; - } - } - // const ontologyTreeFieldIds = (availableTrees[0]?.routes || []).map(el => el.field[0].replace('$', '')); - - let fieldRecords; - if (requester.type === userTypes.ADMIN) { - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } - }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }]).toArray(); - } else { - const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; - aggregatedPermissions.matchObj.forEach((subMetadata) => { - subqueries.push(translateMetadata(subMetadata)); - }); - metadataFilter = { $or: subqueries }; - fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } - }, { $match: metadataFilter }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }]).toArray(); - } - // fieldRecords = fieldRecords.filter(el => ontologyTreeFieldIds.includes(el.fieldId)); - const emptyQueryString: IQueryString = { - cohort: [[]], - new_fields: [] - }; - const pipeline = buildPipeline(emptyQueryString, project.studyId, [availableDataVersions[availableDataVersions.length - 1]], fieldRecords as IFieldEntry[], metadataFilter, requester.type === userTypes.ADMIN, false); - const result = await db.collections.data_collection.aggregate<{ m_subjectId: string, m_visitId: string, m_fieldId: string, value: unknown }>(pipeline, { allowDiskUse: true }).toArray(); - summary['subjects'] = Array.from(new Set(result.map((el) => el.m_subjectId))).sort(); - summary['visits'] = Array.from(new Set(result.map((el) => el.m_visitId))).sort((a, b) => parseFloat(a) - parseFloat(b)).sort(); - summary['standardizationTypes'] = (await db.collections.standardizations_collection.distinct('type', { studyId: study.id, deleted: null })).sort(); - return summary; + summary: async (project: IProject, _args: never, context) => { + return await studyCore.getProjectSummary(context.req.user, project); }, - patientMapping: async (project: Omit, args: never, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (!(await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, // patientMapping is not visible to project users; only to study users. - requester, - project.studyId - ))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* returning */ - const result = - await db.collections.projects_collection.findOne( - { id: project.id, deleted: null }, - { projection: { patientMapping: 1 } } - ); - if (result && result.patientMapping) { - return result.patientMapping; - } else { - return null; - } + patientMapping: async (project: IProject, _args: never, context) => { + return await studyCore.getProjectPatientMapping(context.req.user, project); }, roles: async (project: IProject) => { - return await db.collections.roles_collection.find({ studyId: project.studyId, projectId: project.id, deleted: null }).toArray(); + return await studyCore.getProjectRoles(project); }, - iCanEdit: async (project: IProject) => { // TO_DO - await db.collections.roles_collection.findOne({ - studyId: project.studyId, - projectId: project.id - // permissions: permissions.specific_project.specifi - }); + iCanEdit: async () => { // TO_DO return true; } }, Mutation: { - createStudy: async (parent, { name, description, type }: { name: string, description: string, type: studyType }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* create study */ - const study = await studyCore.createNewStudy(name, description, type, requester.id); - return study; + createStudy: async (_parent, { name, description, type }: { name: string, description: string, type: studyType }, context) => { + return await studyCore.createNewStudy(context.req.user, name, description, type); }, - editStudy: async (parent, { studyId, description }: { studyId: string, description: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* create study */ - const study = await studyCore.editStudy(studyId, description); - return study; + editStudy: async (_parent, { studyId, description }: { studyId: string, description: string }, context) => { + return await studyCore.editStudy(context.req.user, studyId, description); }, - createNewField: async (parent, { studyId, fieldInput }: { studyId: string, fieldInput: CreateFieldInput[] }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - /* user can get study if he has readonly permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - // check study exists - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - const response: IGenericResponse[] = []; - let isError = false; - const bulk = db.collections.field_dictionary_collection.initializeUnorderedBulkOp(); - // remove duplicates by fieldId - const keysToCheck = ['fieldId']; - const filteredFieldInput = fieldInput.filter( - (s => o => (k => !s.has(k) && s.add(k))(keysToCheck.map(k => o[k]).join('|')))(new Set()) - ); - // check fieldId duplicate - for (const oneFieldInput of filteredFieldInput) { - isError = false; - // check data valid - if (!(permissionCore.checkDataEntryValid(hasPermission.raw, oneFieldInput.fieldId))) { - isError = true; - response.push({ successful: false, code: errorCodes.NO_PERMISSION_ERROR, description: 'You do not have permissions to create this field.' }); - continue; - } - const { fieldEntry, error: thisError } = validateAndGenerateFieldEntry(oneFieldInput, requester); - if (thisError.length !== 0) { - response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: `Field ${oneFieldInput.fieldId || 'fieldId not defined'}-${oneFieldInput.fieldName || 'fieldName not defined'}: ${JSON.stringify(thisError)}` }); - isError = true; - } else { - response.push({ successful: true, description: `Field ${oneFieldInput.fieldId}-${oneFieldInput.fieldName} is created successfully.` }); - } - // // construct the rest of the fields - if (!isError) { - const newFieldEntry: IFieldEntry = { - ...fieldEntry, - fieldId: oneFieldInput.fieldId, - fieldName: oneFieldInput.fieldName, - dataType: oneFieldInput.dataType, - id: uuid(), - studyId: studyId, - dataVersion: null, - dateAdded: Date.now(), - dateDeleted: null, - metadata: { - uploader: requester.id - } - }; - bulk.find({ - fieldId: fieldEntry.fieldId, - studyId: studyId, - dataVersion: null - }).upsert().updateOne({ $set: newFieldEntry }); - } - } - if (bulk.batches.length > 0) { - await bulk.execute(); - } - return response; + createNewField: async (_parent, { studyId, fieldInput }: { studyId: string, fieldInput: CreateFieldInput[] }, context) => { + return await studyCore.createNewField(context.req.user, studyId, fieldInput); }, - editField: async (parent, { studyId, fieldInput }: { studyId: string, fieldInput: EditFieldInput }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - // check fieldId exist - const searchField = await db.collections.field_dictionary_collection.findOne({ studyId: studyId, fieldId: fieldInput.fieldId, dateDeleted: null }); - if (!searchField) { - throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - searchField.fieldId = fieldInput.fieldId; - searchField.fieldName = fieldInput.fieldName; - searchField.dataType = fieldInput.dataType; - if (fieldInput.tableName) { - searchField.tableName = fieldInput.tableName; - } - if (fieldInput.unit) { - searchField.unit = fieldInput.unit; - } - if (fieldInput.possibleValues) { - searchField.possibleValues = fieldInput.possibleValues; - } - if (fieldInput.tableName) { - searchField.tableName = fieldInput.tableName; - } - if (fieldInput.comments) { - searchField.comments = fieldInput.comments; - } - - const { fieldEntry, error } = validateAndGenerateFieldEntry(searchField, requester); - if (error.length !== 0) { - throw new GraphQLError(JSON.stringify(error), { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - const newFieldEntry = { ...fieldEntry, id: searchField.id, dateAdded: searchField.dateAdded, deleted: searchField.dateDeleted, studyId: searchField.studyId }; - await db.collections.field_dictionary_collection.findOneAndUpdate({ studyId: studyId, fieldId: newFieldEntry.fieldId }, { $set: newFieldEntry }); - - return newFieldEntry; - + editField: async (_parent, { studyId, fieldInput }: { studyId: string, fieldInput: EditFieldInput }, context) => { + return await studyCore.editField(context.req.user, studyId, fieldInput); }, - deleteField: async (parent, { studyId, fieldId }: { studyId: string, fieldId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - if (!(await permissionCore.checkDataEntryValid(hasPermission.raw, fieldId))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - // check fieldId exist - const searchField = await db.collections.field_dictionary_collection.find({ studyId: studyId, fieldId: fieldId, dateDeleted: null }).limit(1).sort({ dateAdded: -1 }).toArray(); - if (searchField.length === 0 || searchField[0].dateDeleted !== null) { - throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - - const fieldEntry = { - id: uuid(), - studyId: studyId, - fieldId: searchField[0].fieldId, - fieldName: searchField[0].fieldName, - tableName: searchField[0].tableName, - dataType: searchField[0].dataType, - possibleValues: searchField[0].possibleValues, - unit: searchField[0].unit, - comments: searchField[0].comments, - dataVersion: null, - dateAdded: (new Date()).valueOf(), - dateDeleted: (new Date()).valueOf() - }; - await db.collections.field_dictionary_collection.insertOne(fieldEntry); - return searchField[0]; - + deleteField: async (_parent, { studyId, fieldId }: { studyId: string, fieldId: string }, context) => { + return await studyCore.deleteField(context.req.user, studyId, fieldId); }, - uploadDataInArray: async (parent, { studyId, data }: { studyId: string, data: IDataClip[] }, context) => { - // check study exists - const study = await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - /* user can get study if he has readonly permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - // find the fieldsList, including those that have not been versioned, same method as getStudyFields - // get all dataVersions that are valid (before/equal the current version) - const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const fieldRecords = await db.collections.field_dictionary_collection.aggregate([{ - $sort: { dateAdded: -1 } - }, { - $match: { $or: [{ dataVersion: null }, { dataVersion: { $in: availableDataVersions } }] } - }, { - $match: { studyId: studyId } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - } - ]).toArray(); - // filter those that have been deleted - const fieldsList = fieldRecords.map(el => el['doc']).filter(eh => eh.dateDeleted === null); - const response = (await studyCore.uploadOneDataClip(studyId, hasPermission.raw, fieldsList, data, requester)); - - return response; + uploadDataInArray: async (_parent, { studyId, data }: { studyId: string, data: IDataClip[] }, context) => { + return await studyCore.uploadDataInArray(context.req.user, studyId, data); }, - deleteDataRecords: async (parent, { studyId, subjectIds, visitIds, fieldIds }: { studyId: string, subjectIds: string[], visitIds: string[], fieldIds: string[] }, context) => { - // check study exists - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - const response: IGenericResponse[] = []; - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - const hasPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - let validSubjects: string[]; - let validVisits: string[]; - let validFields; - // filter - if (subjectIds === undefined || subjectIds === null || subjectIds.length === 0) { - validSubjects = (await db.collections.data_collection.distinct('m_subjectId', { m_studyId: studyId })); - } else { - validSubjects = subjectIds; - } - if (visitIds === undefined || visitIds === null || visitIds.length === 0) { - validVisits = (await db.collections.data_collection.distinct('m_visitId', { m_studyId: studyId })); - } else { - validVisits = visitIds; - } - if (fieldIds === undefined || fieldIds === null || fieldIds.length === 0) { - validFields = (await db.collections.field_dictionary_collection.distinct('fieldId', { studyId: studyId })); - } else { - validFields = fieldIds; - } - - const bulk = db.collections.data_collection.initializeUnorderedBulkOp(); - for (const subjectId of validSubjects) { - for (const visitId of validVisits) { - for (const fieldId of validFields) { - if (!(await permissionCore.checkDataEntryValid(hasPermission.raw, fieldId, subjectId, visitId))) { - continue; - } - bulk.find({ m_studyId: studyId, m_subjectId: subjectId, m_visitId: visitId, m_fieldId: fieldId, m_versionId: null }).upsert().updateOne({ - $set: { - m_studyId: studyId, - m_subjectId: subjectId, - m_visitId: visitId, - m_versionId: null, - m_fieldId: fieldId, - value: null, - uploadedAt: (new Date()).valueOf(), - id: uuid() - } - }); - response.push({ successful: true, description: `SubjectId-${subjectId}:visitId-${visitId}:fieldId-${fieldId} is deleted.` }); - } - } - } - if (bulk.batches.length > 0) { - await bulk.execute(); - } - return response; + deleteDataRecords: async (_parent, { studyId, subjectIds, visitIds, fieldIds }: { studyId: string, subjectIds: string[], visitIds: string[], fieldIds: string[] }, context) => { + return await studyCore.deleteDataRecords(context.req.user, studyId, subjectIds, visitIds, fieldIds); }, - createNewDataVersion: async (parent, { studyId, dataVersion, tag }: { studyId: string, dataVersion: string, tag: string }, context) => { - // check study exists - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - // check dataVersion name valid - if (!/^\d{1,3}(\.\d{1,2}){0,2}$/.test(dataVersion)) { - throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); - } - - const created = await studyCore.createNewDataVersion(studyId, tag, dataVersion); - if (created === null) { - throw new GraphQLError('No matched or modified records', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - return created; + createNewDataVersion: async (_parent, { studyId, dataVersion, tag }: { studyId: string, dataVersion: string, tag: string }, context) => { + return await studyCore.createNewDataVersion(context.req.user, studyId, dataVersion, tag); }, - createOntologyTree: async (parent, { studyId, ontologyTree }: { studyId: string, ontologyTree: Pick }, context) => { - /* check study exists */ - const study = await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* user can get study if he has readonly permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.ontologyTrees, - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - // in case of old documents whose ontologyTrees are invalid - if (study.ontologyTrees === undefined || study.ontologyTrees === null) { - await db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { - $set: { - ontologyTrees: [] - } - }); - } - const ontologyTreeWithId: Partial = { ...ontologyTree }; - ontologyTreeWithId.id = uuid(); - ontologyTreeWithId.routes = ontologyTreeWithId.routes || []; - ontologyTreeWithId.routes.forEach(el => { - el.id = uuid(); - el.visitRange = el.visitRange || []; - }); - await db.collections.studies_collection.findOneAndUpdate({ - id: studyId, deleted: null, ontologyTrees: { - $not: { - $elemMatch: { - name: ontologyTree.name, - dataVersion: null - } - } - } - }, { - $addToSet: { - ontologyTrees: ontologyTreeWithId - } - }); - await db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null, ontologyTrees: { $elemMatch: { name: ontologyTreeWithId.name, dataVersion: null } } }, { - $set: { - 'ontologyTrees.$.routes': ontologyTreeWithId.routes, - 'ontologyTrees.$.dataVersion': null, - 'ontologyTrees.$.deleted': null - } - }); - return ontologyTreeWithId as IOntologyTree; + createOntologyTree: async (_parent, { studyId, ontologyTree }: { studyId: string, ontologyTree: Pick }, context) => { + return await studyCore.createOntologyTree(context.req.user, studyId, ontologyTree); }, - deleteOntologyTree: async (parent, { studyId, treeName }: { studyId: string, treeName: string }, context) => { - /* check study exists */ - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* user can get study if he has readonly permission */ - const hasPermission = await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.ontologyTrees, - atomicOperation.WRITE, - requester, - studyId - ); - if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const resultAdd = await db.collections.studies_collection.findOneAndUpdate({ - id: studyId, deleted: null, ontologyTrees: { - $not: { - $elemMatch: { - name: treeName, - dataVersion: null - } - } - } - }, { - $addToSet: { - ontologyTrees: { - id: uuid(), - name: treeName, - dataVersion: null, - deleted: Date.now().valueOf() - } - } - }); - const resultUpdate = await db.collections.studies_collection.findOneAndUpdate({ - id: studyId, deleted: null, ontologyTrees: { $elemMatch: { name: treeName, dataVersion: null } } - }, { - $set: { - 'ontologyTrees.$.deleted': Date.now().valueOf(), - 'ontologyTrees.$.routes': undefined - } - }); - if (resultAdd || resultUpdate) { - return makeGenericReponse(treeName); - } else { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - + deleteOntologyTree: async (_parent, { studyId, treeName }: { studyId: string, treeName: string }, context) => { + return await studyCore.deleteOntologyTree(context.req.user, studyId, treeName); }, - createProject: async (parent, { studyId, projectName }: { studyId: string, projectName: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (!(await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.WRITE, - requester, - studyId - ))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* making sure that the study exists first */ - await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - /* create project */ - const project = await studyCore.createProjectForStudy(studyId, projectName, requester.id); - return project; + createProject: async (_parent, { studyId, projectName }: { studyId: string, projectName: string }, context) => { + return await studyCore.createProjectForStudy(context.req.user, studyId, projectName); }, - deleteProject: async (parent, { projectId }: { projectId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const project = await studyCore.findOneProject_throwErrorIfNotExist(projectId); - - /* check privileges */ - if (!(await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.WRITE, - requester, - project.studyId - ))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* delete project */ - await studyCore.deleteProject(projectId); - return makeGenericReponse(projectId); + deleteProject: async (_parent, { projectId }: { projectId: string }, context) => { + return await studyCore.deleteProject(context.req.user, projectId); }, - deleteStudy: async (parent, { studyId }: { studyId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - const study = await db.collections.studies_collection.findOne({ id: studyId, deleted: null }); - - if (study) { - /* delete study */ - await studyCore.deleteStudy(studyId); - } else { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - return makeGenericReponse(studyId); + deleteStudy: async (_parent, { studyId }: { studyId: string }, context) => { + return await studyCore.deleteStudy(context.req.user, studyId); }, - setDataversionAsCurrent: async (parent, { studyId, dataVersionId }: { studyId: string, dataVersionId: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (!(await permissionCore.userHasTheNeccessaryManagementPermission( - IPermissionManagementOptions.own, - atomicOperation.WRITE, - requester, - studyId - ))) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - const study = await studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - /* check whether the dataversion exists */ - const selectedataVersionFiltered = study.dataVersions.filter((el) => el.id === dataVersionId); - if (selectedataVersionFiltered.length !== 1) { - throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); - } - - /* create a new dataversion with the same contentId */ - // const newDataVersion: IStudyDataVersion = { - // ...selectedataVersionFiltered[0], - // id: uuid() - // }; - - /* add this to the database */ - // const result = await db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { - // $push: { dataVersions: newDataVersion }, $inc: { currentDataVersion: 1 } - // }, { returnDocument: 'after' }); - - /* update the currentversion field in database */ - const versionIdsList = study.dataVersions.map((el) => el.id); - const result = await db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { - $set: { currentDataVersion: versionIdsList.indexOf(dataVersionId) } - }, { - returnDocument: 'after' - }); - - if (result) { - return result; - } else { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - - - + setDataversionAsCurrent: async (_parent, { studyId, dataVersionId }: { studyId: string, dataVersionId: string }, context) => { + return await studyCore.setDataversionAsCurrent(context.req.user, studyId, dataVersionId); } }, Subscription: {} diff --git a/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts index 8e362fef1..3a68de6b3 100644 --- a/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts @@ -1,826 +1,66 @@ -import { ApolloServerErrorCode } from '@apollo/server/errors'; -import { } from '@apollo/server'; -import { GraphQLError } from 'graphql'; -import bcrypt from 'bcrypt'; -import crypto from 'crypto'; -import { mailer } from '../../emailer/emailer'; -import { IProject, IStudy, IUser, IUserWithoutToken, IResetPasswordRequest, userTypes } from '@itmat-broker/itmat-types'; -import { Logger } from '@itmat-broker/itmat-commons'; -import { v4 as uuid } from 'uuid'; +import { IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { CreateUserInput, EditUserInput, UserCore } from '@itmat-broker/itmat-cores'; +import { DMPResolversMap } from './context'; import { db } from '../../database/database'; +import { mailer } from '../../emailer/emailer'; import config from '../../utils/configManager'; -import { userCore } from '../core/userCore'; -import { errorCodes } from '../errors'; -import { IGenericResponse, makeGenericReponse } from '../responses'; -import * as mfa from '../../utils/mfa'; -import QRCode from 'qrcode'; -import tmp from 'tmp'; -import { DMPResolversMap } from './context'; -import { UpdateFilter } from 'mongodb'; - -type CreateUserInput = { - username: string, - firstname: string, - lastname: string, - email: string, - emailNotificationsActivated?: boolean, - password: string, - description?: string, - organisation: string, - metadata: unknown -} - -type EditUserInput = { - id: string, - username?: string, - type?: userTypes, - firstname?: string, - lastname?: string, - email?: string, - emailNotificationsActivated?: boolean, - emailNotificationsStatus?: unknown, - password?: string, - description?: string, - organisation?: string, - expiredAt?: number, - metadata?: unknown -} - +const userCore = Object.freeze(new UserCore(db, mailer, config)); export const userResolvers: DMPResolversMap = { Query: { - whoAmI: (parent, args, context) => { + whoAmI: (_parent, _args, context) => { return context.req.user; }, - getUsers: async (parent, args: { userId?: string }) => { - // everyone is allowed to see all the users in the app. But only admin can access certain fields, like emails, etc - see resolvers for User type. - const queryObj = args.userId === undefined ? { deleted: null } : { deleted: null, id: args.userId }; - const cursor = db.collections.users_collection.find(queryObj, { projection: { _id: 0 } }); - return cursor.toArray(); + getUsers: async (_parent, args: { userId?: string }) => { + return await userCore.getUsers(args.userId); }, - validateResetPassword: async (parent, args: { token: string, encryptedEmail: string }) => { - /* decrypt email */ - const salt = makeAESKeySalt(args.token); - const iv = makeAESIv(args.token); - let email; - try { - email = await decryptEmail(args.encryptedEmail, salt, iv); - } catch (e) { - throw new GraphQLError('Token is not valid.'); - } - - /* check whether username and token is valid */ - /* not changing password too in one step (using findOneAndUpdate) because bcrypt is costly */ - const TIME_NOW = new Date().valueOf(); - const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; - const user: IUserWithoutToken | null = await db.collections.users_collection.findOne({ - email, - resetPasswordRequests: { - $elemMatch: { - id: args.token, - timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, - used: false - } - }, - deleted: null - }); - if (!user) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - return makeGenericReponse(); + validateResetPassword: async (_parent, args: { token: string, encryptedEmail: string }) => { + return await userCore.validateResetPassword(args.token, args.encryptedEmail); }, recoverSessionExpireTime: async () => { - return makeGenericReponse(); + return await userCore.recoverSessionExpireTime(); } }, User: { - access: async (user: IUser, args, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - /* if requested user is admin, then he has access to all studies */ - if (user.type === userTypes.ADMIN) { - const allprojects: IProject[] = await db.collections.projects_collection.find({ deleted: null }).toArray(); - const allstudies: IStudy[] = await db.collections.studies_collection.find({ deleted: null }).toArray(); - return { id: `user_access_obj_user_id_${user.id}`, projects: allprojects, studies: allstudies }; - } - - /* if requested user is not admin, find all the roles a user has */ - const roles = await db.collections.roles_collection.find({ users: user.id, deleted: null }).toArray(); - const init: { projects: string[], studies: string[] } = { projects: [], studies: [] }; - const studiesAndProjectThatUserCanSee: { projects: string[], studies: string[] } = roles.reduce( - (a, e) => { - if (e.projectId) { - a.projects.push(e.projectId); - } else { - a.studies.push(e.studyId); - } - return a; - }, init - ); - - const projects = await db.collections.projects_collection.find({ - $or: [ - { id: { $in: studiesAndProjectThatUserCanSee.projects }, deleted: null }, - { studyId: { $in: studiesAndProjectThatUserCanSee.studies }, deleted: null } - ] - }).toArray(); - const studies = await db.collections.studies_collection.find({ id: { $in: studiesAndProjectThatUserCanSee.studies }, deleted: null }).toArray(); - return { id: `user_access_obj_user_id_${user.id}`, projects, studies }; + access: async (user: IUserWithoutToken, _args, context) => { + return await userCore.getUserAccess(context.req.user, user); }, - username: async (user: IUser, args, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - return user.username; + username: async (user: IUserWithoutToken, _args, context) => { + return await userCore.getUserUsername(context.req.user, user); }, - description: async (user: IUser, args, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - return user.description; + description: async (user: IUserWithoutToken, _args, context) => { + return await userCore.getUserDescription(context.req.user, user); }, - email: async (user: IUser, args, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - return user.email; + email: async (user: IUserWithoutToken, _args, context) => { + return await userCore.getUserEmail(context.req.user, user); } }, Mutation: { - requestExpiryDate: async (parent, { username, email }: { username?: string, email?: string }) => { - /* double-check user existence */ - const queryObj = email ? { deleted: null, email } : { deleted: null, username }; - const user: IUser | null = await db.collections.users_collection.findOne(queryObj); - if (!user) { - /* even user is null. send successful response: they should know that a user doesn't exist */ - await new Promise(resolve => setTimeout(resolve, Math.random() * 6000)); - return makeGenericReponse(); - } - /* send email to the DMP admin mailing-list */ - await mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ - userEmail: user.email, - username: user.username - })); - - /* send email to client */ - await mailer.sendMail(formatEmailRequestExpiryDatetoClient({ - to: user.email, - username: user.username - })); - - return makeGenericReponse(); + requestExpiryDate: async (_parent: Record, { username, email }: { username?: string, email?: string }) => { + return await userCore.requestExpiryDate(username, email); }, - requestUsernameOrResetPassword: async (parent, { forgotUsername, forgotPassword, email, username }: { forgotUsername: boolean, forgotPassword: boolean, email?: string, username?: string }, context) => { - /* checking the args are right */ - if ((forgotUsername && !email) // should provide email if no username - || (forgotUsername && username) // should not provide username if it's forgotten.. - || (!email && !username)) { - throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); - } else if (email && username) { - // TO_DO : better client erro - /* only provide email if no username */ - throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); - } - - /* check user existence */ - const queryObj = email ? { deleted: null, email } : { deleted: null, username }; - const user = await db.collections.users_collection.findOne(queryObj); - if (!user) { - /* even user is null. send successful response: they should know that a user doesn't exist */ - await new Promise(resolve => setTimeout(resolve, Math.random() * 6000)); - return makeGenericReponse(); - } - - if (forgotPassword) { - /* make link to change password */ - const passwordResetToken = uuid(); - const resetPasswordRequest: IResetPasswordRequest = { - id: passwordResetToken, - timeOfRequest: new Date().valueOf(), - used: false - }; - const invalidateAllTokens = await db.collections.users_collection.findOneAndUpdate( - queryObj, - { - $set: { - 'resetPasswordRequests.$[].used': true - } - } - ); - if (invalidateAllTokens === null) { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - const updateResult = await db.collections.users_collection.findOneAndUpdate( - queryObj, - { - $push: { - resetPasswordRequests: resetPasswordRequest - } - } - ); - if (updateResult === null) { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - - /* send email to client */ - await mailer.sendMail(await formatEmailForForgottenPassword({ - to: user.email, - resetPasswordToken: passwordResetToken, - username: user.username, - firstname: user.firstname, - origin: context.req.headers.origin - })); - } else { - /* send email to client */ - await mailer.sendMail(formatEmailForFogettenUsername({ - to: user.email, - username: user.username - })); - } - return makeGenericReponse(); - }, - login: async (parent: Record, args: { username: string, password: string, totp: string, requestexpirydate?: boolean }, context) => { - const { req }: { req: Express.Request } = context; - const result = await db.collections.users_collection.findOne({ deleted: null, username: args.username }); - if (!result) { - throw new GraphQLError('User does not exist.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - const passwordMatched = await bcrypt.compare(args.password, result.password); - if (!passwordMatched) { - throw new GraphQLError('Incorrect password.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - // validate the TOTP - const totpValidated = mfa.verifyTOTP(args.totp, result.otpSecret); - if (!totpValidated) { - if (process.env['NODE_ENV'] === 'development') - console.warn('Incorrect One-Time password. Continuing in development ...'); - else - throw new GraphQLError('Incorrect One-Time password.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - /* validate if account expired */ - if (result.expiredAt < Date.now() && result.type === userTypes.STANDARD) { - if (args.requestexpirydate) { - /* send email to the DMP admin mailing-list */ - await mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ - userEmail: result.email, - username: result.username - })); - /* send email to client */ - await mailer.sendMail(formatEmailRequestExpiryDatetoClient({ - to: result.email, - username: result.username - })); - throw new GraphQLError('New expiry date has been requested! Wait for ADMIN to approve.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - throw new GraphQLError('Account Expired. Please request a new expiry date!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - const filteredResult: IUserWithoutToken = { ...result }; - - return new Promise((resolve, reject) => { - req.login(filteredResult, (err: unknown) => { - if (err) { - Logger.error(err); - reject(new GraphQLError('Cannot log in. Please try again later.')); - return; - } - resolve(filteredResult); - }); - }); - }, - logout: async (parent: Record, args: unknown, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const req: Express.Request = context.req; - return new Promise((resolve) => { - req.logout((err) => { - if (err) { - Logger.error(err); - throw new GraphQLError('Cannot log out'); - } else { - resolve(makeGenericReponse(requester.id)); - } - }); - }); + requestUsernameOrResetPassword: async (_parent: Record, { forgotUsername, forgotPassword, email, username }: { forgotUsername: boolean, forgotPassword: boolean, email?: string, username?: string }, context) => { + return await userCore.requestUsernameOrResetPassword(forgotUsername, forgotPassword, context.req.headers.origin, email, username); }, - createUser: async (parent, args: { user: CreateUserInput }) => { - const { username, firstname, lastname, email, emailNotificationsActivated, password, description, organisation, metadata }: { - username: string, firstname: string, lastname: string, email: string, emailNotificationsActivated?: boolean, password: string, description?: string, organisation: string, metadata: unknown - } = args.user; - - /* check email is valid form */ - if (!/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { - throw new GraphQLError('Email is not the right format.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - /* check password validity */ - if (password && !passwordIsGoodEnough(password)) { - throw new GraphQLError('Password has to be at least 8 character long.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - /* check that username and password dont have space */ - if (username.indexOf(' ') !== -1 || password.indexOf(' ') !== -1) { - throw new GraphQLError('Username or password cannot have spaces.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - const alreadyExist = await db.collections.users_collection.findOne({ username, deleted: null }); // since bycrypt is CPU expensive let's check the username is not taken first - if (alreadyExist !== null && alreadyExist !== undefined) { - throw new GraphQLError('User already exists.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - /* check if email has been used to register */ - const emailExist = await db.collections.users_collection.findOne({ email, deleted: null }); - if (emailExist !== null && emailExist !== undefined) { - throw new GraphQLError('This email has been registered. Please sign-in or register with another email!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - /* randomly generate a secret for Time-based One Time Password*/ - const otpSecret = mfa.generateSecret(); - - await userCore.createUser({ - password, - otpSecret, - username, - type: userTypes.STANDARD, - description: description ?? '', - firstname, - lastname, - email, - organisation, - emailNotificationsActivated: !!emailNotificationsActivated, - metadata - }); - - /* send email to the registered user */ - // get QR Code for the otpSecret. - const oauth_uri = `otpauth://totp/${config.appName}:${username}?secret=${otpSecret}&issuer=Data%20Science%20Institute`; - const tmpobj = tmp.fileSync({ mode: 0o644, prefix: 'qrcodeimg-', postfix: '.png' }); - - QRCode.toFile(tmpobj.name, oauth_uri, {}, function (err) { - if (err) throw new GraphQLError(err.message); - }); - - const attachments = [{ filename: 'qrcode.png', path: tmpobj.name, cid: 'qrcode_cid' }]; - await mailer.sendMail({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to: email, - subject: `[${config.appName}] Registration Successful`, - html: ` -

- Dear ${firstname}, -

-

- Welcome to the ${config.appName} data portal!
- Your username is ${username}.
-

-

- To login you will need to use a MFA authenticator app for one time passcode (TOTP).
- Scan the QRCode below in your MFA application of choice to configure it:
- QR code
- If you need to type the token in use ${otpSecret.toLowerCase()} -

-
-

- The ${config.appName} Team. -

- `, - attachments: attachments - }); - tmpobj.removeCallback(); - return makeGenericReponse(); + login: async (_parent: Record, args: { username: string, password: string, totp: string, requestexpirydate?: boolean }, context) => { + return await userCore.login(context.req, args.username, args.password, args.totp, args.requestexpirydate); }, - deleteUser: async (parent, args: { userId: string }, context) => { - /* only admin can delete users */ - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - // user (admin type) cannot delete itself - if (requester.id === args.userId) { - throw new GraphQLError('User cannot delete itself'); - } - - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - await userCore.deleteUser(args.userId); - return makeGenericReponse(args.userId); + logout: async (_parent: Record, _args: unknown, context) => { + return await userCore.logout(context.req); }, - resetPassword: async (parent, { encryptedEmail, token, newPassword }: { encryptedEmail: string, token: string, newPassword: string }) => { - /* check password validity */ - if (!passwordIsGoodEnough(newPassword)) { - throw new GraphQLError('Password has to be at least 8 character long.'); - } - - /* check that username and password dont have space */ - if (newPassword.indexOf(' ') !== -1) { - throw new GraphQLError('Password cannot have spaces.'); - } - - /* decrypt email */ - if (token.length < 16) { - throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); - } - const salt = makeAESKeySalt(token); - const iv = makeAESIv(token); - let email; - try { - email = await decryptEmail(encryptedEmail, salt, iv); - } catch (e) { - throw new GraphQLError('Token is not valid.'); - } - - /* check whether username and token is valid */ - /* not changing password too in one step (using findOneAndUpdate) because bcrypt is costly */ - const TIME_NOW = new Date().valueOf(); - const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; - const user: IUserWithoutToken | null = await db.collections.users_collection.findOne({ - email, - resetPasswordRequests: { - $elemMatch: { - id: token, - timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, - used: false - } - }, - deleted: null - }); - if (!user) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* randomly generate a secret for Time-based One Time Password*/ - const otpSecret = mfa.generateSecret(); - - /* all ok; change the user's password */ - const hashedPw = await bcrypt.hash(newPassword, config.bcrypt.saltround); - const updateResult = await db.collections.users_collection.findOneAndUpdate( - { - id: user.id, - resetPasswordRequests: { - $elemMatch: { - id: token, - timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, - used: false - } - } - }, - { $set: { 'password': hashedPw, 'otpSecret': otpSecret, 'resetPasswordRequests.$.used': true } }); - if (updateResult === null) { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } - - /* need to log user out of all sessions */ - // TO_DO - - /* send email to the registered user */ - // get QR Code for the otpSecret. - const oauth_uri = `otpauth://totp/${config.appName}:${user.username}?secret=${otpSecret}&issuer=Data%20Science%20Institute`; - const tmpobj = tmp.fileSync({ mode: 0o644, prefix: 'qrcodeimg-', postfix: '.png' }); - - QRCode.toFile(tmpobj.name, oauth_uri, {}, function (err) { - if (err) throw new GraphQLError(err.message); - }); - - const attachments = [{ filename: 'qrcode.png', path: tmpobj.name, cid: 'qrcode_cid' }]; - await mailer.sendMail({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to: email, - subject: `[${config.appName}] Password reset`, - html: ` -

- Dear ${user.firstname}, -

-

- Your password on ${config.appName} is now reset!
- You will need to update your MFA application for one-time passcode.
-

-

- To update your MFA authenticator app you can scan the QRCode below to configure it:
- QR code
- If you need to type the token in use ${otpSecret.toLowerCase()} -

-
-

- The ${config.appName} Team. -

- `, - attachments: attachments - }); - tmpobj.removeCallback(); - return makeGenericReponse(); + createUser: async (_parent, args: { user: CreateUserInput }) => { + return await userCore.createUser(args.user); }, - editUser: async (parent, args: { user: EditUserInput }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const { id, username, type, firstname, lastname, email, emailNotificationsActivated, emailNotificationsStatus, password, description, organisation, expiredAt, metadata }: { - id: string, username?: string, type?: userTypes, firstname?: string, lastname?: string, email?: string, emailNotificationsActivated?: boolean, emailNotificationsStatus?: unknown, password?: string, description?: string, organisation?: string, expiredAt?: number, metadata?: unknown - } = args.user; - if (password !== undefined && requester.id !== id) { // only the user themself can reset password - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - if (password && !passwordIsGoodEnough(password)) { - throw new GraphQLError('Password has to be at least 8 character long.'); - } - if (requester.type !== userTypes.ADMIN && requester.id !== id) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - let result; - if (requester.type === userTypes.ADMIN) { - result = await db.collections.users_collection.findOne({ id, deleted: null }); // just an extra guard before going to bcrypt cause bcrypt is CPU intensive. - if (result === null || result === undefined) { - throw new GraphQLError('User not found'); - } - } - - const fieldsToUpdate: UpdateFilter = { - type, - firstname, - lastname, - username, - email, - emailNotificationsActivated, - emailNotificationsStatus, - password, - description, - organisation, - expiredAt, - metadata - }; - - /* check email is valid form */ - if (email && !/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { - throw new GraphQLError('User not updated: Email is not the right format.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - if (requester.type !== userTypes.ADMIN && ( - type || firstname || lastname || username || description || organisation - )) { - throw new GraphQLError('User not updated: Non-admin users are only authorised to change their password, email or email notification.'); - } - - if (password) { fieldsToUpdate['password'] = await bcrypt.hash(password, config.bcrypt.saltround); } - for (const each of (Object.keys(fieldsToUpdate) as Array)) { - if (fieldsToUpdate[each] === undefined) { - delete fieldsToUpdate[each]; - } - } - if (expiredAt) { - fieldsToUpdate['emailNotificationsStatus'] = { - expiringNotification: false - }; - } - const updateResult = await db.collections.users_collection.findOneAndUpdate({ id, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); - if (updateResult) { - // New expiry date has been updated successfully. - if (expiredAt && result) { - /* send email to client */ - await mailer.sendMail(formatEmailRequestExpiryDateNotification({ - to: result.email, - username: result.username - })); - } - return updateResult; - } else { - throw new GraphQLError('Server error; no entry or more than one entry has been updated.'); - } + deleteUser: async (_parent, args: { userId: string }, context) => { + return await userCore.deleteUser(context.req.user, args.userId); }, - createOrganisation: async (parent, { name, shortname, containOrg, metadata }: { name: string, shortname: string, containOrg: string, metadata: unknown }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - // if the org already exists, update it; the existence is checked by the name - const createdOrganisation = await userCore.createOrganisation({ - name, - shortname: shortname ?? null, - containOrg: containOrg ?? null, - metadata: metadata ?? null - }); - - return createdOrganisation; + resetPassword: async (_parent, { encryptedEmail, token, newPassword }: { encryptedEmail: string, token: string, newPassword: string }) => { + return await userCore.resetPassword(encryptedEmail, token, newPassword); }, - deleteOrganisation: async (parent, { id }: { id: string }, context) => { - const requester = context.req.user; - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== userTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - const res = await db.collections.organisations_collection.findOneAndUpdate({ id: id }, { - $set: { - deleted: Date.now() - } - }, { - returnDocument: 'after' - }); - - if (res) { - return res; - } else { - throw new GraphQLError('Delete organisation failed.'); - } + editUser: async (_parent, args: { user: EditUserInput }, context) => { + return await userCore.editUser(context.req.user, args.user); } }, Subscription: {} }; - -export function makeAESKeySalt(str: string): string { - return str; -} - -export function makeAESIv(str: string): string { - if (str.length < 16) { throw new Error('IV cannot be less than 16 bytes long.'); } - return str.slice(0, 16); -} - -export async function encryptEmail(email: string, keySalt: string, iv: string) { - const algorithm = 'aes-256-cbc'; - return new Promise((resolve, reject) => { - crypto.scrypt(config.aesSecret, keySalt, 32, (err, derivedKey) => { - if (err) reject(err); - const cipher = crypto.createCipheriv(algorithm, derivedKey, iv); - let encoded = cipher.update(email, 'utf8', 'hex'); - encoded += cipher.final('hex'); - resolve(encoded); - }); - }); - -} - -export async function decryptEmail(encryptedEmail: string, keySalt: string, iv: string) { - const algorithm = 'aes-256-cbc'; - return new Promise((resolve, reject) => { - crypto.scrypt(config.aesSecret, keySalt, 32, (err, derivedKey) => { - if (err) reject(err); - try { - const decipher = crypto.createDecipheriv(algorithm, derivedKey, iv); - let decoded = decipher.update(encryptedEmail, 'hex', 'utf8'); - decoded += decipher.final('utf-8'); - resolve(decoded); - } catch (e) { - reject(e); - } - }); - }); -} - -async function formatEmailForForgottenPassword({ username, firstname, to, resetPasswordToken, origin }: { resetPasswordToken: string, to: string, username: string, firstname: string, origin: unknown }) { - const keySalt = makeAESKeySalt(resetPasswordToken); - const iv = makeAESIv(resetPasswordToken); - const encryptedEmail = await encryptEmail(to, keySalt, iv); - - const link = `${origin}/reset/${encryptedEmail}/${resetPasswordToken}`; - return ({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to, - subject: `[${config.appName}] password reset`, - html: ` -

- Dear ${firstname}, -

-

- Your username is ${username}. -

-

- You can reset you password by click the following link (active for 1 hour):
- ${link} -

-
-

- The ${config.appName} Team. -

- ` - }); -} - -function formatEmailForFogettenUsername({ username, to }: { username: string, to: string }) { - return ({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to, - subject: `[${config.appName}] password reset`, - html: ` -

- Dear user, -

-

- Your username is ${username}. -

-
-

- The ${config.appName} Team. -

- ` - }); -} - -function formatEmailRequestExpiryDatetoClient({ username, to }: { username: string, to: string }) { - return ({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to, - subject: `[${config.appName}] New expiry date has been requested!`, - html: ` -

- Dear user, -

-

- New expiry date for your ${username} account has been requested. - You will get a notification email once the request is approved. -

-
-

- The ${config.appName} Team. -

- ` - }); -} - -function formatEmailRequestExpiryDatetoAdmin({ username, userEmail }: { username: string, userEmail: string }) { - return ({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to: `${config.adminEmail}`, - subject: `[${config.appName}] New expiry date has been requested from ${username} account!`, - html: ` -

- Dear ADMINs, -

-

- A expiry date request from the ${username} account (whose email address is ${userEmail}) has been submitted. - Please approve or deny the request ASAP. -

-
-

- The ${config.appName} Team. -

- ` - }); -} - -function formatEmailRequestExpiryDateNotification({ username, to }: { username: string, to: string }) { - return ({ - from: `${config.appName} <${config.nodemailer.auth.user}>`, - to, - subject: `[${config.appName}] New expiry date has been updated!`, - html: ` -

- Dear user, -

-

- New expiry date for your ${username} account has been updated. - You now can log in as normal. -

-
-

- The ${config.appName} Team. -

- ` - }); -} - -function passwordIsGoodEnough(pw: string): boolean { - if (pw.length < 8) { - return false; - } - return true; -} diff --git a/packages/itmat-interface/src/interfaceRunner.ts b/packages/itmat-interface/src/interfaceRunner.ts index cf261216d..af503897b 100644 --- a/packages/itmat-interface/src/interfaceRunner.ts +++ b/packages/itmat-interface/src/interfaceRunner.ts @@ -4,7 +4,8 @@ import { objStore } from './objStore/objStore'; import { MongoClient } from 'mongodb'; import { Router } from './server/router'; import { Runner } from './server/server'; -import { pubsub, subscriptionEvents } from './graphql/pubsub'; +import { pubsub } from './graphql/pubsub'; +import { subscriptionEvents } from '@itmat-broker/itmat-cores'; class ITMATInterfaceRunner extends Runner { diff --git a/packages/itmat-interface/src/log/logPlugin.ts b/packages/itmat-interface/src/log/logPlugin.ts index 16c9a57b6..60529b03b 100644 --- a/packages/itmat-interface/src/log/logPlugin.ts +++ b/packages/itmat-interface/src/log/logPlugin.ts @@ -1,118 +1,4 @@ +import { LogPlugin } from '@itmat-broker/itmat-cores'; import { db } from '../database/database'; -import { v4 as uuid } from 'uuid'; -import { LOG_TYPE, LOG_ACTION, LOG_STATUS, USER_AGENT, userTypes } from '@itmat-broker/itmat-types'; -import { GraphQLRequestContextWillSendResponse } from '@apollo/server'; -import { ApolloServerContext } from '../graphql/ApolloServerContext'; -// only requests in white list will be recorded -export const logActionRecordWhiteList = Object.keys(LOG_ACTION); - -// only requests in white list will be recorded -export const logActionShowWhiteList = Object.keys(LOG_ACTION); - -export class LogPlugin { - public async serverWillStartLogPlugin(): Promise { - await db.collections.log_collection.insertOne({ - id: uuid(), - requesterName: userTypes.SYSTEM, - requesterType: userTypes.SYSTEM, - logType: LOG_TYPE.SYSTEM_LOG, - actionType: LOG_ACTION.startSERVER, - actionData: JSON.stringify({}), - time: Date.now(), - status: LOG_STATUS.SUCCESS, - errors: '', - userAgent: USER_AGENT.OTHER - }); - return null; - } - - public async requestDidStartLogPlugin(requestContext: GraphQLRequestContextWillSendResponse): Promise { - if (!requestContext.operationName || !logActionRecordWhiteList.includes(requestContext.operationName)) { - return null; - } - if (LOG_ACTION[requestContext.operationName] === undefined || LOG_ACTION[requestContext.operationName] === null) { - return null; - } - const variables = requestContext.request.variables ?? {}; // Add null check - await db.collections.log_collection.insertOne({ - id: uuid(), - requesterName: requestContext.contextValue?.req?.user?.username ?? 'NA', - requesterType: requestContext.contextValue?.req?.user?.type ?? userTypes.SYSTEM, - userAgent: (requestContext.contextValue.req?.headers['user-agent'] as string)?.startsWith('Mozilla') ? USER_AGENT.MOZILLA : USER_AGENT.OTHER, - logType: LOG_TYPE.REQUEST_LOG, - actionType: LOG_ACTION[requestContext.operationName], - actionData: JSON.stringify(ignoreFieldsHelper(variables, requestContext.operationName)), // Use the null-checked variables - time: Date.now(), - status: requestContext.errors === undefined ? LOG_STATUS.SUCCESS : LOG_STATUS.FAIL, - errors: requestContext.errors === undefined ? '' : requestContext.errors[0].message - }); - return null; - } -} - -type LogOperationName = 'login' | 'createUser' | 'registerPubkey' | 'issueAccessToken' | 'editUser' | 'uploadDataInArray' | 'uploadFile' | string; - -interface LoginData { - passpord?: string; - totp?: string; -} - -interface CreateUserData { - user?: { - password?: string; - }; -} - -interface registerPubkeyData { - signature?: string; -} - -interface EditUserData { - user?: { - password?: string; - }; -} - -interface UploadDataInArrayData { - data?: { - value?: string; - file?: string; - metadata?: string; - }[]; -} - -interface UploadFileData { - file?: string; -} - -type DataObj = LoginData | CreateUserData | registerPubkeyData | EditUserData | UploadDataInArrayData | UploadFileData; - -function ignoreFieldsHelper(dataObj: DataObj, operationName: LogOperationName) { - if (operationName === 'login') { - delete dataObj['password']; - delete dataObj['totp']; - } else if (operationName === 'createUser') { - delete dataObj['user']['password']; - } else if (operationName === 'registerPubkey') { - delete dataObj['signature']; - } else if (operationName === 'issueAccessToken') { - delete dataObj['signature']; - } else if (operationName === 'editUser') { - delete dataObj['user']['password']; - } else if (operationName === 'uploadDataInArray') { - if (Array.isArray(dataObj['data'])) { - for (let i = 0; i < dataObj['data'].length; i++) { - // only keep the fieldId - delete dataObj['data'][i].value; - delete dataObj['data'][i].file; - delete dataObj['data'][i].metadata; - } - } - } else if (operationName === 'uploadFile') { - delete dataObj['file']; - } - return dataObj; -} - -export const logPlugin = Object.freeze(new LogPlugin()); +export const logPluginInstance = new LogPlugin(db); \ No newline at end of file diff --git a/packages/itmat-interface/src/rest/fileDownload.ts b/packages/itmat-interface/src/rest/fileDownload.ts index c1d10fd61..edc295586 100644 --- a/packages/itmat-interface/src/rest/fileDownload.ts +++ b/packages/itmat-interface/src/rest/fileDownload.ts @@ -1,68 +1,6 @@ -import { Request, Response } from 'express'; import { db } from '../database/database'; +import { FileDownloadController } from '@itmat-broker/itmat-cores'; import { objStore } from '../objStore/objStore'; -import { permissionCore } from '../graphql/core/permissionCore'; -import { atomicOperation, IUser } from '@itmat-broker/itmat-types'; -import jwt from 'jsonwebtoken'; -import { userRetrieval } from '../authentication/pubkeyAuthentication'; -import { ApolloServerErrorCode } from '@apollo/server/errors'; -import { GraphQLError } from 'graphql'; -export const fileDownloadController = (req: Request, res: Response) => { - (async () => { - const requester = req.user as IUser; - const requestedFile = req.params['fileId']; - const token = req.headers.authorization || ''; - let associatedUser = requester; - if ((token !== '') && (req.user === undefined)) { - // get the decoded payload ignoring signature, no symmetric secret or asymmetric key needed - const decodedPayload = jwt.decode(token); - // obtain the public-key of the robot user in the JWT payload - let pubkey: string; - if (decodedPayload !== null && !(typeof decodedPayload === 'string')) { - pubkey = decodedPayload['publicKey']; - } else { - throw new GraphQLError('JWT verification failed.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - // verify the JWT - jwt.verify(token, pubkey, function (error) { - if (error) { - throw new GraphQLError('JWT verification failed.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); - } - }); - associatedUser = await userRetrieval(pubkey); - } else if (!requester) { - res.status(403).json({ error: 'Please log in.' }); - return; - } - try { - /* download file */ - const file = await db.collections.files_collection.findOne({ id: requestedFile, deleted: null }); - if (!file) { - res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); - return; - } - // check target field exists - const hasStudyLevelPermission = await permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - associatedUser, - file.studyId - ); - if (!hasStudyLevelPermission) { - res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); - return; - } - - const stream = await objStore.downloadFile(file.studyId, file.uri); - res.set('Content-Type', 'application/octet-stream'); - res.set('Content-Type', 'application/download'); - res.set('Content-Disposition', `attachment; filename="${file.fileName}"`); - stream.pipe(res, { end: true }); - return; - } catch (e) { - res.status(500).json(e); - return; - } - })().catch(() => { return; }); -}; +export const fileDownloadControllerInstance = new FileDownloadController(db, objStore); \ No newline at end of file diff --git a/packages/itmat-interface/src/server/router.ts b/packages/itmat-interface/src/server/router.ts index 3a4c65415..aa95830e1 100644 --- a/packages/itmat-interface/src/server/router.ts +++ b/packages/itmat-interface/src/server/router.ts @@ -19,19 +19,17 @@ import passport from 'passport'; import { db } from '../database/database'; import { resolvers } from '../graphql/resolvers'; import { typeDefs } from '../graphql/typeDefs'; -import { fileDownloadController } from '../rest/fileDownload'; -import { userLoginUtils } from '../utils/userLoginUtils'; -import { IConfiguration } from '../utils/configManager'; -import { logPlugin } from '../log/logPlugin'; -import { spaceFixing } from '../utils/regrex'; +import { fileDownloadControllerInstance } from '../rest/fileDownload'; import { BigIntResolver as scalarResolvers } from 'graphql-scalars'; import jwt from 'jsonwebtoken'; -import { userRetrieval } from '../authentication/pubkeyAuthentication'; import { createProxyMiddleware, RequestHandler } from 'http-proxy-middleware'; import qs from 'qs'; import { IUser } from '@itmat-broker/itmat-types'; import { ApolloServerContext } from '../graphql/ApolloServerContext'; import { DMPContext } from '../graphql/resolvers/context'; +import { logPluginInstance } from '../log/logPlugin'; +import { IConfiguration, spaceFixing, userRetrieval } from '@itmat-broker/itmat-cores'; +import { userLoginUtils } from '../utils/userLoginUtils'; export class Router { private readonly app: Express; @@ -122,7 +120,7 @@ export class Router { plugins: [ { async serverWillStart() { - await logPlugin.serverWillStartLogPlugin(); + await logPluginInstance.serverWillStartLogPlugin(); return { async drainServer() { await serverCleanup.dispose(); @@ -137,7 +135,7 @@ export class Router { requestContext.request.variables = operation ? spaceFixing(operation, actionData) : undefined; }, async willSendResponse(requestContext) { - await logPlugin.requestDidStartLogPlugin(requestContext); + await logPluginInstance.requestDidStartLogPlugin(requestContext); } }; } @@ -236,7 +234,7 @@ export class Router { } }); // store the associated user with the JWT to context - const associatedUser = await userRetrieval(pubkey); + const associatedUser = await userRetrieval(db, pubkey); req.user = associatedUser; } return ({ req, res }); @@ -267,7 +265,7 @@ export class Router { // next(); // }); - this.app.get('/file/:fileId', fileDownloadController); + this.app.get('/file/:fileId', fileDownloadControllerInstance.fileDownloadController); } diff --git a/packages/itmat-interface/src/server/server.ts b/packages/itmat-interface/src/server/server.ts index a743bcf92..88dff204d 100644 --- a/packages/itmat-interface/src/server/server.ts +++ b/packages/itmat-interface/src/server/server.ts @@ -1,5 +1,5 @@ import { CustomError, IServerBaseConfig, Logger, ServerBase } from '@itmat-broker/itmat-commons'; -import { IConfiguration } from '../utils/configManager'; +import { IConfiguration } from '@itmat-broker/itmat-cores'; export interface IServerConfig extends IServerBaseConfig { bcrypt: { diff --git a/packages/itmat-interface/src/utils/configManager.ts b/packages/itmat-interface/src/utils/configManager.ts index 9ccf7ecbe..bb8a90166 100644 --- a/packages/itmat-interface/src/utils/configManager.ts +++ b/packages/itmat-interface/src/utils/configManager.ts @@ -1,45 +1,4 @@ -import merge from 'deepmerge'; -import fs from 'fs-extra'; import path from 'path'; -import { IObjectStoreConfig, IDatabaseBaseConfig, Logger } from '@itmat-broker/itmat-commons'; -import configDefaults from '../../config/config.sample.json'; -import { IServerConfig } from '../server/server.js'; -import chalk from 'chalk'; -import SMTPTransport from 'nodemailer/lib/smtp-transport'; - -export interface IConfiguration extends IServerConfig { - appName: string; - database: IDatabaseBaseConfig; - objectStore: IObjectStoreConfig; - nodemailer: SMTPTransport.Options & { auth: { user: string, pass: string } }; - aesSecret: string; - sessionsSecret: string; - adminEmail: string; - aeEndpoint: string; -} - -class ConfigurationManager { - - public static expand(configurationFiles: string[]): IConfiguration { - - let config = configDefaults; - Logger.log('Applied default configuration.'); - - configurationFiles.forEach((configurationFile) => { - try { - if (fs.existsSync(configurationFile)) { - const content = fs.readFileSync(configurationFile, 'utf8'); - config = merge(config, JSON.parse(content)); - Logger.log(`Applied configuration from ${path.resolve(configurationFile)}.`); - } - } catch (e) { - Logger.error(chalk.red('Cannot parse configuration file.')); - } - }); - - return config; - } - -} +import { ConfigurationManager } from '@itmat-broker/itmat-cores'; export default ConfigurationManager.expand((process.env['NODE_ENV'] === 'development' ? [path.join(__dirname.replace('dist', ''), 'config/config.json')] : []).concat(['config/config.json'])); diff --git a/packages/itmat-interface/src/utils/userLoginUtils.ts b/packages/itmat-interface/src/utils/userLoginUtils.ts index d05c3c39a..bd227874b 100644 --- a/packages/itmat-interface/src/utils/userLoginUtils.ts +++ b/packages/itmat-interface/src/utils/userLoginUtils.ts @@ -1,27 +1,4 @@ -import { IUser, IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { UserLoginUtils } from '@itmat-broker/itmat-cores'; import { db } from '../database/database'; -export class UserLoginUtils { - constructor() { - this.serialiseUser = this.serialiseUser.bind(this); - this.deserialiseUser = this.deserialiseUser.bind(this); - } - - public serialiseUser(user: Express.User, done: (__unused__err: unknown, __unused__id: string) => void) { - done(null, (user as IUser).username); - } - - public deserialiseUser(username: string, done: (__unused__err: unknown, __unused__id: IUserWithoutToken | null) => void) { - this._getUser(username) - .then(user => { - done(null, user); - }) - .catch(() => { return; }); - } - - private async _getUser(username: string): Promise { - return await db.collections.users_collection.findOne({ deleted: null, username }, { projection: { _id: 0, deleted: 0, password: 0 } }); - } -} - -export const userLoginUtils = Object.freeze(new UserLoginUtils()); +export const userLoginUtils = Object.freeze(new UserLoginUtils(db)); diff --git a/packages/itmat-interface/test/serverTests/_loginHelper.ts b/packages/itmat-interface/test/serverTests/_loginHelper.ts index e804b9cc7..42208a60c 100644 --- a/packages/itmat-interface/test/serverTests/_loginHelper.ts +++ b/packages/itmat-interface/test/serverTests/_loginHelper.ts @@ -1,7 +1,7 @@ import { print } from 'graphql'; import { LOGIN, LOGOUT } from '@itmat-broker/itmat-models'; -import * as mfa from '../../src/utils/mfa'; import { SuperTest, Test } from 'supertest'; +import { generateTOTP } from '@itmat-broker/itmat-cores'; export async function connectAdmin(agent: SuperTest): Promise { const adminSecret = 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA'; @@ -14,7 +14,7 @@ export async function connectUser(agent: SuperTest): Promise { } export async function connectAgent(agent: SuperTest, user: string, pw: string, secret: string): Promise { - const otp = mfa.generateTOTP(secret).toString(); + const otp = generateTOTP(secret).toString(); return agent.post('/graphql') .set('Content-type', 'application/json') .send({ diff --git a/packages/itmat-interface/test/serverTests/file.test.ts b/packages/itmat-interface/test/serverTests/file.test.ts index a754c7914..9093d53c7 100644 --- a/packages/itmat-interface/test/serverTests/file.test.ts +++ b/packages/itmat-interface/test/serverTests/file.test.ts @@ -12,7 +12,7 @@ import { objStore } from '../../src/objStore/objStore'; import { Router } from '../../src/server/router'; import path from 'path'; import { v4 as uuid } from 'uuid'; -import { errorCodes } from '../../src/graphql/errors'; +import { errorCodes } from '@itmat-broker/itmat-cores'; import { Db, MongoClient } from 'mongodb'; import { studyType, IStudy, IUser, IRole, IFile, IFieldEntry, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; import { UPLOAD_FILE, CREATE_STUDY, DELETE_FILE } from '@itmat-broker/itmat-models'; diff --git a/packages/itmat-interface/test/serverTests/job.test.ts b/packages/itmat-interface/test/serverTests/job.test.ts index 261a0723c..116928ed6 100644 --- a/packages/itmat-interface/test/serverTests/job.test.ts +++ b/packages/itmat-interface/test/serverTests/job.test.ts @@ -7,7 +7,7 @@ import { connectAdmin, connectUser, connectAgent } from './_loginHelper'; import { db } from '../../src/database/database'; import { Router } from '../../src/server/router'; import { v4 as uuid } from 'uuid'; -import { errorCodes } from '../../src/graphql/errors'; +import { errorCodes } from '@itmat-broker/itmat-cores'; import { Db, MongoClient } from 'mongodb'; import { IJobEntry, IUser, IRole, IStudy, IProject, IQueryEntry, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; import { CREATE_QUERY_CURATION_JOB } from '@itmat-broker/itmat-models'; diff --git a/packages/itmat-interface/test/serverTests/log.test.ts b/packages/itmat-interface/test/serverTests/log.test.ts index cd0c8687e..b3c1e20a5 100644 --- a/packages/itmat-interface/test/serverTests/log.test.ts +++ b/packages/itmat-interface/test/serverTests/log.test.ts @@ -11,8 +11,7 @@ import { Db, MongoClient } from 'mongodb'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { setupDatabase } from '@itmat-broker/itmat-setup'; import config from '../../config/config.sample.json'; -import { errorCodes } from '../../src/graphql/errors'; -import * as mfa from '../../src/utils/mfa'; +import { errorCodes, generateTOTP } from '@itmat-broker/itmat-cores'; import { GET_LOGS, LOGIN, DELETE_USER } from '@itmat-broker/itmat-models'; import { userTypes, IUser, ILogEntry, LOG_STATUS, LOG_ACTION, LOG_TYPE, USER_AGENT } from '@itmat-broker/itmat-types'; import { Express } from 'express'; @@ -87,7 +86,7 @@ describe('LOG API', () => { }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const newloggedoutuser = request.agent(app); - const otp = mfa.generateTOTP(userSecret).toString(); + const otp = generateTOTP(userSecret).toString(); const res = await newloggedoutuser.post('/graphql').set('Content-type', 'application/json').send({ query: print(LOGIN), variables: { diff --git a/packages/itmat-interface/test/serverTests/permission.test.ts b/packages/itmat-interface/test/serverTests/permission.test.ts index 103318e00..9ee8ac180 100644 --- a/packages/itmat-interface/test/serverTests/permission.test.ts +++ b/packages/itmat-interface/test/serverTests/permission.test.ts @@ -6,7 +6,7 @@ import { print } from 'graphql'; import { connectAdmin, connectUser, connectAgent } from './_loginHelper'; import { db } from '../../src/database/database'; import { Router } from '../../src/server/router'; -import { errorCodes } from '../../src/graphql/errors'; +import { errorCodes } from '@itmat-broker/itmat-cores'; import { Db, MongoClient } from 'mongodb'; import { IUser, IStudy, IProject, IRole, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; import { ADD_NEW_ROLE, EDIT_ROLE, REMOVE_ROLE } from '@itmat-broker/itmat-models'; diff --git a/packages/itmat-interface/test/serverTests/standardization.test.ts b/packages/itmat-interface/test/serverTests/standardization.test.ts index 9f4bc66be..ea3aa8254 100644 --- a/packages/itmat-interface/test/serverTests/standardization.test.ts +++ b/packages/itmat-interface/test/serverTests/standardization.test.ts @@ -6,7 +6,7 @@ import { print } from 'graphql'; import { connectAdmin, connectUser, connectAgent } from './_loginHelper'; import { db } from '../../src/database/database'; import { Router } from '../../src/server/router'; -import { errorCodes } from '../../src/graphql/errors'; +import { errorCodes } from '@itmat-broker/itmat-cores'; import { Db, MongoClient } from 'mongodb'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { setupDatabase } from '@itmat-broker/itmat-setup'; diff --git a/packages/itmat-interface/test/serverTests/study.test.ts b/packages/itmat-interface/test/serverTests/study.test.ts index 9152e9990..c761c003c 100644 --- a/packages/itmat-interface/test/serverTests/study.test.ts +++ b/packages/itmat-interface/test/serverTests/study.test.ts @@ -8,7 +8,7 @@ import { print } from 'graphql'; import { connectAdmin, connectUser, connectAgent } from './_loginHelper'; import { db } from '../../src/database/database'; import { Router } from '../../src/server/router'; -import { errorCodes } from '../../src/graphql/errors'; +import { errorCodes } from '@itmat-broker/itmat-cores'; import { Db, MongoClient } from 'mongodb'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { setupDatabase } from '@itmat-broker/itmat-setup'; @@ -407,7 +407,7 @@ if (global.hasMinio) { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + expect(res.body.errors[0].message).toBe('Study does not exist.'); expect(res.body.data.deleteStudy).toEqual(null); }); @@ -418,7 +418,7 @@ if (global.hasMinio) { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + expect(res.body.errors[0].message).toBe('Study does not exist.'); expect(res.body.data.deleteStudy).toEqual(null); }); diff --git a/packages/itmat-interface/test/serverTests/users.test.ts b/packages/itmat-interface/test/serverTests/users.test.ts index 49191b53b..99ff2bc3b 100644 --- a/packages/itmat-interface/test/serverTests/users.test.ts +++ b/packages/itmat-interface/test/serverTests/users.test.ts @@ -6,15 +6,13 @@ import request, { SuperAgentTest } from 'supertest'; import { print } from 'graphql'; import { connectAdmin, connectUser, connectAgent } from './_loginHelper'; import { db } from '../../src/database/database'; -import { makeAESIv, makeAESKeySalt, encryptEmail } from '../../src/graphql/resolvers/userResolvers'; import { v4 as uuid } from 'uuid'; import { Router } from '../../src/server/router'; -import { errorCodes } from '../../src/graphql/errors'; import { MongoClient, Db } from 'mongodb'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { setupDatabase } from '@itmat-broker/itmat-setup'; import config from '../../config/config.sample.json'; -import * as mfa from '../../src/utils/mfa'; +import { errorCodes, generateTOTP, encryptEmail, makeAESKeySalt, makeAESIv } from '@itmat-broker/itmat-cores'; import { WHO_AM_I, GET_USERS, @@ -109,7 +107,7 @@ describe('USERS API', () => { beforeAll(async () => { loggedoutUser = request.agent(app); encryptedEmailForStandardUser = - await encryptEmail(SEED_STANDARD_USER_EMAIL, makeAESKeySalt(presetToken), makeAESIv(presetToken)); + await encryptEmail(config.aesSecret, SEED_STANDARD_USER_EMAIL, makeAESKeySalt(presetToken), makeAESIv(presetToken)); }); test('Request reset password with non-existent user providing username', async () => { @@ -692,7 +690,7 @@ describe('USERS API', () => { }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const newloggedoutuser = request.agent(app); - const otp = mfa.generateTOTP(userSecret).toString(); + const otp = generateTOTP(userSecret).toString(); const res = await newloggedoutuser.post('/graphql').set('Content-type', 'application/json').send({ query: print(LOGIN), variables: { @@ -737,7 +735,7 @@ describe('USERS API', () => { }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const newloggedoutuser = request.agent(app); - const otp = mfa.generateTOTP(adminSecret).toString(); + const otp = generateTOTP(adminSecret).toString(); const res = await newloggedoutuser.post('/graphql').set('Content-type', 'application/json').send({ query: print(LOGIN), variables: { @@ -1104,7 +1102,7 @@ describe('USERS API', () => { .collection(config.database.collections.users_collection) .findOne({ username: 'testuser0' })); - const incorrectTotp = mfa.generateTOTP(createdUser.otpSecret) + 1; + const incorrectTotp = generateTOTP(createdUser.otpSecret) + 1; const res_login = await admin.post('/graphql') .set('Content-type', 'application/json') .send({ diff --git a/packages/itmat-job-executor/src/utils/configManager.ts b/packages/itmat-job-executor/src/utils/configManager.ts index 7170cb8c8..fc4bbf5f7 100644 --- a/packages/itmat-job-executor/src/utils/configManager.ts +++ b/packages/itmat-job-executor/src/utils/configManager.ts @@ -1,7 +1,7 @@ import merge from 'deepmerge'; import fs from 'fs-extra'; import path from 'path'; -import { IObjectStoreConfig, IDatabaseBaseConfig } from '@itmat-broker/itmat-commons'; +import { IObjectStoreConfig, IDatabaseBaseConfig, Logger } from '@itmat-broker/itmat-commons'; import configDefaults from '../../config/config.sample.json'; import { IServerConfig } from '../server/server.js'; @@ -15,17 +15,17 @@ class ConfigurationManager { public static expand(configurationFiles: string[]): IConfiguration { let config = configDefaults; - console.log('Applied default configuration.'); + Logger.log('Applied default configuration.'); configurationFiles.forEach((configurationFile) => { try { if (fs.existsSync(configurationFile)) { const content = fs.readFileSync(configurationFile, 'utf8'); config = merge(config, JSON.parse(content)); - console.log(`Applied configuration from ${path.resolve(configurationFile)}.`); + Logger.log(`Applied configuration from ${path.resolve(configurationFile)}.`); } } catch (e) { - console.error('Could not parse configuration file.'); + Logger.error('Could not parse configuration file.'); } }); diff --git a/packages/itmat-ui-react/src/components/log/logList.tsx b/packages/itmat-ui-react/src/components/log/logList.tsx index 81f6243a5..c05bb4a6f 100644 --- a/packages/itmat-ui-react/src/components/log/logList.tsx +++ b/packages/itmat-ui-react/src/components/log/logList.tsx @@ -91,7 +91,6 @@ const LogList: FunctionComponent<{ list: ILogEntry[] }> = ({ list }) => { Object.keys(logCopy).forEach(item => { logCopy[item] = (logCopy[item] || '').toString(); }); - console.log(inputs); return (searchTerm === '' || (logCopy.requesterName.toUpperCase().search(searchTerm) > -1 || logCopy.requesterType.toUpperCase().search(searchTerm) > -1 || logCopy.logType.toUpperCase().search(searchTerm) > -1 || logCopy.actionType.toUpperCase().search(searchTerm) > -1 || logCopy.status.toUpperCase().search(searchTerm) > -1 || logCopy.userAgent.toUpperCase().search(searchTerm) > -1 diff --git a/tsconfig.base.json b/tsconfig.base.json index bf400e0a0..d39bb4f21 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -33,6 +33,9 @@ ], "@itmat-broker/itmat-types": [ "packages/itmat-types/src/index.ts" + ], + "@itmat-broker/itmat-cores": [ + "packages/itmat-cores/src/index.ts" ] } }, diff --git a/yarn.lock b/yarn.lock index cb2cb164d..eb39b36a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,7 +63,7 @@ "@ant-design/plots@2.2.0": version "2.2.0" - resolved "https://registry.npmjs.org/@ant-design/plots/-/plots-2.2.0.tgz" + resolved "https://registry.yarnpkg.com/@ant-design/plots/-/plots-2.2.0.tgz#9606de1dfd5ca0297c38a77ea483815d7417c2f5" integrity sha512-tCsOyPwxQ8xr5wMOWrFO3CEgdboXxMzMPrydZ7szXULi8LzztO9HHrg5WClwjAr9Zgfit0fHjmPd1FkIBP4k7A== dependencies: "@ant-design/charts-util" "0.0.1-alpha.5" From ec86d525247185549fb84c07d35fb9dfeecbfa4a Mon Sep 17 00:00:00 2001 From: Siyao Wang Date: Wed, 5 Jun 2024 16:46:45 +0100 Subject: [PATCH 06/12] fix(std): Fix the issue that empty data will be pushed into std pipeline --- packages/itmat-cores/src/utils/query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/itmat-cores/src/utils/query.ts b/packages/itmat-cores/src/utils/query.ts index 2e561680e..583464319 100644 --- a/packages/itmat-cores/src/utils/query.ts +++ b/packages/itmat-cores/src/utils/query.ts @@ -238,7 +238,7 @@ export function standardize(study: IStudy, fields: IFieldEntry[], data: IGrouped } for (const subjectId of Object.keys(data)) { for (const visitId of Object.keys(data[subjectId])) { - if (data[subjectId][visitId][field.fieldId] === null) { + if (data[subjectId][visitId][field.fieldId] === null || data[subjectId][visitId][field.fieldId] === undefined) { continue; } const dataClip: Record = {}; From 07709bc32342b3761aae61843a0e385c07b09865 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:12:01 +0000 Subject: [PATCH 07/12] chore(deps): bump express from 4.18.2 to 4.19.2 Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2) --- updated-dependencies: - dependency-name: express dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index eb39b36a1..9bfcd01b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4692,7 +4692,7 @@ bn.js@^4.0.0: body-parser@1.20.2: version "1.20.2" - resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== dependencies: bytes "3.1.2" @@ -6703,7 +6703,7 @@ express-session@1.18.0: express@^4.17.1, express@^4.17.3, express@^4.19.2: version "4.19.2" - resolved "https://registry.npmjs.org/express/-/express-4.19.2.tgz" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" @@ -10730,7 +10730,7 @@ range-parser@^1.2.1, range-parser@~1.2.1: raw-body@2.5.2: version "2.5.2" - resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== dependencies: bytes "3.1.2" From c8c8d48b6a876bfe603327be148cfab3f5908978 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:11:41 +0000 Subject: [PATCH 08/12] chore(deps): bump express in /packages/itmat-docker Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2) --- updated-dependencies: - dependency-name: express dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- packages/itmat-docker/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/itmat-docker/package.json b/packages/itmat-docker/package.json index ea859f6b1..d972f5503 100644 --- a/packages/itmat-docker/package.json +++ b/packages/itmat-docker/package.json @@ -17,7 +17,7 @@ "dependencies": { "bcrypt": "5.1.1", "compression": "1.7.4", - "express": "4.18.2", + "express": "4.19.2", "express-rate-limit": "7.1.2", "isobject": "4.0.0", "minio": "7.1.3", From 4a18f5af38989433f6ef557da45a408697f1006d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:14:37 +0000 Subject: [PATCH 09/12] chore(deps): bump tar from 6.2.0 to 6.2.1 Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.0 to 6.2.1. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v6.2.0...v6.2.1) --- updated-dependencies: - dependency-name: tar dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 9bfcd01b5..1fd969423 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12478,7 +12478,7 @@ tar-stream@~2.2.0: tar@^6.1.11: version "6.2.1" - resolved "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== dependencies: chownr "^2.0.0" From 3eb289d1a3dfa83cfd393318fa912d3e52bcaa1e Mon Sep 17 00:00:00 2001 From: Siyao Wang Date: Tue, 11 Jun 2024 13:36:07 +0100 Subject: [PATCH 10/12] chore: Change version to 2.6.0 --- SECURITY.md | 2 +- package.json | 2 +- packages/itmat-docker/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 62844fc8f..b6db843eb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ currently being supported with security updates. | Version | Supported | | ------- | ------------------ | -| 2.5.1 | :white_check_mark: | +| 2.6.0 | :white_check_mark: | | < 2 | :x: | ## Reporting a Vulnerability diff --git a/package.json b/package.json index b60383ea5..4242d7ffe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itmat-broker", - "version": "2.5.1", + "version": "2.6.0", "private": true, "license": "MIT", "scripts": { diff --git a/packages/itmat-docker/package.json b/packages/itmat-docker/package.json index d972f5503..c56ae160f 100644 --- a/packages/itmat-docker/package.json +++ b/packages/itmat-docker/package.json @@ -1,6 +1,6 @@ { "name": "@itmat-broker/itmat-setup", - "version": "2.5.1", + "version": "2.6.0", "description": "ITMAT Broker", "keywords": [ "itmat", From 6f013e117d72a35b6786aa03aa7074ad557c8329 Mon Sep 17 00:00:00 2001 From: Siyao Wang Date: Sun, 26 May 2024 21:47:22 +0100 Subject: [PATCH 11/12] feat(trpc-user): Create the tRPC backend and add user procedures --- package.json | 12 +- .../src/{core => GraphQLCore}/fileCore.ts | 131 +- .../src/{core => GraphQLCore}/jobCore.ts | 0 .../src/{core => GraphQLCore}/logCore.ts | 6 +- .../{core => GraphQLCore}/organisationCore.ts | 20 +- .../{core => GraphQLCore}/permissionCore.ts | 10 +- .../src/{core => GraphQLCore}/pubkeyCore.ts | 0 .../src/{core => GraphQLCore}/queryCore.ts | 0 .../standardizationCore.ts | 0 .../src/{core => GraphQLCore}/studyCore.ts | 772 ++- .../src/{core => GraphQLCore}/userCore.ts | 104 +- .../authentication/pubkeyAuthentication.ts | 2 +- packages/itmat-cores/src/database/database.ts | 8 +- packages/itmat-cores/src/index.ts | 25 +- packages/itmat-cores/src/log/logPlugin.ts | 8 +- packages/itmat-cores/src/rest/fileDownload.ts | 4 +- .../itmat-cores/src/trpcCore/configCore.ts | 57 + packages/itmat-cores/src/trpcCore/fileCore.ts | 251 + .../itmat-cores/src/trpcCore/studyCore.ts | 34 + packages/itmat-cores/src/trpcCore/userCore.ts | 1301 +++++ packages/itmat-cores/src/utils/query.ts | 32 +- .../itmat-cores/src/utils/userLoginUtils.ts | 2 +- .../itmat-interface/config/config.sample.json | 3 +- packages/itmat-interface/express-user.d.ts | 4 +- .../src/graphql/resolvers/index.ts | 2 +- .../src/graphql/resolvers/logResolvers.ts | 4 +- .../src/graphql/resolvers/studyResolvers.ts | 3 - .../src/graphql/resolvers/userResolvers.ts | 6 +- packages/itmat-interface/src/index.ts | 10 +- .../src/server/commonMiddleware.ts | 31 + packages/itmat-interface/src/server/router.ts | 52 +- .../itmat-interface/src/trpc/middleware.ts | 1 + .../itmat-interface/src/trpc/tRPCRouter.ts | 8 + packages/itmat-interface/src/trpc/trpc.ts | 21 + .../itmat-interface/src/trpc/userProcedure.ts | 291 ++ .../_loginHelper.ts | 0 .../file.test.ts | 53 +- .../{serverTests => GraphQLTests}/job.test.ts | 11 +- .../{serverTests => GraphQLTests}/log.test.ts | 18 +- .../permission.test.ts | 134 +- .../standardization.test.ts | 65 +- .../study.test.ts | 1013 ++-- .../users.test.ts | 200 +- .../test/trpcTests/_loginHelper.ts | 42 + .../itmat-interface/test/trpcTests/helper.ts | 3 + .../test/trpcTests/user.test.ts | 476 ++ .../src/database/database.ts | 4 +- packages/itmat-models/src/graphql/fields.ts | 1 - packages/itmat-models/src/graphql/study.ts | 1 - packages/itmat-models/src/graphql/user.ts | 1 - .../databaseSetup/collectionsAndIndexes.ts | 15 +- .../src/databaseSetup/seed/organisations.ts | 23 +- .../src/databaseSetup/seed/users.ts | 21 +- packages/itmat-types/src/types/base.ts | 12 + packages/itmat-types/src/types/config.ts | 212 + packages/itmat-types/src/types/coreErrors.ts | 42 + packages/itmat-types/src/types/data.ts | 10 + packages/itmat-types/src/types/field.ts | 52 +- packages/itmat-types/src/types/file.ts | 59 +- packages/itmat-types/src/types/index.ts | 13 +- packages/itmat-types/src/types/log.ts | 4 +- .../itmat-types/src/types/organisation.ts | 15 +- packages/itmat-types/src/types/pubkey.ts | 6 +- packages/itmat-types/src/types/study.ts | 18 +- packages/itmat-types/src/types/user.ts | 29 +- packages/itmat-types/src/types/utils.ts | 47 + packages/itmat-types/src/types/zod.ts | 9 + packages/itmat-ui-react/proxy.conf.js | 5 + .../src/components/datasetDetail/index.tsx | 4 +- .../tabContent/data/dataSummary.tsx | 2 +- .../datasetDetail/tabContent/data/dataTab.tsx | 4 +- .../tabContent/data/fieldListSelection.tsx | 4 +- .../tabContent/files/fileTab.tsx | 30 +- .../projects/detailSections/projectDetail.tsx | 4 +- .../tabContent/projects/projectTab.tsx | 4 +- .../components/datasetList/addNewDataSet.tsx | 4 +- .../src/components/log/logList.tsx | 6 +- .../src/components/profile/profile.tsx | 2 +- .../src/components/projectDetail/index.tsx | 6 +- .../tabContent/analysis/analysisTab.tsx | 44 +- .../projectDetail/tabContent/data/dataTab.tsx | 28 +- .../reusable/fieldList/fieldList.tsx | 8 +- .../components/reusable/fileList/fileList.tsx | 8 +- .../src/components/scaffold/mainMenuBar.tsx | 10 +- .../src/components/users/userDetails.tsx | 10 +- .../itmat-ui-react/src/utils/logUtils.tsx | 4 +- packages/itmat-ui-react/src/utils/tools.ts | 10 +- yarn.lock | 4247 +++++++++-------- 88 files changed, 6586 insertions(+), 3682 deletions(-) rename packages/itmat-cores/src/{core => GraphQLCore}/fileCore.ts (80%) rename packages/itmat-cores/src/{core => GraphQLCore}/jobCore.ts (100%) rename packages/itmat-cores/src/{core => GraphQLCore}/logCore.ts (89%) rename packages/itmat-cores/src/{core => GraphQLCore}/organisationCore.ts (78%) rename packages/itmat-cores/src/{core => GraphQLCore}/permissionCore.ts (98%) rename packages/itmat-cores/src/{core => GraphQLCore}/pubkeyCore.ts (100%) rename packages/itmat-cores/src/{core => GraphQLCore}/queryCore.ts (100%) rename packages/itmat-cores/src/{core => GraphQLCore}/standardizationCore.ts (100%) rename packages/itmat-cores/src/{core => GraphQLCore}/studyCore.ts (80%) rename packages/itmat-cores/src/{core => GraphQLCore}/userCore.ts (89%) create mode 100644 packages/itmat-cores/src/trpcCore/configCore.ts create mode 100644 packages/itmat-cores/src/trpcCore/fileCore.ts create mode 100644 packages/itmat-cores/src/trpcCore/studyCore.ts create mode 100644 packages/itmat-cores/src/trpcCore/userCore.ts create mode 100644 packages/itmat-interface/src/server/commonMiddleware.ts create mode 100644 packages/itmat-interface/src/trpc/middleware.ts create mode 100644 packages/itmat-interface/src/trpc/tRPCRouter.ts create mode 100644 packages/itmat-interface/src/trpc/trpc.ts create mode 100644 packages/itmat-interface/src/trpc/userProcedure.ts rename packages/itmat-interface/test/{serverTests => GraphQLTests}/_loginHelper.ts (100%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/file.test.ts (95%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/job.test.ts (97%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/log.test.ts (91%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/permission.test.ts (97%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/standardization.test.ts (97%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/study.test.ts (85%) rename packages/itmat-interface/test/{serverTests => GraphQLTests}/users.test.ts (93%) create mode 100644 packages/itmat-interface/test/trpcTests/_loginHelper.ts create mode 100644 packages/itmat-interface/test/trpcTests/helper.ts create mode 100644 packages/itmat-interface/test/trpcTests/user.test.ts create mode 100644 packages/itmat-types/src/types/base.ts create mode 100644 packages/itmat-types/src/types/config.ts create mode 100644 packages/itmat-types/src/types/coreErrors.ts create mode 100644 packages/itmat-types/src/types/utils.ts create mode 100644 packages/itmat-types/src/types/zod.ts diff --git a/package.json b/package.json index 4242d7ffe..fa18702af 100644 --- a/package.json +++ b/package.json @@ -97,13 +97,15 @@ }, "dependencies": { "@ant-design/icons": "5.3.6", - "@ant-design/plots": "2.2.0", + "@ant-design/plots": "2.2.2", "@apollo/client": "3.9.11", "@apollo/server": "4.10.4", "@commitlint/config-conventional": "^18.6.2", "@commitlint/config-nx-scopes": "^18.6.1", "@ideafast/idgen": "0.1.1", "@swc/helpers": "0.5.10", + "@trpc/server": "10.45.2", + "JSONStream": "1.3.5", "antd": "5.16.4", "apollo-upload-client": "18.0.1", "axios": "1.6.8", @@ -114,7 +116,7 @@ "core-js": "^3.37.0", "cors": "2.8.5", "csv-parse": "5.5.5", - "dayjs": "1.11.10", + "dayjs": "1.11.11", "deepmerge": "4.3.1", "esbuild": "^0.20.2", "export-from-json": "1.7.4", @@ -132,16 +134,17 @@ "hi-base32": "0.5.1", "http-proxy-middleware": "3.0.0", "https-browserify": "1.0.0", - "JSONStream": "1.3.5", "jsonwebtoken": "9.0.2", "jstat": "1.9.6", "lint-staged": "15.2.2", + "lodash-es": "4.17.21", "minio": "7.1.3", "mongodb": "6.5.0", "nodemailer": "6.9.13", "passport": "0.7.0", "path-browserify": "1.0.1", "qrcode": "1.5.3", + "rc-picker": "4.5.0", "react": "18.2.0", "react-csv": "2.2.2", "react-dom": "18.2.0", @@ -156,7 +159,8 @@ "tmp": "0.2.3", "tslib": "^2.6.2", "url": "0.11.3", - "uuid": "9.0.1" + "uuid": "9.0.1", + "zod": "3.23.8" }, "resolutions": { "**/@jest/create-cache-key-function": "^29", diff --git a/packages/itmat-cores/src/core/fileCore.ts b/packages/itmat-cores/src/GraphQLCore/fileCore.ts similarity index 80% rename from packages/itmat-cores/src/core/fileCore.ts rename to packages/itmat-cores/src/GraphQLCore/fileCore.ts index 51d71d7d5..840d31d1c 100644 --- a/packages/itmat-cores/src/core/fileCore.ts +++ b/packages/itmat-cores/src/GraphQLCore/fileCore.ts @@ -1,4 +1,4 @@ -import { IDataEntry, IFile, IOrganisation, IPermissionManagementOptions, IUserWithoutToken, atomicOperation, deviceTypes } from '@itmat-broker/itmat-types'; +import { IFile, IPermissionManagementOptions, IUserWithoutToken, atomicOperation, deviceTypes, enumFileCategories, enumFileTypes } from '@itmat-broker/itmat-types'; import { v4 as uuid } from 'uuid'; import { DBType } from '../database/database'; import { FileUpload } from 'graphql-upload-minimal'; @@ -7,7 +7,6 @@ import { validate } from '@ideafast/idgen'; import { fileSizeLimit } from '../utils/definition'; import crypto from 'crypto'; import { makeGenericReponse } from '../utils/responses'; -import { MatchKeysAndValues } from 'mongodb'; import { errorCodes } from '../utils/errors'; import { PermissionCore } from './permissionCore'; import { StudyCore } from './studyCore'; @@ -49,13 +48,14 @@ export class FileCore { let targetFieldId: string; let isStudyLevel = false; - // obtain sitesIDMarker from db - const sitesIDMarkers = (await this.db.collections.organisations_collection.find({ deleted: null }).toArray()).reduce>((acc, curr) => { - if (curr.metadata?.siteIDMarker) { - acc[curr.metadata.siteIDMarker] = curr.shortname; + const organisations = await this.db.collections.organisations_collection.find({ 'life.deletedTime': null }).toArray(); + const sitesIDMarkers: Record = organisations.reduce((acc, curr) => { + if (curr.metadata['siteIDMarker']) { + acc[String(curr.metadata['siteIDMarker'])] = curr.shortname ?? curr.name; } return acc; - }, {}); + }, {} as Record); + // if the description object is empty, then the file is study-level data // otherwise, a subjectId must be provided in the description object // we will check other properties in the decription object (deviceId, startDate, endDate) @@ -100,7 +100,7 @@ export class FileCore { const file_ = await file; const fileNameParts = file_.filename.split('.'); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { (async () => { try { let fileName: string = file_.filename; @@ -202,42 +202,56 @@ export class FileCore { reject(new GraphQLError('File size mismatch', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); return; } + const fileParts: string[] = file_.filename.split('.'); + const fileExtension = fileParts.length === 1 ? 'UNKNOWN' : fileParts[fileParts.length - 1].trim().toLowerCase(); const fileEntry: IFile = { id: uuid(), - fileName: fileName, studyId: studyId, + userId: null, + fileName: fileName, + fileSize: readBytes, description: description, - uploadTime: `${Date.now()}`, - uploadedBy: requester.id, - deleted: null, - metadata: metadata, - fileSize: readBytes.toString(), + properties: {}, uri: fileUri, - hash: hashString + hash: hashString, + fileType: fileExtension in enumFileTypes ? enumFileTypes[fileExtension] : enumFileTypes.UNKNOWN, + fileCategory: enumFileCategories.STUDY_DATA_FILE, + sharedUsers: [], + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: metadata }; - fileEntry.fileSize = readBytes.toString(); - fileEntry.uri = fileUri; - fileEntry.hash = hashString; if (!isStudyLevel) { await this.db.collections.data_collection.insertOne({ id: uuid(), - m_studyId: studyId, - m_subjectId: parsedDescription.participantId, - m_versionId: null, - m_visitId: targetVisitId, - m_fieldId: targetFieldId, - value: '', - uploadedAt: (new Date()).valueOf(), - metadata: { - 'uploader:user': requester.id, - 'add': [fileEntry.id], - 'remove': [] - } + studyId: studyId, + fieldId: targetFieldId, + dataVersion: null, + value: fileEntry.id, + properties: { + m_subjectId: parsedDescription.participantId, + m_visitId: targetVisitId + }, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} }); } const insertResult = await this.db.collections.files_collection.insertOne(fileEntry); if (insertResult.acknowledged) { - resolve(fileEntry); + resolve({ + ...fileEntry, + uploadTime: fileEntry.life.createdTime, + uploadedBy: requester.id + }); } else { throw new GraphQLError(errorCodes.DATABASE_ERROR); } @@ -255,7 +269,7 @@ export class FileCore { } const file = await this.db.collections.files_collection.findOne({ deleted: null, id: fileId }); - if (!file) { + if (!file || !file.studyId || !file.description) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } const hasStudyLevelPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( @@ -278,44 +292,21 @@ export class FileCore { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } // update data record - const obj = { - m_studyId: file.studyId, - m_subjectId: parsedDescription.participantId, - m_versionId: null, - m_visitId: targetVisitId, - m_fieldId: targetFieldId - }; - const existing = await this.db.collections.data_collection.findOne(obj); - if (!existing) { - await this.db.collections.data_collection.insertOne({ - ...obj, - id: uuid(), - uploadedAt: (new Date()).valueOf(), - value: '', - metadata: { - add: [], - remove: [] - } - }); - } - const objWithData: Partial> = { - ...obj, + await this.db.collections.data_collection.insertOne({ id: uuid(), - value: '', - uploadedAt: (new Date()).valueOf(), - metadata: { - 'uploader:user': requester.id, - 'add': existing?.metadata?.add ?? [], - 'remove': (existing?.metadata?.remove || []).concat(fileId) - } - }; - const updateResult = await this.db.collections.data_collection.updateOne(obj, { $set: objWithData }, { upsert: true }); - - // const updateResult = await this.db.collections.files_collection.updateOne({ deleted: null, id: args.fileId }, { $set: { deleted: new Date().valueOf() } }); - if (updateResult.modifiedCount === 1 || updateResult.upsertedCount === 1) { - return makeGenericReponse(); - } else { - throw new GraphQLError(errorCodes.DATABASE_ERROR); - } + studyId: file.studyId, + fieldId: targetFieldId, + dataVersion: null, + value: fileId, + properties: file.properties, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: Date.now(), + deletedUser: requester.id + }, + metadata: {} + }); + return makeGenericReponse(file.id); } } \ No newline at end of file diff --git a/packages/itmat-cores/src/core/jobCore.ts b/packages/itmat-cores/src/GraphQLCore/jobCore.ts similarity index 100% rename from packages/itmat-cores/src/core/jobCore.ts rename to packages/itmat-cores/src/GraphQLCore/jobCore.ts diff --git a/packages/itmat-cores/src/core/logCore.ts b/packages/itmat-cores/src/GraphQLCore/logCore.ts similarity index 89% rename from packages/itmat-cores/src/core/logCore.ts rename to packages/itmat-cores/src/GraphQLCore/logCore.ts index a7833a020..d9a0ecae2 100644 --- a/packages/itmat-cores/src/core/logCore.ts +++ b/packages/itmat-cores/src/GraphQLCore/logCore.ts @@ -1,4 +1,4 @@ -import { ILogEntry, IUserWithoutToken, LOG_ACTION, LOG_STATUS, LOG_TYPE, userTypes } from '@itmat-broker/itmat-types'; +import { ILogEntry, IUserWithoutToken, LOG_ACTION, LOG_STATUS, LOG_TYPE, enumUserTypes } from '@itmat-broker/itmat-types'; import { DBType } from '../database/database'; import { GraphQLError } from 'graphql'; import { errorCodes } from '../utils/errors'; @@ -15,12 +15,12 @@ export class LogCore { UPLOAD_FILE: ['file', 'description'] }; - public async getLogs(requester: IUserWithoutToken | undefined, requesterName?: string, requesterType?: userTypes, logType?: LOG_TYPE, actionType?: LOG_ACTION, status?: LOG_STATUS) { + public async getLogs(requester: IUserWithoutToken | undefined, requesterName?: string, requesterType?: enumUserTypes, logType?: LOG_TYPE, actionType?: LOG_ACTION, status?: LOG_STATUS) { if (!requester) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* only admin can access this field */ - if (!(requester.type === userTypes.ADMIN) && !(requester.metadata?.logPermission === true)) { + if (!(requester.type === enumUserTypes.ADMIN) && !(requester.metadata?.['logPermission'] === true)) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } diff --git a/packages/itmat-cores/src/core/organisationCore.ts b/packages/itmat-cores/src/GraphQLCore/organisationCore.ts similarity index 78% rename from packages/itmat-cores/src/core/organisationCore.ts rename to packages/itmat-cores/src/GraphQLCore/organisationCore.ts index 6adf99385..4ec9a9cb9 100644 --- a/packages/itmat-cores/src/core/organisationCore.ts +++ b/packages/itmat-cores/src/GraphQLCore/organisationCore.ts @@ -1,4 +1,4 @@ -import { IOrganisation, IUserWithoutToken, userTypes } from '@itmat-broker/itmat-types'; +import { IOrganisation, IUserWithoutToken, enumUserTypes } from '@itmat-broker/itmat-types'; import { DBType } from '../database/database'; import { GraphQLError } from 'graphql'; import { errorCodes } from '../utils/errors'; @@ -15,24 +15,28 @@ export class OrganisationCore { return await this.db.collections.organisations_collection.find(queryObj, { projection: { _id: 0 } }).toArray(); } - public async createOrganisation(requester: IUserWithoutToken | undefined, org: { name: string, shortname: string | null, containOrg: string | null, metadata }): Promise { + public async createOrganisation(requester: IUserWithoutToken | undefined, org: { name: string, shortname: string | undefined, containOrg: string | null, metadata }): Promise { if (!requester) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const { name, shortname, containOrg, metadata } = org; + const { name, shortname, metadata } = org; const entry: IOrganisation = { id: uuid(), name, shortname, - containOrg, - deleted: null, metadata: metadata?.siteIDMarker ? { siteIDMarker: metadata.siteIDMarker - } : {} + } : {}, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + } }; const result = await this.db.collections.organisations_collection.findOneAndUpdate({ name: name, deleted: null }, { $set: entry @@ -51,7 +55,7 @@ export class OrganisationCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } diff --git a/packages/itmat-cores/src/core/permissionCore.ts b/packages/itmat-cores/src/GraphQLCore/permissionCore.ts similarity index 98% rename from packages/itmat-cores/src/core/permissionCore.ts rename to packages/itmat-cores/src/GraphQLCore/permissionCore.ts index 174bfbe98..dfcbe1a4b 100644 --- a/packages/itmat-cores/src/core/permissionCore.ts +++ b/packages/itmat-cores/src/GraphQLCore/permissionCore.ts @@ -1,5 +1,5 @@ import { GraphQLError } from 'graphql'; -import { atomicOperation, IDataEntry, IDataPermission, IManagementPermission, IPermissionManagementOptions, IRole, IUserWithoutToken, userTypes } from '@itmat-broker/itmat-types'; +import { atomicOperation, IDataEntry, IDataPermission, IManagementPermission, IPermissionManagementOptions, IRole, IUserWithoutToken, enumUserTypes } from '@itmat-broker/itmat-types'; import { BulkWriteResult, Document, Filter } from 'mongodb'; import { v4 as uuid } from 'uuid'; import { DBType } from '../database/database'; @@ -62,7 +62,7 @@ export class PermissionCore { } /* if user is an admin then return true if admin privileges includes needed permissions */ - if (user.type === userTypes.ADMIN) { + if (user.type === enumUserTypes.ADMIN) { return true; } const tag = `permissions.manage.${type}`; @@ -77,7 +77,7 @@ export class PermissionCore { } public async combineUserDataPermissions(operation: string, user: IUserWithoutToken, studyId: string, projectId?: string) { - if (user.type === userTypes.ADMIN) { + if (user.type === enumUserTypes.ADMIN) { const matchAnyString = '^.*$'; return { subjectIds: [matchAnyString], @@ -126,7 +126,7 @@ export class PermissionCore { } const matchAnyString = '^.*$'; /* if user is an admin then return true if admin privileges includes needed permissions */ - if (user.type === userTypes.ADMIN) { + if (user.type === enumUserTypes.ADMIN) { return { matchObj: [], hasVersioned: true, @@ -299,7 +299,7 @@ export class PermissionCore { const testedUser: string[] = []; for (const each of allRequestedUserChanges) { if (!testedUser.includes(each)) { - const user = await this.db.collections.users_collection.findOne({ id: each, deleted: null }); + const user = await this.db.collections.users_collection.findOne({ 'id': each, 'life.deletedTime': null }); if (user === null) { throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); } else { diff --git a/packages/itmat-cores/src/core/pubkeyCore.ts b/packages/itmat-cores/src/GraphQLCore/pubkeyCore.ts similarity index 100% rename from packages/itmat-cores/src/core/pubkeyCore.ts rename to packages/itmat-cores/src/GraphQLCore/pubkeyCore.ts diff --git a/packages/itmat-cores/src/core/queryCore.ts b/packages/itmat-cores/src/GraphQLCore/queryCore.ts similarity index 100% rename from packages/itmat-cores/src/core/queryCore.ts rename to packages/itmat-cores/src/GraphQLCore/queryCore.ts diff --git a/packages/itmat-cores/src/core/standardizationCore.ts b/packages/itmat-cores/src/GraphQLCore/standardizationCore.ts similarity index 100% rename from packages/itmat-cores/src/core/standardizationCore.ts rename to packages/itmat-cores/src/GraphQLCore/standardizationCore.ts diff --git a/packages/itmat-cores/src/core/studyCore.ts b/packages/itmat-cores/src/GraphQLCore/studyCore.ts similarity index 80% rename from packages/itmat-cores/src/core/studyCore.ts rename to packages/itmat-cores/src/GraphQLCore/studyCore.ts index 1f82134a2..d38c4e376 100644 --- a/packages/itmat-cores/src/core/studyCore.ts +++ b/packages/itmat-cores/src/GraphQLCore/studyCore.ts @@ -1,10 +1,10 @@ import { GraphQLError } from 'graphql'; -import { IFile, IProject, IStudy, studyType, IStudyDataVersion, IDataEntry, IDataClip, IRole, IFieldEntry, deviceTypes, IOrganisation, IUserWithoutToken, IPermissionManagementOptions, atomicOperation, userTypes, IOntologyTree, ISubjectDataRecordSummary, IQueryString, IGroupedData, enumValueType, IValueDescription } from '@itmat-broker/itmat-types'; +import { IFile, IProject, IStudy, studyType, IStudyDataVersion, IDataEntry, IDataClip, IRole, IField, deviceTypes, IUserWithoutToken, IPermissionManagementOptions, atomicOperation, enumUserTypes, IOntologyTree, IQueryString, IGroupedData, enumFileTypes, enumFileCategories, enumDataTypes, ICategoricalOption } from '@itmat-broker/itmat-types'; import { v4 as uuid } from 'uuid'; import { errorCodes } from '../utils/errors'; import { ICombinedPermissions, PermissionCore, translateCohort } from './permissionCore'; import { validate } from '@ideafast/idgen'; -import type { Filter, MatchKeysAndValues } from 'mongodb'; +import type { Filter } from 'mongodb'; import { FileUpload } from 'graphql-upload-minimal'; import crypto from 'crypto'; import { fileSizeLimit } from '../utils/definition'; @@ -16,20 +16,20 @@ import { ObjectStore } from '@itmat-broker/itmat-commons'; export interface CreateFieldInput { fieldId: string; fieldName: string - tableName: string - dataType: enumValueType - possibleValues?: IValueDescription[] + tableName?: string + dataType: string; + possibleValues?: ICategoricalOption[] unit?: string comments?: string - metadata: Record + metadata?: Record } export interface EditFieldInput { fieldId: string; fieldName: string; tableName?: string; - dataType: enumValueType; - possibleValues?: IValueDescription[] + dataType: string; + possibleValues?: ICategoricalOption[] unit?: string comments?: string } @@ -73,11 +73,15 @@ export class StudyCore { ); if (!hasPermission) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const study = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); if (study === null || study === undefined) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } - return study; + return { + ...study, + createdBy: study.life.createdUser, + deleted: study.life.deletedTime + }; } public async getProject(requester: IUserWithoutToken | undefined, projectId: string) { @@ -110,6 +114,47 @@ export class StudyCore { return project; } + /** + * This function convert the new field type to the old ones for consistency with the GraphQL schema + */ + public fieldTypeConverter(fields: IField[]) { + return fields.map((field) => { + return { + ...field, + possibleValues: field.categoricalOptions ? field.categoricalOptions.map((el) => { + return { + id: el.id, + code: el.code, + description: el.description + }; + }) : [], + dataType: (() => { + if (field.dataType === enumDataTypes.INTEGER) { + return 'int'; + } else if (field.dataType === enumDataTypes.DECIMAL) { + return 'dec'; + } else if (field.dataType === enumDataTypes.STRING) { + return 'str'; + } else if (field.dataType === enumDataTypes.BOOLEAN) { + return 'bool'; + } else if (field.dataType === enumDataTypes.DATETIME) { + return 'date'; + } else if (field.dataType === enumDataTypes.FILE) { + return 'file'; + } else if (field.dataType === enumDataTypes.JSON) { + return 'json'; + } else if (field.dataType === enumDataTypes.CATEGORICAL) { + return 'cat'; + } else { + return 'str'; + } + })(), + dateAdded: field.life.createdTime.toString(), + dateDeleted: field.life.deletedTime ? field.life.deletedTime.toString() : null + }; + }); + } + public async getStudyFields(requester: IUserWithoutToken | undefined, studyId: string, projectId?: string, versionId?: string | null) { if (!requester) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); @@ -134,14 +179,14 @@ export class StudyCore { // check the metadata:role:**** for versioned data directly const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); // check the regular expressions for unversioned data - if (requester.type === userTypes.ADMIN) { + if (requester.type === enumUserTypes.ADMIN) { if (versionId === null) { availableDataVersions.push(null); } - const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } }, { - $sort: { dateAdded: -1 } + $sort: { 'life.createdTime': -1 } }, { $group: { _id: '$fieldId', @@ -154,15 +199,15 @@ export class StudyCore { }, { $sort: { fieldId: 1 } }]).toArray(); - return fieldRecords.filter(el => el.dateDeleted === null); + return this.fieldTypeConverter(fieldRecords.filter(el => el.life.deletedTime === null)); } // unversioned data could not be returned by metadata filters if (versionId === null && aggregatedPermissions.hasVersioned) { availableDataVersions.push(null); - const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } }, { - $sort: { dateAdded: -1 } + $sort: { 'life.createdTime': -1 } }, { $match: { fieldId: { $in: aggregatedPermissions.raw.fieldIds.map((el: string) => new RegExp(el)) } @@ -179,7 +224,7 @@ export class StudyCore { }, { $sort: { fieldId: 1 } }]).toArray(); - return fieldRecords.filter(el => el.dateDeleted === null); + return this.fieldTypeConverter(fieldRecords.filter(el => el.life.deletedTime === null)); } else { // metadata filter const subqueries: Filter<{ [key: string]: string | number | boolean }>[] = []; @@ -187,10 +232,10 @@ export class StudyCore { subqueries.push(translateMetadata(subMetadata)); }); const metadataFilter = { $or: subqueries }; - const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } }, { - $sort: { dateAdded: -1 } + $sort: { 'life.createdTime': -1 } }, { $match: metadataFilter }, { $group: { _id: '$fieldId', @@ -202,11 +247,10 @@ export class StudyCore { } }, { $sort: { fieldId: 1 } - }, { - $set: { metadata: null } }]).toArray(); - return fieldRecords.filter(el => el.dateDeleted === null); + return this.fieldTypeConverter(fieldRecords.filter(el => el.life.deletedTime === null)); } + return []; } public async getOntologyTree(requester: IUserWithoutToken | undefined, studyId: string, projectId?: string, treeName?: string, versionId?: string) { @@ -278,143 +322,6 @@ export class StudyCore { } } - public async checkDataComplete(requester: IUserWithoutToken | undefined, studyId: string) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* user can get study if he has readonly permission */ - const hasPermission = await this.permissionCore.userHasTheNeccessaryDataPermission( - atomicOperation.READ, - requester, - studyId - ); - if (!hasPermission) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - // we only check data that hasnt been pushed to a new data version - const data: IDataEntry[] = await this.db.collections.data_collection.find({ - m_studyId: studyId, - m_versionId: null, - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } - }).toArray(); - const fieldMapping = (await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId } - }, { - $match: { fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } - }, { - $sort: { dateAdded: -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $last: '$$ROOT' } - } - } - ]).toArray()).map(el => el['doc']).filter(eh => eh.dateDeleted === null).reduce((acc, curr) => { - acc[curr.fieldId] = curr; - return acc; - }, {}); - const summary: ISubjectDataRecordSummary[] = []; - // we will not check data whose fields are not defined, because data that the associated fields are undefined will not be returned while querying data - for (const record of data) { - let error: string | null = null; - if (fieldMapping[record.m_fieldId] !== undefined && fieldMapping[record.m_fieldId] !== null) { - switch (fieldMapping[record.m_fieldId].dataType) { - case 'dec': {// decimal - if (typeof record.value === 'number') { - if (!/^\d+(.\d+)?$/.test(record.value.toString())) { - error = `Field ${record.m_fieldId}: Cannot parse as decimal.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as decimal.`; - break; - } - break; - } - case 'int': {// integer - if (typeof record.value === 'number') { - if (!/^-?\d+$/.test(record.value.toString())) { - error = `Field ${record.m_fieldId}: Cannot parse as integer.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as integer.`; - break; - } - break; - } - case 'bool': {// boolean - if (typeof record.value !== 'boolean') { - error = `Field ${record.m_fieldId}: Cannot parse as boolean.`; - break; - } - break; - } - case 'str': { - break; - } - // 01/02/2021 00:00:00 - case 'date': { - if (typeof record.value === 'string') { - const matcher = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?/; - if (!record.value.match(matcher)) { - error = `Field ${record.m_fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; - break; - } - break; - } - case 'json': { - break; - } - case 'file': { - if (typeof record.value === 'string') { - const file = await this.db.collections.files_collection.findOne({ id: record.value }); - if (!file) { - error = `Field ${record.m_fieldId}: Cannot parse as file or file does not exist.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as file or file does not exist.`; - break; - } - break; - } - case 'cat': { - if (typeof record.value === 'string') { - if (!fieldMapping[record.m_fieldId].possibleValues.map((el) => el.code).includes(record.value.toString())) { - error = `Field ${record.m_fieldId}: Cannot parse as categorical, value not in value list.`; - break; - } - } else { - error = `Field ${record.m_fieldId}: Cannot parse as categorical, value not in value list.`; - break; - } - break; - } - default: { - error = `Field ${record.m_fieldId}: Invalid data Type.`; - break; - } - } - } - error && summary.push({ - subjectId: record.m_subjectId, - visitId: record.m_visitId, - fieldId: record.m_fieldId, - error: error - }); - } - - return summary; - } - public async getDataRecords(requester: IUserWithoutToken | undefined, queryString: IQueryString, studyId: string, versionId: string | null | undefined, projectId?: string) { if (!requester) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); @@ -438,12 +345,12 @@ export class StudyCore { const aggregatedPermissions = this.permissionCore.combineMultiplePermissions([hasStudyLevelPermission, hasProjectLevelPermission]); let availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - let fieldRecords: IFieldEntry[] = []; + let fieldRecords: IField[] = []; let result; let metadataFilter; // we obtain the data by different requests // admin used will not filtered by metadata filters - if (requester.type === userTypes.ADMIN) { + if (requester.type === enumUserTypes.ADMIN) { if (versionId !== undefined) { if (versionId === null) { availableDataVersions.push(null); @@ -454,7 +361,7 @@ export class StudyCore { } } - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } }, { $sort: { dateAdded: -1 } @@ -484,7 +391,7 @@ export class StudyCore { // unversioned data: metadatafilter for versioned data and all unversioned tags if (versionId === null && aggregatedPermissions.hasVersioned) { availableDataVersions.push(null); - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } }, { $sort: { dateAdded: -1 } @@ -514,7 +421,7 @@ export class StudyCore { if (versionId === '-1') { availableDataVersions = availableDataVersions.length !== 0 ? [availableDataVersions[availableDataVersions.length - 1]] : []; } - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } }, { $sort: { dateAdded: -1 } @@ -541,7 +448,7 @@ export class StudyCore { } } else if (versionId !== undefined) { availableDataVersions = [versionId]; - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } }, { $sort: { dateAdded: -1 } @@ -623,8 +530,8 @@ export class StudyCore { return []; } const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const fileFieldIds: string[] = (await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumValueType.FILE } + const fileFieldIds: string[] = (await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumDataTypes.FILE } }, { $match: { fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } }, { $sort: { dateAdded: -1 } }, { @@ -642,9 +549,9 @@ export class StudyCore { let adds: string[] = []; let removes: string[] = []; // versioned data - if (requester.type === userTypes.ADMIN) { + if (requester.type === enumUserTypes.ADMIN) { const fileRecords = await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_fieldId: { $in: fileFieldIds } } + $match: { studyId: study.id, fieldId: { $in: fileFieldIds } } }]).toArray(); adds = fileRecords.map(el => el.metadata?.add || []).flat(); removes = fileRecords.map(el => el.metadata?.remove || []).flat(); @@ -655,7 +562,7 @@ export class StudyCore { }); const metadataFilter = { $or: subqueries }; const versionedFileRecors = await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } + $match: { studyId: study.id, dataVersion: { $in: availableDataVersions }, fieldId: { $in: fileFieldIds } } }, { $match: metadataFilter }]).toArray(); @@ -666,16 +573,16 @@ export class StudyCore { continue; } filters.push({ - m_subjectId: { $in: role.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: role.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: role.fieldIds.map((el: string) => new RegExp(el)) }, - m_versionId: null + 'properties.m_subjectId': { $in: role.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: role.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: role.fieldIds.map((el: string) => new RegExp(el)) }, + 'dataVersion': null }); } let unversionedFileRecords: IDataEntry[] = []; if (filters.length !== 0) { unversionedFileRecords = await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: null, m_fieldId: { $in: fileFieldIds } } + $match: { studyId: study.id, dataVersion: null, fieldId: { $in: fileFieldIds } } }, { $match: { $or: filters } }]).toArray(); @@ -701,21 +608,21 @@ export class StudyCore { return [[], []]; } const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const versionedSubjects = (await this.db.collections.data_collection.distinct('m_subjectId', { - m_studyId: study.id, - m_versionId: availableDataVersions[availableDataVersions.length - 1], - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } + const versionedSubjects = (await this.db.collections.data_collection.distinct('properties.m_subjectId', { + 'studyId': study.id, + 'dataVersion': availableDataVersions[availableDataVersions.length - 1], + 'properties.m_subjectId': { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + 'value': { $ne: null } })).sort() || []; - const unVersionedSubjects = hasPermission.hasVersioned ? (await this.db.collections.data_collection.distinct('m_subjectId', { - m_studyId: study.id, - m_versionId: null, - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } + const unVersionedSubjects = hasPermission.hasVersioned ? (await this.db.collections.data_collection.distinct('properties.m_subjectId', { + 'studyId': study.id, + 'dataVersion': null, + 'properties.m_subjectId': { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + 'value': { $ne: null } })).sort() || [] : []; return [versionedSubjects, unVersionedSubjects]; } @@ -733,21 +640,21 @@ export class StudyCore { return [[], []]; } const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - const versionedVisits = (await this.db.collections.data_collection.distinct('m_visitId', { - m_studyId: study.id, - m_versionId: availableDataVersions[availableDataVersions.length - 1], - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } + const versionedVisits = (await this.db.collections.data_collection.distinct('properties.m_visitId', { + 'studyId': study.id, + 'dataVersion': availableDataVersions[availableDataVersions.length - 1], + 'properties.m_subjectId': { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + 'value': { $ne: null } })).sort((a, b) => parseFloat(a) - parseFloat(b)); - const unVersionedVisits = hasPermission.hasVersioned ? (await this.db.collections.data_collection.distinct('m_visitId', { - m_studyId: study.id, - m_versionId: null, - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, - value: { $ne: null } + const unVersionedVisits = hasPermission.hasVersioned ? (await this.db.collections.data_collection.distinct('properties.m_visitId', { + 'studyId': study.id, + 'dataVersion': null, + 'properties.m_subjectId': { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) }, + 'value': { $ne: null } })).sort((a, b) => parseFloat(a) - parseFloat(b)) : []; return [versionedVisits, unVersionedVisits]; } @@ -766,23 +673,23 @@ export class StudyCore { } const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); const numberOfVersioned: number = (await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: availableDataVersions[availableDataVersions.length - 1], value: { $ne: null } } + $match: { studyId: study.id, dataVersion: availableDataVersions[availableDataVersions.length - 1], value: { $ne: null } } }, { $match: { - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } + 'properties.m_subjectId': { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } }, { $count: 'count' }]).toArray())[0]?.['count'] || 0; const numberOfUnVersioned: number = hasPermission.hasVersioned ? (await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: null, value: { $ne: null } } + $match: { studyId: study.id, dataVersion: null, value: { $ne: null } } }, { $match: { - m_subjectId: { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, - m_visitId: { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } + 'properties.m_subjectId': { $in: hasPermission.raw.subjectIds.map((el: string) => new RegExp(el)) }, + 'properties.m_visitId': { $in: hasPermission.raw.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } } }, { $count: 'count' @@ -826,9 +733,9 @@ export class StudyCore { return []; } const ontologyTreeFieldIds: string[] = (availableTrees[0].routes || []).map(el => el.field[0].replace('$', '')); - let fieldRecords: IFieldEntry[] = []; - if (requester.type === userTypes.ADMIN) { - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + let fieldRecords: IField[] = []; + if (requester.type === enumUserTypes.ADMIN) { + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions }, fieldId: { $in: ontologyTreeFieldIds } } }, { $group: { @@ -851,8 +758,8 @@ export class StudyCore { subqueries.push(translateMetadata(subMetadata)); }); const metadataFilter = { $or: subqueries }; - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions }, fieldId: { $in: ontologyTreeFieldIds } } + fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { 'studyId': project.studyId, 'life.deletedTime': null, 'dataVersion': { $in: availableDataVersions }, 'fieldId': { $in: ontologyTreeFieldIds } } }, { $match: metadataFilter }, { $group: { _id: '$fieldId', @@ -904,8 +811,8 @@ export class StudyCore { return []; } const ontologyTreeFieldIds: string[] = (availableTrees[0].routes || []).map(el => el.field[0].replace('$', '')); - const fileFieldIds: string[] = (await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumValueType.FILE } + const fileFieldIds: string[] = (await this.db.collections.field_dictionary_collection.aggregate([{ + $match: { studyId: study.id, dateDeleted: null, dataVersion: { $in: availableDataVersions }, dataType: enumDataTypes.FILE } }, { $match: { $and: [{ fieldId: { $in: hasPermission.raw.fieldIds.map((el: string) => new RegExp(el)) } }, { fieldId: { $in: ontologyTreeFieldIds } }] } }, { $group: { _id: '$fieldId', @@ -922,7 +829,7 @@ export class StudyCore { let remove: string[] = []; if (Object.keys(hasPermission.matchObj).length === 0) { (await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } + $match: { studyId: study.id, dataVersion: { $in: availableDataVersions }, fieldId: { $in: fileFieldIds } } }]).toArray()).forEach(element => { add = add.concat(element.metadata?.add || []); remove = remove.concat(element.metadata?.remove || []); @@ -934,7 +841,7 @@ export class StudyCore { }); const metadataFilter = { $or: subqueries }; (await this.db.collections.data_collection.aggregate([{ - $match: { m_studyId: study.id, m_versionId: { $in: availableDataVersions }, m_fieldId: { $in: fileFieldIds } } + $match: { studyId: study.id, dataVersion: { $in: availableDataVersions }, fieldId: { $in: fileFieldIds } } }, { $match: metadataFilter }]).toArray()).forEach(element => { @@ -998,9 +905,9 @@ export class StudyCore { // const ontologyTreeFieldIds = (availableTrees[0]?.routes || []).map(el => el.field[0].replace('$', '')); let fieldRecords; - if (requester.type === userTypes.ADMIN) { + if (requester.type === enumUserTypes.ADMIN) { fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + $match: { 'studyId': project.studyId, 'life.deletedTime': null, 'dataVersion': { $in: availableDataVersions } } }, { $group: { _id: '$fieldId', @@ -1018,7 +925,7 @@ export class StudyCore { }); metadataFilter = { $or: subqueries }; fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: project.studyId, dateDeleted: null, dataVersion: { $in: availableDataVersions } } + $match: { 'studyId': project.studyId, 'life.deletedTime': null, 'dataVersion': { $in: availableDataVersions } } }, { $match: metadataFilter }, { $group: { _id: '$fieldId', @@ -1035,7 +942,7 @@ export class StudyCore { cohort: [[]], new_fields: [] }; - const pipeline = buildPipeline(emptyQueryString, project.studyId, [availableDataVersions[availableDataVersions.length - 1]], fieldRecords as IFieldEntry[], metadataFilter, requester.type === userTypes.ADMIN, false); + const pipeline = buildPipeline(emptyQueryString, project.studyId, [availableDataVersions[availableDataVersions.length - 1]], fieldRecords as IField[], metadataFilter, requester.type === enumUserTypes.ADMIN, false); const result = await this.db.collections.data_collection.aggregate<{ m_subjectId: string, m_visitId: string, m_fieldId: string, value: unknown }>(pipeline, { allowDiskUse: true }).toArray(); summary['subjects'] = Array.from(new Set(result.map((el) => el.m_subjectId))).sort(); summary['visits'] = Array.from(new Set(result.map((el) => el.m_visitId))).sort((a, b) => parseFloat(a) - parseFloat(b)).sort(); @@ -1073,13 +980,13 @@ export class StudyCore { return await this.db.collections.roles_collection.find({ studyId: project.studyId, projectId: project.id, deleted: null }).toArray(); } - public async createNewStudy(requester: IUserWithoutToken | undefined, studyName: string, description: string, type: studyType): Promise { + public async createNewStudy(requester: IUserWithoutToken | undefined, studyName: string, description: string, type: studyType) { /* check if study already exist (lowercase because S3 minio buckets cant be mixed case) */ if (!requester) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -1105,21 +1012,28 @@ export class StudyCore { const study: IStudy = { id: uuid(), name: studyName, - createdBy: requester.id, currentDataVersion: -1, - lastModified: new Date().valueOf(), dataVersions: [], - deleted: null, description: description, - type: type, ontologyTrees: [], - metadata: {} + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: { + type: type + } }; await this.db.collections.studies_collection.insertOne(study); - return study; + return { + ...study, + type: type + }; } - public async validateAndGenerateFieldEntry(fieldEntry: Partial, requester: IUserWithoutToken) { + public async validateAndGenerateFieldEntry(fieldEntry: CreateFieldInput, requester: IUserWithoutToken) { // duplicates with existing fields are checked by caller function const error: string[] = []; const complusoryField = [ @@ -1139,11 +1053,12 @@ export class StudyCore { error.push('FieldId should contain letters, numbers and _ only.'); } // data types - if (!fieldEntry.dataType || !Object.values(enumValueType).includes(fieldEntry.dataType)) { + const dataTypes = ['int', 'dec', 'str', 'bool', 'date', 'file', 'json', 'cat']; + if (!fieldEntry.dataType || !dataTypes.includes(fieldEntry.dataType)) { error.push(`Data type shouldn't be ${fieldEntry.dataType}: use 'int' for integer, 'dec' for decimal, 'str' for string, 'bool' for boolean, 'date' for datetime, 'file' for FILE, 'json' for json.`); } // check possiblevalues to be not-empty if datatype is categorical - if (fieldEntry.dataType === enumValueType.CATEGORICAL) { + if (fieldEntry.dataType === enumDataTypes.CATEGORICAL) { if (fieldEntry.possibleValues !== undefined && fieldEntry.possibleValues !== null) { if (fieldEntry.possibleValues.length === 0) { error.push(`${fieldEntry.fieldId}-${fieldEntry.fieldName}: possible values can't be empty if data type is categorical.`); @@ -1159,9 +1074,9 @@ export class StudyCore { const newField = { fieldId: fieldEntry.fieldId, fieldName: fieldEntry.fieldName, - tableName: fieldEntry.tableName, + tableName: null, dataType: fieldEntry.dataType, - possibleValues: fieldEntry.dataType === enumValueType.CATEGORICAL ? fieldEntry.possibleValues : null, + possibleValues: fieldEntry.dataType === enumDataTypes.CATEGORICAL ? fieldEntry.possibleValues : null, unit: fieldEntry.unit, comments: fieldEntry.comments, metadata: { @@ -1209,7 +1124,7 @@ export class StudyCore { response.push({ successful: false, code: errorCodes.NO_PERMISSION_ERROR, description: 'You do not have permissions to create this field.' }); continue; } - const { fieldEntry, error: thisError } = await this.validateAndGenerateFieldEntry(oneFieldInput, requester); + const { error: thisError } = await this.validateAndGenerateFieldEntry(oneFieldInput, requester); if (thisError.length !== 0) { response.push({ successful: false, code: errorCodes.CLIENT_MALFORMED_INPUT, description: `Field ${oneFieldInput.fieldId || 'fieldId not defined'}-${oneFieldInput.fieldName || 'fieldName not defined'}: ${JSON.stringify(thisError)}` }); isError = true; @@ -1218,25 +1133,47 @@ export class StudyCore { } // // construct the rest of the fields if (!isError) { - const newFieldEntry: IFieldEntry = { - ...fieldEntry, - fieldId: oneFieldInput.fieldId, - fieldName: oneFieldInput.fieldName, - dataType: oneFieldInput.dataType, + const newFieldEntry: IField = { id: uuid(), studyId: studyId, + fieldName: oneFieldInput.fieldName, + fieldId: oneFieldInput.fieldId, + dataType: (() => { + if (oneFieldInput.dataType === 'int') { + return enumDataTypes.INTEGER; + } else if (oneFieldInput.dataType === 'dec') { + return enumDataTypes.DECIMAL; + } else if (oneFieldInput.dataType === 'str') { + return enumDataTypes.STRING; + } else if (oneFieldInput.dataType === 'bool') { + return enumDataTypes.BOOLEAN; + } else if (oneFieldInput.dataType === 'date') { + return enumDataTypes.DATETIME; + } else if (oneFieldInput.dataType === 'file') { + return enumDataTypes.FILE; + } else if (oneFieldInput.dataType === 'json') { + return enumDataTypes.JSON; + } else if (oneFieldInput.dataType === 'cat') { + return enumDataTypes.CATEGORICAL; + } else { + return enumDataTypes.STRING; + } + })(), + categoricalOptions: oneFieldInput.dataType === 'cat' ? oneFieldInput.possibleValues : undefined, + unit: oneFieldInput.unit, + comments: oneFieldInput.comments, dataVersion: null, - dateAdded: Date.now(), - dateDeleted: null, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, metadata: { - uploader: requester.id + tableName: oneFieldInput.tableName } }; - bulk.find({ - fieldId: fieldEntry.fieldId, - studyId: studyId, - dataVersion: null - }).upsert().updateOne({ $set: newFieldEntry }); + bulk.insert(newFieldEntry); } } if (bulk.batches.length > 0) { @@ -1250,39 +1187,79 @@ export class StudyCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } // check fieldId exist - const searchField = await this.db.collections.field_dictionary_collection.findOne({ studyId: studyId, fieldId: fieldInput.fieldId, dateDeleted: null }); + const searchField = await this.db.collections.field_dictionary_collection.findOne({ studyId: studyId, fieldId: fieldInput.fieldId, dateDeleted: null }); if (!searchField) { throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); } searchField.fieldId = fieldInput.fieldId; searchField.fieldName = fieldInput.fieldName; - searchField.dataType = fieldInput.dataType; + searchField.dataType = (() => { + if (fieldInput.dataType === 'int') { + return enumDataTypes.INTEGER; + } else if (fieldInput.dataType === 'dec') { + return enumDataTypes.DECIMAL; + } else if (fieldInput.dataType === 'str') { + return enumDataTypes.STRING; + } else if (fieldInput.dataType === 'bool') { + return enumDataTypes.BOOLEAN; + } else if (fieldInput.dataType === 'date') { + return enumDataTypes.DATETIME; + } else if (fieldInput.dataType === 'file') { + return enumDataTypes.FILE; + } else if (fieldInput.dataType === 'json') { + return enumDataTypes.JSON; + } else if (fieldInput.dataType === 'cat') { + return enumDataTypes.CATEGORICAL; + } else { + return enumDataTypes.STRING; + } + })(); if (fieldInput.tableName) { - searchField.tableName = fieldInput.tableName; + searchField.metadata['tableName'] = fieldInput.tableName; } if (fieldInput.unit) { searchField.unit = fieldInput.unit; } if (fieldInput.possibleValues) { - searchField.possibleValues = fieldInput.possibleValues; + searchField.categoricalOptions = fieldInput.possibleValues; } if (fieldInput.tableName) { - searchField.tableName = fieldInput.tableName; + searchField.metadata['tableName'] = fieldInput.tableName; } if (fieldInput.comments) { searchField.comments = fieldInput.comments; } - const { fieldEntry, error } = await this.validateAndGenerateFieldEntry(searchField, requester); + const { error } = await this.validateAndGenerateFieldEntry(searchField, requester); if (error.length !== 0) { throw new GraphQLError(JSON.stringify(error), { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); } - const newFieldEntry = { ...fieldEntry, id: searchField.id, dateAdded: searchField.dateAdded, deleted: searchField.dateDeleted, studyId: searchField.studyId }; + // const newFieldEntry = { ...fieldEntry, id: searchField.id, dateAdded: searchField.dateAdded, deleted: searchField.dateDeleted, studyId: searchField.studyId }; + const newFieldEntry: IField = { + id: searchField.id, + studyId: searchField.studyId, + fieldName: searchField.fieldName, + fieldId: searchField.fieldId, + dataType: searchField.dataType, + categoricalOptions: searchField.categoricalOptions, + unit: searchField.unit, + comments: searchField.comments, + dataVersion: null, + life: { + createdTime: searchField.life.createdTime, + createdUser: searchField.life.createdUser, + deletedTime: searchField.life.deletedTime, + deletedUser: searchField.life.deletedUser + }, + verifier: searchField.verifier, + properties: searchField.properties, + metadata: searchField.metadata + }; await this.db.collections.field_dictionary_collection.findOneAndUpdate({ studyId: studyId, fieldId: newFieldEntry.fieldId }, { $set: newFieldEntry }); return newFieldEntry; @@ -1308,26 +1285,32 @@ export class StudyCore { // check fieldId exist const searchField = await this.db.collections.field_dictionary_collection.find({ studyId: studyId, fieldId: fieldId, dateDeleted: null }).limit(1).sort({ dateAdded: -1 }).toArray(); - if (searchField.length === 0 || searchField[0].dateDeleted !== null) { + if (searchField.length === 0 || searchField[0].life.deletedTime !== null) { throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); } - const fieldEntry = { + const fieldEntry: IField = { id: uuid(), studyId: studyId, - fieldId: searchField[0].fieldId, fieldName: searchField[0].fieldName, - tableName: searchField[0].tableName, + fieldId: searchField[0].fieldId, dataType: searchField[0].dataType, - possibleValues: searchField[0].possibleValues, + categoricalOptions: searchField[0].categoricalOptions, unit: searchField[0].unit, comments: searchField[0].comments, dataVersion: null, - dateAdded: (new Date()).valueOf(), - dateDeleted: (new Date()).valueOf() + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: Date.now(), + deletedUser: requester.id + }, + verifier: searchField[0].verifier, + properties: searchField[0].properties, + metadata: searchField[0].metadata }; await this.db.collections.field_dictionary_collection.insertOne(fieldEntry); - return searchField[0]; + return this.fieldTypeConverter([fieldEntry])[0]; } public async editStudy(requester: IUserWithoutToken | undefined, studyId: string, description: string): Promise { @@ -1335,7 +1318,7 @@ export class StudyCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -1369,7 +1352,7 @@ export class StudyCore { // get all dataVersions that are valid (before/equal the current version) const availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $sort: { dateAdded: -1 } + $sort: { 'life.createdTime': -1 } }, { $match: { $or: [{ dataVersion: null }, { dataVersion: { $in: availableDataVersions } }] } }, { @@ -1382,7 +1365,7 @@ export class StudyCore { } ]).toArray(); // filter those that have been deleted - const fieldsList = fieldRecords.map(el => el['doc']).filter(eh => eh.dateDeleted === null); + const fieldsList = fieldRecords.map(el => el['doc']).filter(eh => eh.life.deletedTime === null); const response = (await this.uploadOneDataClip(studyId, hasPermission.raw, fieldsList, data, requester)); return response; @@ -1410,12 +1393,12 @@ export class StudyCore { let validFields; // filter if (subjectIds === undefined || subjectIds === null || subjectIds.length === 0) { - validSubjects = (await this.db.collections.data_collection.distinct('m_subjectId', { m_studyId: studyId })); + validSubjects = (await this.db.collections.data_collection.distinct('properties.m_subjectId', { studyId: studyId })); } else { validSubjects = subjectIds; } if (visitIds === undefined || visitIds === null || visitIds.length === 0) { - validVisits = (await this.db.collections.data_collection.distinct('m_visitId', { m_studyId: studyId })); + validVisits = (await this.db.collections.data_collection.distinct('properties.m_visitId', { studyId: studyId })); } else { validVisits = visitIds; } @@ -1432,17 +1415,22 @@ export class StudyCore { if (!(await this.permissionCore.checkDataEntryValid(hasPermission.raw, fieldId, subjectId, visitId))) { continue; } - bulk.find({ m_studyId: studyId, m_subjectId: subjectId, m_visitId: visitId, m_fieldId: fieldId, m_versionId: null }).upsert().updateOne({ - $set: { - m_studyId: studyId, + bulk.insert({ + studyId: studyId, + fieldId: fieldId, + value: null, + properties: { m_subjectId: subjectId, - m_visitId: visitId, - m_versionId: null, - m_fieldId: fieldId, - value: null, - uploadedAt: (new Date()).valueOf(), - id: uuid() - } + m_visitId: visitId + }, + dataVersion: null, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: Date.now(), + deletedUser: requester.id + }, + id: uuid() }); response.push({ successful: true, description: `SubjectId-${subjectId}:visitId-${visitId}:fieldId-${fieldId} is deleted.` }); } @@ -1463,7 +1451,7 @@ export class StudyCore { } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -1472,15 +1460,14 @@ export class StudyCore { throw new GraphQLError(errorCodes.CLIENT_MALFORMED_INPUT); } const newDataVersionId = uuid(); - const newContentId = uuid(); // update data const resData = await this.db.collections.data_collection.updateMany({ - m_studyId: studyId, - m_versionId: null + studyId: studyId, + dataVersion: null }, { $set: { - m_versionId: newDataVersionId + dataVersion: newDataVersionId } }); // update field @@ -1516,10 +1503,15 @@ export class StudyCore { // insert a new version into study const newDataVersion: IStudyDataVersion = { id: newDataVersionId, - contentId: newContentId, // same content = same id - used in reverting data, version control version: dataVersion, tag: tag, - updateDate: (new Date().valueOf()).toString() + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await this.db.collections.studies_collection.updateOne({ id: studyId }, { $push: { dataVersions: newDataVersion }, @@ -1543,22 +1535,22 @@ export class StudyCore { validSubjects = []; const subqueries = translateCohort(role.permissions.data.filters); validSubjects = (await this.db.collections.data_collection.aggregate<{ - m_subjectId: string, m_visitId: string, m_fieldId: string, value: string | number | boolean | { [key: string]: unknown } + 'm_subjectId': string, m_visitId: string, fieldId: string, value: string | number | boolean | { [key: string]: unknown } }>([{ - $match: { m_fieldId: { $in: role.permissions.data.filters.map(el => el.field) } } + $match: { fieldId: { $in: role.permissions.data.filters.map(el => el.field) } } }, { $sort: { uploadedAt: -1 } }, { $group: { - _id: { m_subjectId: '$m_subjectId', m_visitId: '$m_visitId', m_fieldId: '$m_fieldId' }, + _id: { m_subjectId: '$properties.m_subjectId', m_visitId: '$properties.m_visitId', m_fieldId: '$fieldId' }, doc: { $first: '$$ROOT' } } }, { $project: { - m_subjectId: '$doc.m_subjectId', - m_visitId: '$doc.m_visitId', - m_fieldId: '$doc.m_fieldId', + m_subjectId: '$doc.properties.m_subjectId', + m_visitId: '$doc.properties.m_visitId', + m_fieldId: '$doc.fieldId', value: '$doc.value', _id: 0 } @@ -1572,25 +1564,25 @@ export class StudyCore { } const tag = `metadata.${'role:'.concat(role.id)}`; await this.db.collections.data_collection.updateMany({ - m_studyId: studyId, - m_versionId: newDataVersionId, - $and: [ - { m_subjectId: { $in: filters.subjectIds.map((el: string) => new RegExp(el)) } }, - { m_subjectId: { $in: validSubjects } } + 'studyId': studyId, + 'dataVersion': newDataVersionId, + '$and': [ + { 'properties.m_subjectId': { $in: filters.subjectIds.map((el: string) => new RegExp(el)) } }, + { 'properties.m_subjectId': { $in: validSubjects } } ], - m_visitId: { $in: filters.visitIds.map((el: string) => new RegExp(el)) }, - m_fieldId: { $in: filters.fieldIds.map((el: string) => new RegExp(el)) } + 'properties.m_visitId': { $in: filters.visitIds.map((el: string) => new RegExp(el)) }, + 'fieldId': { $in: filters.fieldIds.map((el: string) => new RegExp(el)) } }, { $set: { [tag]: true } }); await this.db.collections.data_collection.updateMany({ - m_studyId: studyId, - m_versionId: newDataVersionId, + studyId: studyId, + dataVersion: newDataVersionId, $or: [ - { m_subjectId: { $nin: filters.subjectIds.map((el: string) => new RegExp(el)) } }, - { m_subjectId: { $nin: validSubjects } }, - { m_visitId: { $nin: filters.visitIds.map((el: string) => new RegExp(el)) } }, - { m_fieldId: { $nin: filters.fieldIds.map((el: string) => new RegExp(el)) } } + { 'properties.m_subjectId': { $nin: filters.subjectIds.map((el: string) => new RegExp(el)) } }, + { 'properties.m_subjectId': { $nin: validSubjects } }, + { 'properties.m_visitId': { $nin: filters.visitIds.map((el: string) => new RegExp(el)) } }, + { fieldId: { $nin: filters.fieldIds.map((el: string) => new RegExp(el)) } } ] }, { $set: { [tag]: false } @@ -1613,10 +1605,14 @@ export class StudyCore { if (newDataVersion === null) { throw new GraphQLError('No matched or modified records', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); } - return newDataVersion; + return { + ...newDataVersion, + updateDate: newDataVersion.life.createdTime.toString(), + contentId: uuid() + }; } - public async uploadOneDataClip(studyId: string, permissions, fieldList: Partial[], data: IDataClip[], requester: IUserWithoutToken): Promise { + public async uploadOneDataClip(studyId: string, permissions, fieldList: Partial[], data: IDataClip[], requester: IUserWithoutToken): Promise { const response: IGenericResponse[] = []; let bulk = this.db.collections.data_collection.initializeUnorderedBulkOp(); // remove duplicates by subjectId, visitId and fieldId @@ -1648,7 +1644,7 @@ export class StudyCore { parsedValue = '99999'; } else { switch (fieldInDb.dataType) { - case 'dec': {// decimal + case enumDataTypes.DECIMAL: {// decimal if (typeof (dataClip.value) !== 'string') { error = `Field ${dataClip.fieldId}: Cannot parse as decimal.`; break; @@ -1660,7 +1656,7 @@ export class StudyCore { parsedValue = parseFloat(dataClip.value); break; } - case 'int': {// integer + case enumDataTypes.INTEGER: {// integer if (typeof (dataClip.value) !== 'string') { error = `Field ${dataClip.fieldId}: Cannot parse as integer.`; break; @@ -1672,7 +1668,7 @@ export class StudyCore { parsedValue = parseInt(dataClip.value, 10); break; } - case 'bool': {// boolean + case enumDataTypes.BOOLEAN: {// boolean if (typeof (dataClip.value) !== 'string') { error = `Field ${dataClip.fieldId}: Cannot parse as boolean.`; break; @@ -1685,7 +1681,7 @@ export class StudyCore { } break; } - case 'str': { + case enumDataTypes.STRING: { if (typeof (dataClip.value) !== 'string') { error = `Field ${dataClip.fieldId}: Cannot parse as string.`; break; @@ -1694,7 +1690,7 @@ export class StudyCore { break; } // 01/02/2021 00:00:00 - case 'date': { + case enumDataTypes.DATETIME: { if (typeof (dataClip.value) !== 'string') { error = `Field ${dataClip.fieldId}: Cannot parse as data. Value for date type must be in ISO format.`; break; @@ -1707,11 +1703,11 @@ export class StudyCore { parsedValue = dataClip.value.toString(); break; } - case 'json': { + case enumDataTypes.JSON: { parsedValue = dataClip.value; break; } - case 'file': { + case enumDataTypes.FILE: { if (!dataClip.file || typeof (dataClip.file) === 'string') { error = `Field ${dataClip.fieldId}: Cannot parse as file.`; break; @@ -1726,12 +1722,12 @@ export class StudyCore { } break; } - case 'cat': { - if (!fieldInDb.possibleValues) { + case enumDataTypes.CATEGORICAL: { + if (!fieldInDb.categoricalOptions) { error = `Field ${dataClip.fieldId}: Cannot parse as categorical, possible values not defined.`; break; } - if (dataClip.value && !fieldInDb.possibleValues.map((el) => el.code).includes(dataClip.value?.toString())) { + if (dataClip.value && !fieldInDb.categoricalOptions.map((el) => el.code).includes(dataClip.value?.toString())) { error = `Field ${dataClip.fieldId}: Cannot parse as categorical, value not in value list.`; break; } else { @@ -1751,58 +1747,25 @@ export class StudyCore { } else { response.push({ successful: true, description: `${dataClip.subjectId}-${dataClip.visitId}-${dataClip.fieldId}` }); } - const obj = { - m_studyId: studyId, - m_versionId: null, - m_subjectId: dataClip.subjectId, - m_visitId: dataClip.visitId, - m_fieldId: dataClip.fieldId - }; - let objWithData: Partial>; - // update the file data differently - if (fieldInDb.dataType === 'file') { - const existing = await this.db.collections.data_collection.findOne(obj); - if (!existing) { - await this.db.collections.data_collection.insertOne({ - ...obj, - id: uuid(), - uploadedAt: (new Date()).valueOf(), - value: '', - metadata: { - add: [], - remove: [] - } - }); - } + bulk.insert({ + id: uuid(), + studyId: studyId, + fieldId: dataClip.fieldId, + dataVersion: null, + value: parsedValue, + properties: { + m_subjectId: dataClip.subjectId, + m_visitId: dataClip.visitId + }, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }); - objWithData = { - ...obj, - id: uuid(), - value: '', - uploadedAt: (new Date()).valueOf(), - metadata: { - ...dataClip.metadata, - participantId: dataClip.subjectId, - add: (existing?.metadata?.add || []).concat(parsedValue), - uploader: requester.id - }, - uploadedBy: requester.id - }; - bulk.find(obj).updateOne({ $set: objWithData }); - } else { - objWithData = { - ...obj, - id: uuid(), - value: parsedValue, - uploadedAt: (new Date()).valueOf(), - metadata: { - ...dataClip.metadata, - uploader: requester.id - }, - uploadedBy: requester.id - }; - bulk.insert(objWithData); - } if (bulk.batches.length > 999) { await bulk.execute(); bulk = this.db.collections.data_collection.initializeUnorderedBulkOp(); @@ -1821,19 +1784,20 @@ export class StudyCore { if (!study) { return { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, description: 'Study does not exist.' }; } - const sitesIDMarkers = (await this.db.collections.organisations_collection.find({ deleted: null }).toArray()).reduce<{ [key: string]: string | null }>((acc, curr) => { - if (curr.metadata?.siteIDMarker) { - acc[curr.metadata.siteIDMarker] = curr.shortname; + const organisations = await this.db.collections.organisations_collection.find({ 'life.deletedTime': null }).toArray(); + const sitesIDMarkers: Record = organisations.reduce((acc, curr) => { + if (curr.metadata['siteIDMarker']) { + acc[String(curr.metadata['siteIDMarker'])] = curr.shortname ?? curr.name; } return acc; - }, {}); + }, {} as Record); // check file metadata + let parsedDescription: Record; + let startDate: number; + let endDate: number; + let deviceId: string; + let participantId: string; if (data.metadata) { - let parsedDescription: Record; - let startDate: number; - let endDate: number; - let deviceId: string; - let participantId: string; try { parsedDescription = data.metadata; if (!parsedDescription['startDate'] || !parsedDescription['endDate'] || !parsedDescription['deviceId'] || !parsedDescription['participantId']) { @@ -1864,22 +1828,11 @@ export class StudyCore { const file: FileUpload = await data.file; // check if old files exist; if so, denote it as deleted - const dataEntry = await this.db.collections.data_collection.findOne({ m_studyId: studyId, m_visitId: data.visitId, m_subjectId: data.subjectId, m_versionId: null, m_fieldId: data.fieldId }); + const dataEntry = await this.db.collections.data_collection.findOne({ 'studyId': studyId, 'properties.m_visitId': data.visitId, 'properties.m_subjectId': data.subjectId, 'dataVersion': null, 'fieldId': data.fieldId }); const oldFileId = dataEntry ? dataEntry.value : null; return new Promise((resolve, reject) => { (async () => { try { - const fileEntry: Partial = { - id: uuid(), - fileName: file.filename, - studyId: studyId, - description: JSON.stringify({}), - uploadTime: `${Date.now()}`, - uploadedBy: uploader.id, - deleted: null, - metadata: (data.metadata as Record) - }; - if (args.fileLength !== undefined && args.fileLength > fileSizeLimit) { reject(new GraphQLError('File should not be larger than 8GB', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); return; @@ -1923,10 +1876,29 @@ export class StudyCore { reject(new GraphQLError('File size mismatch', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } })); return; } + const fileParts: string[] = file.filename.split('.'); + const fileExtension = fileParts.length === 1 ? enumFileTypes.UNKNOWN : (fileParts[fileParts.length - 1].trim().toUpperCase() in enumFileTypes ? fileParts[fileParts.length - 1].trim().toUpperCase() : enumFileTypes.UNKNOWN); - fileEntry.fileSize = readBytes.toString(); - fileEntry.uri = fileUri; - fileEntry.hash = hashString; + const fileEntry: Partial = { + id: uuid(), + studyId: studyId, + userId: null, + fileName: file.filename, + fileSize: readBytes, + description: JSON.stringify({}), + properties: { + participantId: participantId, + deviceId: deviceId, + startDate: startDate, + endDate: endDate, + site: sitesIDMarkers[participantId.substr(0, 1).toUpperCase()] ?? 'Unknown' + }, + uri: fileUri, + hash: hashString, + fileType: fileExtension in enumFileTypes ? enumFileTypes[fileExtension] : enumFileTypes.UNKNOWN, + fileCategory: enumFileCategories.STUDY_DATA_FILE, + sharedUsers: [] + }; const insertResult = await this.db.collections.files_collection.insertOne(fileEntry as IFile); if (insertResult.acknowledged) { // delete old file if existing @@ -2079,7 +2051,7 @@ export class StudyCore { }; const getListOfPatientsResult = await this.db.collections.data_collection.aggregate([ - { $match: { m_studyId: studyId } }, + { $match: { studyId: studyId } }, { $group: { _id: null, array: { $addToSet: '$m_subjectId' } } }, { $project: { array: 1 } } ]).toArray(); @@ -2101,10 +2073,10 @@ export class StudyCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* check privileges */ - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const study = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); if (!study) { throw new GraphQLError('Study does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); } @@ -2116,7 +2088,7 @@ export class StudyCore { try { /* delete the study */ - await this.db.collections.studies_collection.findOneAndUpdate({ id: studyId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }); + await this.db.collections.studies_collection.findOneAndUpdate({ 'id': studyId, 'life.deletedTime': null }, { $set: { 'life.deletedUser': requester.id, 'life.deletedTime': timestamp } }); /* delete all projects related to the study */ await this.db.collections.projects_collection.updateMany({ studyId, deleted: null }, { $set: { lastModified: timestamp, deleted: timestamp } }); @@ -2125,7 +2097,7 @@ export class StudyCore { await this.permissionCore.removeRoleFromStudyOrProject({ studyId }); /* delete all files belong to the study*/ - await this.db.collections.files_collection.updateMany({ studyId, deleted: null }, { $set: { deleted: timestamp } }); + await this.db.collections.files_collection.updateMany({ studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp } }); await session.commitTransaction(); session.endSession().catch(() => { return; }); diff --git a/packages/itmat-cores/src/core/userCore.ts b/packages/itmat-cores/src/GraphQLCore/userCore.ts similarity index 89% rename from packages/itmat-cores/src/core/userCore.ts rename to packages/itmat-cores/src/GraphQLCore/userCore.ts index 41326775d..764b5db3f 100644 --- a/packages/itmat-cores/src/core/userCore.ts +++ b/packages/itmat-cores/src/GraphQLCore/userCore.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcrypt'; import { GraphQLError } from 'graphql'; -import { IUser, IUserWithoutToken, userTypes, IPubkey, IProject, IStudy, IResetPasswordRequest } from '@itmat-broker/itmat-types'; +import { IUser, IUserWithoutToken, enumUserTypes, IPubkey, IProject, IStudy, IResetPasswordRequest, enumReservedUsers } from '@itmat-broker/itmat-types'; import { v4 as uuid } from 'uuid'; import { errorCodes } from '../utils/errors'; import { MarkOptional } from 'ts-essentials'; @@ -31,7 +31,7 @@ export interface CreateUserInput { export interface EditUserInput { id: string, username?: string, - type?: userTypes, + type?: enumUserTypes, firstname?: string, lastname?: string, email?: string, @@ -66,7 +66,7 @@ export class UserCore { } public async getOneUser_throwErrorIfNotExists(username: string): Promise { - const user = await this.db.collections.users_collection.findOne({ deleted: null, username }); + const user = await this.db.collections.users_collection.findOne({ 'life.deletedTime': null, username }); if (user === undefined || user === null) { throw new GraphQLError('User does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); } @@ -75,8 +75,17 @@ export class UserCore { public async getUsers(userId?: string) { // everyone is allowed to see all the users in the app. But only admin can access certain fields, like emails, etc - see resolvers for User type. - const queryObj = userId === undefined ? { deleted: null } : { deleted: null, id: userId }; - return await this.db.collections.users_collection.find(queryObj, { projection: { _id: 0 } }).toArray(); + const queryObj = userId === undefined ? { 'life.deletedTime': null } : { 'life.deletedTime': null, 'id': userId }; + const users = await this.db.collections.users_collection.find(queryObj, { projection: { _id: 0, password: 0, otpSecret: 0 } }).toArray(); + const modifiedUsers: Record[] = []; + for (const user of users) { + modifiedUsers.push({ + ...user, + createdAt: user.life.createdTime, + deleted: user.life.deletedTime + }); + } + return modifiedUsers; } public async validateResetPassword(token: string, encryptedEmail: string) { @@ -96,14 +105,14 @@ export class UserCore { const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; const user: IUserWithoutToken | null = await this.db.collections.users_collection.findOne({ email, - resetPasswordRequests: { + 'resetPasswordRequests': { $elemMatch: { id: token, timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, used: false } }, - deleted: null + 'life.deletedTime': null }); if (!user) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); @@ -120,14 +129,14 @@ export class UserCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + if (requester.type !== enumUserTypes.ADMIN && user.id !== requester.id) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } /* if requested user is admin, then he has access to all studies */ - if (user.type === userTypes.ADMIN) { + if (user.type === enumUserTypes.ADMIN) { const allprojects: IProject[] = await this.db.collections.projects_collection.find({ deleted: null }).toArray(); - const allstudies: IStudy[] = await this.db.collections.studies_collection.find({ deleted: null }).toArray(); + const allstudies: IStudy[] = await this.db.collections.studies_collection.find({ 'life.deletedTime': null }).toArray(); return { id: `user_access_obj_user_id_${user.id}`, projects: allprojects, studies: allstudies }; } @@ -151,7 +160,7 @@ export class UserCore { { studyId: { $in: studiesAndProjectThatUserCanSee.studies }, deleted: null } ] }).toArray(); - const studies = await this.db.collections.studies_collection.find({ id: { $in: studiesAndProjectThatUserCanSee.studies }, deleted: null }).toArray(); + const studies = await this.db.collections.studies_collection.find({ 'id': { $in: studiesAndProjectThatUserCanSee.studies }, 'life.deletedTime': null }).toArray(); return { id: `user_access_obj_user_id_${user.id}`, projects, studies }; } @@ -160,7 +169,7 @@ export class UserCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + if (requester.type !== enumUserTypes.ADMIN && user.id !== requester.id) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -172,7 +181,7 @@ export class UserCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + if (requester.type !== enumUserTypes.ADMIN && user.id !== requester.id) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -184,7 +193,7 @@ export class UserCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } /* only admin can access this field */ - if (requester.type !== userTypes.ADMIN && user.id !== requester.id) { + if (requester.type !== enumUserTypes.ADMIN && user.id !== requester.id) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -193,7 +202,7 @@ export class UserCore { public async requestExpiryDate(username?: string, email?: string) { /* double-check user existence */ - const queryObj = email ? { deleted: null, email } : { deleted: null, username }; + const queryObj = email ? { 'life.deletedTime': null, email } : { 'life.deletedTime': null, username }; const user: IUser | null = await this.db.collections.users_collection.findOne(queryObj); if (!user) { /* even user is null. send successful response: they should know that a user doesn't exist */ @@ -217,7 +226,7 @@ export class UserCore { return makeGenericReponse(); } - public async requestUsernameOrResetPassword(forgotUsername: boolean, forgotPassword: boolean, origin: unknown, email?: string, username?: string) { + public async requestUsernameOrResetPassword(forgotUsername: boolean, forgotPassword: boolean, origin: string, email?: string, username?: string) { /* checking the args are right */ if ((forgotUsername && !email) // should provide email if no username || (forgotUsername && username) // should not provide username if it's forgotten.. @@ -230,7 +239,7 @@ export class UserCore { } /* check user existence */ - const queryObj = email ? { deleted: null, email } : { deleted: null, username }; + const queryObj = email ? { 'life.deletedTime': null, email } : { 'life.deletedTime': null, username }; const user = await this.db.collections.users_collection.findOne(queryObj); if (!user) { /* even user is null. send successful response: they should know that a user doesn't exist */ @@ -292,7 +301,7 @@ export class UserCore { public async login(request: Express.Request, username: string, password: string, totp: string, requestexpirydate?: boolean) { // const { req }: { req: Express.Request } = context; - const result = await this.db.collections.users_collection.findOne({ deleted: null, username: username }); + const result = await this.db.collections.users_collection.findOne({ 'life.deletedTime': null, 'username': username }); if (!result) { throw new GraphQLError('User does not exist.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } @@ -312,7 +321,7 @@ export class UserCore { } /* validate if account expired */ - if (result.expiredAt < Date.now() && result.type === userTypes.STANDARD) { + if (result.expiredAt < Date.now() && result.type === enumUserTypes.STANDARD) { if (requestexpirydate) { /* send email to the DMP admin mailing-list */ await this.mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ @@ -334,14 +343,19 @@ export class UserCore { const filteredResult: IUserWithoutToken = { ...result }; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { request.login(filteredResult, (err: unknown) => { if (err) { Logger.error(err); reject(new GraphQLError('Cannot log in. Please try again later.')); return; } - resolve(filteredResult); + + resolve({ + ...filteredResult, + createdAt: filteredResult.life.createdTime, + deleted: filteredResult.life.deletedTime + }); }); }); } @@ -378,13 +392,13 @@ export class UserCore { throw new GraphQLError('Username or password cannot have spaces.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } - const alreadyExist = await this.db.collections.users_collection.findOne({ username: user.username, deleted: null }); // since bycrypt is CPU expensive let's check the username is not taken first + const alreadyExist = await this.db.collections.users_collection.findOne({ 'username': user.username, 'life.deletedTime': null }); // since bycrypt is CPU expensive let's check the username is not taken first if (alreadyExist !== null && alreadyExist !== undefined) { throw new GraphQLError('User already exists.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } /* check if email has been used to register */ - const emailExist = await this.db.collections.users_collection.findOne({ email: user.email, deleted: null }); + const emailExist = await this.db.collections.users_collection.findOne({ 'email': user.email, 'life.deletedTime': null }); if (emailExist !== null && emailExist !== undefined) { throw new GraphQLError('This email has been registered. Please sign-in or register with another email!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } @@ -398,7 +412,7 @@ export class UserCore { id: uuid(), username: user.username, otpSecret, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, description: user.description ?? '', organisation: user.organisation, firstname: user.firstname, @@ -407,11 +421,15 @@ export class UserCore { email: user.email, emailNotificationsActivated: user.emailNotificationsActivated ?? false, emailNotificationsStatus: { expiringNotification: false }, - createdAt, expiredAt, resetPasswordRequests: [], - metadata: user.metadata, - deleted: null + metadata: {}, + life: { + createdTime: createdAt, + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + } }; const result = await this.db.collections.users_collection.insertOne(entry); @@ -470,7 +488,7 @@ export class UserCore { throw new GraphQLError('User cannot delete itself'); } - if (requester.type !== userTypes.ADMIN) { + if (requester.type !== enumUserTypes.ADMIN) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } @@ -478,7 +496,7 @@ export class UserCore { session.startTransaction(); try { /* delete the user */ - await this.db.collections.users_collection.findOneAndUpdate({ id: userId, deleted: null }, { $set: { deleted: new Date().valueOf(), password: 'DeletedUserDummyPassword' } }, { returnDocument: 'after', projection: { deleted: 1 } }); + await this.db.collections.users_collection.findOneAndUpdate({ 'id': userId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': Date.now(), 'password': 'DeletedUserDummyPassword' } }, { returnDocument: 'after', projection: { 'life.deletedTime': 1 } }); /* delete all user records in roles related to the study */ await this.db.collections.roles_collection.updateMany( @@ -533,14 +551,14 @@ export class UserCore { const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; const user: IUserWithoutToken | null = await this.db.collections.users_collection.findOne({ email, - resetPasswordRequests: { + 'resetPasswordRequests': { $elemMatch: { id: token, timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, used: false } }, - deleted: null + 'life.deletedTime': null }); if (!user) { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); @@ -613,7 +631,7 @@ export class UserCore { throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); } const { id, username, type, firstname, lastname, email, emailNotificationsActivated, emailNotificationsStatus, password, description, organisation, expiredAt, metadata }: { - id: string, username?: string, type?: userTypes, firstname?: string, lastname?: string, email?: string, emailNotificationsActivated?: boolean, emailNotificationsStatus?: unknown, password?: string, description?: string, organisation?: string, expiredAt?: number, metadata?: unknown + id: string, username?: string, type?: enumUserTypes, firstname?: string, lastname?: string, email?: string, emailNotificationsActivated?: boolean, emailNotificationsStatus?: unknown, password?: string, description?: string, organisation?: string, expiredAt?: number, metadata?: unknown } = user; if (password !== undefined && requester.id !== id) { // only the user themself can reset password throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); @@ -621,12 +639,12 @@ export class UserCore { if (password && !passwordIsGoodEnough(password)) { throw new GraphQLError('Password has to be at least 8 character long.'); } - if (requester.type !== userTypes.ADMIN && requester.id !== id) { + if (requester.type !== enumUserTypes.ADMIN && requester.id !== id) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } let result; - if (requester.type === userTypes.ADMIN) { - result = await this.db.collections.users_collection.findOne({ id, deleted: null }); // just an extra guard before going to bcrypt cause bcrypt is CPU intensive. + if (requester.type === enumUserTypes.ADMIN) { + result = await this.db.collections.users_collection.findOne({ id, 'life.deletedTime': null }); // just an extra guard before going to bcrypt cause bcrypt is CPU intensive. if (result === null || result === undefined) { throw new GraphQLError('User not found'); } @@ -644,14 +662,14 @@ export class UserCore { description, organisation, expiredAt, - metadata + metadata: metadata ?? {} }; /* check email is valid form */ if (email && !/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { throw new GraphQLError('User not updated: Email is not the right format.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } - if (requester.type !== userTypes.ADMIN && ( + if (requester.type !== enumUserTypes.ADMIN && ( type || firstname || lastname || username || description || organisation )) { throw new GraphQLError('User not updated: Non-admin users are only authorised to change their password, email or email notification.'); @@ -668,7 +686,7 @@ export class UserCore { expiringNotification: false }; } - const updateResult = await this.db.collections.users_collection.findOneAndUpdate({ id, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); + const updateResult = await this.db.collections.users_collection.findOneAndUpdate({ id, 'life.deletedTime': null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); if (updateResult) { // New expiry date has been updated successfully. if (expiredAt && result) { @@ -685,7 +703,7 @@ export class UserCore { } } - public async registerPubkey(pubkeyobj: { pubkey: string, associatedUserId: string | null, jwtPubkey: string, jwtSeckey: string }): Promise { + public async registerPubkey(pubkeyobj: { pubkey: string, associatedUserId: string, jwtPubkey: string, jwtSeckey: string }): Promise { const { pubkey, associatedUserId, jwtPubkey, jwtSeckey } = pubkeyobj; const entry: IPubkey = { id: uuid(), @@ -694,7 +712,13 @@ export class UserCore { jwtPubkey, jwtSeckey, refreshCounter: 0, - deleted: null + life: { + createdTime: Date.now(), + createdUser: associatedUserId, + deletedTime: null, + deletedUser: null + }, + metadata: {} }; const result = await this.db.collections.pubkeys_collection.insertOne(entry); diff --git a/packages/itmat-cores/src/authentication/pubkeyAuthentication.ts b/packages/itmat-cores/src/authentication/pubkeyAuthentication.ts index 27f7b1ce7..6e52daa74 100644 --- a/packages/itmat-cores/src/authentication/pubkeyAuthentication.ts +++ b/packages/itmat-cores/src/authentication/pubkeyAuthentication.ts @@ -14,7 +14,7 @@ export async function userRetrieval(db: DBType, pubkey: string): Promise throw new GraphQLError('The public-key embedded in the JWT is not associated with any user!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } - const associatedUser: IUser | null = await db.collections.users_collection.findOne({ deleted: null, id: pubkeyrec.associatedUserId }, { projection: { _id: 0 } }); + const associatedUser: IUser | null = await db.collections.users_collection.findOne({ 'life.deletedTime': null, 'id': pubkeyrec.associatedUserId }, { projection: { _id: 0 } }); if (!associatedUser) { throw new GraphQLError('The user assciated with the public-key embedded in the JWT is not existed or already deleted!', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } diff --git a/packages/itmat-cores/src/database/database.ts b/packages/itmat-cores/src/database/database.ts index 261e5179d..6c46c2192 100644 --- a/packages/itmat-cores/src/database/database.ts +++ b/packages/itmat-cores/src/database/database.ts @@ -1,4 +1,4 @@ -import type { IDataEntry, IFieldEntry, IFile, IJobEntry, ILogEntry, IOrganisation, IProject, IPubkey, IQueryEntry, IRole, IStudy, IUser, IStandardization } from '@itmat-broker/itmat-types'; +import type { IField, IFile, IJobEntry, ILogEntry, IOrganisation, IProject, IPubkey, IQueryEntry, IRole, IStudy, IUser, IStandardization, IConfig, IData } from '@itmat-broker/itmat-types'; import { Database as DatabaseBase, IDatabaseBaseConfig } from '@itmat-broker/itmat-commons'; import type { Collection } from 'mongodb'; @@ -17,6 +17,7 @@ export interface IDatabaseConfig extends IDatabaseBaseConfig { pubkeys_collection: string, data_collection: string, standardizations_collection: string, + configs_collection: string }; } @@ -26,13 +27,14 @@ export interface IDatabaseCollectionConfig { studies_collection: Collection, projects_collection: Collection, queries_collection: Collection, - field_dictionary_collection: Collection, + field_dictionary_collection: Collection, roles_collection: Collection, files_collection: Collection, organisations_collection: Collection, log_collection: Collection, pubkeys_collection: Collection, - data_collection: Collection, + data_collection: Collection, standardizations_collection: Collection, + configs_collection: Collection } export type DBType = DatabaseBase; diff --git a/packages/itmat-cores/src/index.ts b/packages/itmat-cores/src/index.ts index a2e4263f3..81a15686d 100644 --- a/packages/itmat-cores/src/index.ts +++ b/packages/itmat-cores/src/index.ts @@ -1,15 +1,18 @@ -export * from './core/fileCore'; -export * from './core/jobCore'; -export * from './core/logCore'; -export * from './core/organisationCore'; -export * from './core/permissionCore'; -export * from './core/pubkeyCore'; -export * from './core/queryCore'; -export * from './core/standardizationCore'; -export * from './core/studyCore'; -export * from './core/userCore'; +// GraphQLCore +export * from './GraphQLCore/fileCore'; +export * from './GraphQLCore/jobCore'; +export * from './GraphQLCore/logCore'; +export * from './GraphQLCore/organisationCore'; +export * from './GraphQLCore/permissionCore'; +export * from './GraphQLCore/pubkeyCore'; +export * from './GraphQLCore/queryCore'; +export * from './GraphQLCore/standardizationCore'; +export * from './GraphQLCore/studyCore'; +export * from './GraphQLCore/userCore'; +// TRPCCore +export * from './trpcCore/userCore'; export * from './rest/fileDownload'; export * from './authentication/pubkeyAuthentication'; export * from './log/logPlugin'; export * from './database/database'; -export * from './utils'; \ No newline at end of file +export * from './utils'; diff --git a/packages/itmat-cores/src/log/logPlugin.ts b/packages/itmat-cores/src/log/logPlugin.ts index 2a684ea2e..a1618ebd1 100644 --- a/packages/itmat-cores/src/log/logPlugin.ts +++ b/packages/itmat-cores/src/log/logPlugin.ts @@ -1,5 +1,5 @@ import { v4 as uuid } from 'uuid'; -import { LOG_TYPE, LOG_ACTION, LOG_STATUS, USER_AGENT, userTypes } from '@itmat-broker/itmat-types'; +import { LOG_TYPE, LOG_ACTION, LOG_STATUS, USER_AGENT, enumUserTypes } from '@itmat-broker/itmat-types'; import { GraphQLRequestContextWillSendResponse } from '@apollo/server'; import { ApolloServerContext } from '../utils/ApolloServerContext'; import { DBType } from '../database/database'; @@ -19,8 +19,8 @@ export class LogPlugin { public async serverWillStartLogPlugin(): Promise { await this.db.collections.log_collection.insertOne({ id: uuid(), - requesterName: userTypes.SYSTEM, - requesterType: userTypes.SYSTEM, + requesterName: enumUserTypes.SYSTEM, + requesterType: enumUserTypes.SYSTEM, logType: LOG_TYPE.SYSTEM_LOG, actionType: LOG_ACTION.startSERVER, actionData: JSON.stringify({}), @@ -43,7 +43,7 @@ export class LogPlugin { await this.db.collections.log_collection.insertOne({ id: uuid(), requesterName: requestContext.contextValue?.req?.user?.username ?? 'NA', - requesterType: requestContext.contextValue?.req?.user?.type ?? userTypes.SYSTEM, + requesterType: requestContext.contextValue?.req?.user?.type ?? enumUserTypes.SYSTEM, userAgent: (requestContext.contextValue.req?.headers['user-agent'] as string)?.startsWith('Mozilla') ? USER_AGENT.MOZILLA : USER_AGENT.OTHER, logType: LOG_TYPE.REQUEST_LOG, actionType: LOG_ACTION[requestContext.operationName], diff --git a/packages/itmat-cores/src/rest/fileDownload.ts b/packages/itmat-cores/src/rest/fileDownload.ts index 896811582..65f64d147 100644 --- a/packages/itmat-cores/src/rest/fileDownload.ts +++ b/packages/itmat-cores/src/rest/fileDownload.ts @@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'; import { userRetrieval } from '../authentication/pubkeyAuthentication'; import { ApolloServerErrorCode } from '@apollo/server/errors'; import { GraphQLError } from 'graphql'; -import { PermissionCore } from '../core/permissionCore'; +import { PermissionCore } from '../GraphQLCore/permissionCore'; import { DBType } from '../database/database'; import { ObjectStore } from '@itmat-broker/itmat-commons'; @@ -49,7 +49,7 @@ export class FileDownloadController { try { /* download file */ const file = await this._db.collections.files_collection.findOne({ id: requestedFile, deleted: null }); - if (!file) { + if (!file || !file.studyId) { res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); return; } diff --git a/packages/itmat-cores/src/trpcCore/configCore.ts b/packages/itmat-cores/src/trpcCore/configCore.ts new file mode 100644 index 000000000..da7c6e0af --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/configCore.ts @@ -0,0 +1,57 @@ +import { CoreError, defaultSettings, enumConfigType, enumCoreErrors, enumReservedUsers } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; +import { v4 as uuid } from 'uuid'; + +export class TRPCConfigCore { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + /** + * Get the config. + * + * @param configType - The type of the config.. + * @param key - The key of the config. studyid, userid, or null for system. + * @param useDefault - Whether to use the default config if not found. + * + * @return IConfig + */ + public async getConfig(configType: enumConfigType, key: string | null, useDefault: boolean) { + const config = await this.db.collections.configs_collection.findOne({ 'type': configType, 'key': key, 'life.deletedTime': null }); + if (!config) { + if (useDefault) { + return { + id: uuid(), + type: configType, + key: key, + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + properties: (() => { + if (configType === enumConfigType.CACHECONFIG) { + return defaultSettings.cacheConfig; + } else if (configType === enumConfigType.STUDYCONFIG) { + return defaultSettings.studyConfig; + } else if (configType === enumConfigType.SYSTEMCONFIG) { + return defaultSettings.systemConfig; + } else if (configType === enumConfigType.USERCONFIG) { + return defaultSettings.userConfig; + } else { + return defaultSettings.userConfig; + } + })() + }; + } else { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Config does not exist.' + ); + } + } + return config; + } +} \ No newline at end of file diff --git a/packages/itmat-cores/src/trpcCore/fileCore.ts b/packages/itmat-cores/src/trpcCore/fileCore.ts new file mode 100644 index 000000000..93c67d1f1 --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/fileCore.ts @@ -0,0 +1,251 @@ +import { enumConfigType, IFile, defaultSettings, enumFileTypes, enumFileCategories, IUserWithoutToken, IStudy, FileUpload, CoreError, enumCoreErrors, ISystemConfig, IStudyConfig, IUserConfig, IDocConfig, ICacheConfig, IDomainConfig, IGenericResponse } from '@itmat-broker/itmat-types'; +import { v4 as uuid } from 'uuid'; +import crypto, { BinaryLike } from 'crypto'; +import { DBType } from '../database/database'; +import { ObjectStore } from '@itmat-broker/itmat-commons'; +import { TRPCStudyCore } from './studyCore'; +import { makeGenericReponse } from '../utils'; + +/** + * This class provides methods to interact with files. + * Note that all core functions are should be called by other core functions or resolvers, not by the client. + * Necessary permission check should be done in the caller functions. + */ +export class TRPCFileCore { + db: DBType; + objStore: ObjectStore; + studyCore: TRPCStudyCore; + constructor(db: DBType, objStore: ObjectStore, studyCore: TRPCStudyCore) { + this.db = db; + this.objStore = objStore; + this.studyCore = studyCore; + } + /** + * Upload a file to storage. + * Note this function will upload file based on the input parameters regardless of the requester's permission and file metadata. + * + * @param requester - The requester. + * @param studyId - The id of the study. Could be null for non-study files. + * @param userId - The id of the user. + * @param fileUpload - The file to upload. + * @param description - The description of the file. + * @param fileType - The type of the file. + * @param fileCategory - The category of the file. + * @param properties - The properties of the file. Note if the data is attached to a field, the fieldproperties will be used. + * + * @return IFile - The object of IFile. + */ + public async uploadFile(requester: IUserWithoutToken, studyId: string | null, userId: string | null, fileUpload: FileUpload, fileType: enumFileTypes, fileCategory: enumFileCategories, description?: string, properties?: Record): Promise { + let study: IStudy | null = null; + if (studyId) { + study = (await this.studyCore.getStudies(studyId))[0]; + if (!study) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Study does not exist.' + ); + } + } + // fetch the config file. study file or user file or system file + let fileConfig: ISystemConfig | IStudyConfig | IUserConfig | IDocConfig | ICacheConfig | IDomainConfig | null = null; + let userRepoRemainingSpace = 0; + let fileSizeLimit: number; + let defaultFileBucketId: string; + if (fileCategory === enumFileCategories.STUDY_DATA_FILE && studyId) { + // study file config + const config = await this.db.collections.configs_collection.findOne({ type: enumConfigType.STUDYCONFIG, key: studyId }); + if (config) { + fileConfig = config.properties; + } else { + fileConfig = defaultSettings.studyConfig; + } + fileSizeLimit = (fileConfig as IStudyConfig).defaultMaximumFileSize; + defaultFileBucketId = studyId; + } else if (fileCategory === enumFileCategories.USER_DRIVE_FILE) { + // user file config + const config = await this.db.collections.configs_collection.findOne({ type: enumConfigType.USERCONFIG, key: requester.id }); + if (config) { + fileConfig = config.properties as IUserConfig; + } else { + fileConfig = defaultSettings.userConfig; + } + const totalSize: number = (await this.db.collections.files_collection.aggregate([{ + $match: { 'userId': requester.id, 'life.deletedTime': null } + }, { + $group: { _id: '$userId', totalSize: { $sum: '$fileSize' } } + }]))[0].totalSize; + userRepoRemainingSpace = (fileConfig as IUserConfig).defaultMaximumFileRepoSize - totalSize; + fileSizeLimit = Math.max((fileConfig as IUserConfig).defaultMaximumFileSize, userRepoRemainingSpace); + defaultFileBucketId = (fileConfig as IUserConfig).defaultFileBucketId; + } else if (fileCategory === enumFileCategories.DOC_FILE) { + const config = await this.db.collections.configs_collection.findOne({ type: enumConfigType.DOCCONFIG, key: null }); + if (config) { + fileConfig = config.properties as IDocConfig; + } else { + fileConfig = defaultSettings.docConfig; + } + fileSizeLimit = (fileConfig as IDocConfig).defaultMaximumFileSize; + defaultFileBucketId = (fileConfig as IDocConfig).defaultFileBucketId; + } else if (fileCategory === enumFileCategories.CACHE) { + const config = await this.db.collections.configs_collection.findOne({ type: enumConfigType.CACHECONFIG, key: null }); + if (config) { + fileConfig = config.properties as ICacheConfig; + } else { + fileConfig = defaultSettings.cacheConfig; + } + fileSizeLimit = (fileConfig as ICacheConfig).defaultMaximumFileSize; + defaultFileBucketId = (fileConfig as ICacheConfig).defaultFileBucketId; + } else if (fileCategory === enumFileCategories.PROFILE_FILE) { + const config = await this.db.collections.configs_collection.findOne({ type: enumConfigType.SYSTEMCONFIG, key: null }); + if (config) { + fileConfig = config.properties as ISystemConfig; + } else { + fileConfig = defaultSettings.systemConfig; + } + fileSizeLimit = (fileConfig as ISystemConfig).defaultMaximumFileSize; + defaultFileBucketId = (fileConfig as ISystemConfig).defaultProfileBucketId; + } else { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'File category does not exist.' + ); + } + + const fileUri = uuid(); + const hash = crypto.createHash('sha256'); + + // Create a read stream for the file + const fileStream = fileUpload.createReadStream(); + + return new Promise((resolve, reject) => { + (async () => { + try { + let fileSize = 0; + + fileStream.on('data', (chunk: BinaryLike) => { + hash.update(chunk); + fileSize += (chunk as Buffer).length; // Asserting the chunk as Buffer for length property + }); + + + try { + // Check file size limit + if (fileSize > fileSizeLimit) { + throw new Error('File size exceeds the limit.'); + } + + const hashString = hash.digest('hex'); + + // Create a new read stream for the file upload + const uploadStream = fileUpload.createReadStream(); + + // Upload the file to the storage + await this.objStore.uploadFile(uploadStream, defaultFileBucketId, fileUri); + + const fileEntry: IFile = { + id: uuid(), + studyId: studyId, + userId: userId, + fileName: fileUpload.filename, // Access filename directly from the fileUpload object. + fileSize: fileSize, // Use buffer's length for file size. + description: description, + uri: fileUri, + hash: hashString, + fileType: fileType, + fileCategory: fileCategory, + properties: properties ? properties : {}, + sharedUsers: [], + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + const insertResult = await this.db.collections.files_collection.insertOne(fileEntry as IFile); + if (insertResult.acknowledged) { + resolve(fileEntry as IFile); + } else { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } + + } catch (error) { + reject(new CoreError( + enumCoreErrors.FILE_STREAM_ERROR, + 'Error during file upload.' + )); + } + + + fileStream.on('error', (err) => { + reject(new CoreError( + enumCoreErrors.FILE_STREAM_ERROR, + 'Error reading file stream.' + String(err.message) + )); + }); + } catch (error) { + reject(new CoreError( + enumCoreErrors.FILE_STREAM_ERROR, + enumCoreErrors.FILE_STREAM_ERROR + )); + } + })().catch((e) => reject(e)); + }); + } + + /** + * Delete a file. + * + * @param requester - The requester. + * @param fileId - The id of the file. + * + * @return IGenericResponse + */ + public async deleteFile(requester: string, fileId: string): Promise { + const file = await this.db.collections.files_collection.findOne({ 'id': fileId, 'life.deletedTime': null }); + if (!file) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'File does not exist.' + ); + } + try { + await this.db.collections.files_collection.findOneAndUpdate({ id: fileId }, { $set: { 'life.deletedTime': Date.now().valueOf(), 'life.deletedUser': requester } }); + return makeGenericReponse(fileId, undefined, undefined, 'File has been deleted.'); + } catch { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } + } + + /** + * Get the list of files by fileIds in a simplified format or detailed format with explicit username. + * The aim is to avoid leaking user information in the frontend. + * + * @param fileIds + * @param readable + * @returns + */ + public async findFiles(fileIds: string[], readable?: boolean): Promise { + const result = await this.db.collections.files_collection.find({ id: { $in: fileIds } }).toArray(); + if (readable) { + const users = await this.db.collections.users_collection.find({ 'life.deletedTime': null }).toArray(); + result.forEach(el => { + const user = users.filter(ek => ek.id === el.life.createdUser)[0]; + if (!user) { + el.life.createdUser = 'UNKNOWN'; + } else { + el.life.createdUser = `${user.firstname} ${user.lastname}`; + } + }); + } + return result; + } +} + diff --git a/packages/itmat-cores/src/trpcCore/studyCore.ts b/packages/itmat-cores/src/trpcCore/studyCore.ts new file mode 100644 index 000000000..081c4a504 --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/studyCore.ts @@ -0,0 +1,34 @@ +import { ObjectStore } from '@itmat-broker/itmat-commons'; +import { DBType } from '../database/database'; +import { CoreError, IStudy, enumCoreErrors } from '@itmat-broker/itmat-types'; +import { Filter } from 'mongodb'; + +export class TRPCStudyCore { + db: DBType; + objStore: ObjectStore; + constructor(db: DBType, objStore: ObjectStore) { + this.db = db; + this.objStore = objStore; + } + /** + * Get the info of a study. + * + * @param studyId - The id of the study. + * + * @return IStudy - The object of IStudy. + */ + public async getStudies(studyId: string | null): Promise { + const query: Filter = { 'life.deletedTime': null }; + if (studyId) { + query.id = studyId; + } + const studies = await this.db.collections.studies_collection.find(query).toArray(); + if (studies.length === 0) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Study does not exist.' + ); + } + return studies; + } +} \ No newline at end of file diff --git a/packages/itmat-cores/src/trpcCore/userCore.ts b/packages/itmat-cores/src/trpcCore/userCore.ts new file mode 100644 index 000000000..979e729a3 --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/userCore.ts @@ -0,0 +1,1301 @@ +import { CoreError, FileUpload, IFile, IPubkey, IResetPasswordRequest, IUser, IUserWithoutToken, defaultSettings, enumCoreErrors, enumFileCategories, enumFileTypes, enumUserTypes } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; +import { Logger, Mailer, ObjectStore } from '@itmat-broker/itmat-commons'; +import { IConfiguration, makeGenericReponse, rsakeygen, rsaverifier, tokengen } from '../utils'; +import { TRPCFileCore } from './fileCore'; +import { TRPCConfigCore } from './configCore'; +import { TRPCStudyCore } from './studyCore'; +import { v4 as uuid } from 'uuid'; +import bcrypt from 'bcrypt'; +import * as mfa from '../utils/mfa'; +import QRCode from 'qrcode'; +import tmp from 'tmp'; +import { UpdateFilter } from 'mongodb'; +import { decryptEmail, encryptEmail, makeAESIv, makeAESKeySalt } from '..'; + +export class TRPCUserCore { + db: DBType; + mailer: Mailer; + config: IConfiguration; + objStore: ObjectStore; + fileCore: TRPCFileCore; + configCore: TRPCConfigCore; + studyCore: TRPCStudyCore; + constructor(db: DBType, mailer: Mailer, config: IConfiguration, objStore: ObjectStore) { + this.db = db; + this.mailer = mailer; + this.config = config; + this.objStore = objStore; + this.studyCore = new TRPCStudyCore(db, objStore); + this.fileCore = new TRPCFileCore(db, objStore, this.studyCore); + this.configCore = new TRPCConfigCore(db); + } + /** + * Get a user. One of the parameters should not be null, we will find users by the following order: usreId, username, email. + * + * @param requester - The requester. + * @param userId - The id of the user. + * @param username - The username of the user. + * @param email - The email of the user. + * + * @return IUserWithoutToken | null - Thu user object; or null if the user does not exist. + */ + public async getUser(requester: IUserWithoutToken | undefined, userId?: string, username?: string, email?: string): Promise { + if (!userId && !username && !email) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'At least one of the parameters should not be empty.' + ); + } + if (!requester || (requester.type !== enumUserTypes.ADMIN && ((userId && requester.id !== userId) || (username && requester.username !== username) || (email && requester.email !== email)))) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + + let user: IUserWithoutToken | null = null; + if (userId) { + user = await this.db.collections.users_collection.findOne({ 'id': userId, 'life.deletedTime': null }, { projection: { password: 0, otpSecret: 0 } }); + } else if (username) { + user = await this.db.collections.users_collection.findOne({ 'username': username, 'life.deletedTime': null }, { projection: { password: 0, otpSecret: 0 } }); + } else if (email) { + user = await this.db.collections.users_collection.findOne({ 'email': email, 'life.deletedTime': null }, { projection: { password: 0, otpSecret: 0 } }); + } else { + return null; + } + + if (!user) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'User does not exist.' + ); + } + return user; + } + + /** + * Get all users. + * + * @param requester - The id of the requester. + * @param includeDeleted - Whether to include users that have been deleted. + * + * @return IUserWithoutToken[] - The list of users. + */ + public async getUsers(requester: IUserWithoutToken | undefined, includeDeleted?: boolean): Promise { + if (!requester || requester.type !== enumUserTypes.ADMIN) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + return includeDeleted ? await this.db.collections.users_collection.find({}).toArray() : await this.db.collections.users_collection.find({ 'life.deletedTime': null }).toArray(); + } + + /** + * Validate the token from the reset password request. + * + * @param encryptedEmail - The encrypted email. + * @param token - The token for resetting password. + * @returns IGenericResponse + */ + public async validateResetPassword(encryptedEmail: string, token: string) { + /* decrypt email */ + const salt = makeAESKeySalt(token); + const iv = makeAESIv(token); + let email; + try { + email = await decryptEmail(this.config.aesSecret, encryptedEmail, salt, iv); + } catch (e) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Token is not valid.' + ); + } + + /* check whether username and token is valid */ + /* not changing password too in one step (using findOneAndUpdate) because bcrypt is costly */ + const TIME_NOW = new Date().valueOf(); + const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; + const user: IUser | null = await this.db.collections.users_collection.findOne({ + email, + 'resetPasswordRequests': { + $elemMatch: { + id: token, + timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, + used: false + } + }, + 'life.deletedTime': null + }); + if (!user) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'User does not exist.' + ); + } + return makeGenericReponse(user.id, true); + } + + /** + * Create a user. + * + * @param username - The username of the user, should be unique. + * @param email - The email address of the user. + * @param firstname - The first name of the user. + * @param lastname - The last name of the user. + * @param organisation - The id of the user's organisation. Should be one of the organisaiton in the database. + * @param type - The user type of the user. + * @param emailNotificationsActivated - Whether email notification service is activared. + * @param password - The password of the user, should be hashed. + * @param profile - The profile of the user. + * @param description - The description of the user. + * @param requester - The id of the requester. + * + * @return Partial - The object of IUser. Remove private information. + */ + public async createUser(requester: IUserWithoutToken | undefined, username: string, email: string, firstname: string, lastname: string, organisation: string, type: enumUserTypes, emailNotificationsActivated: boolean, password: string, profile?: FileUpload, description?: string): Promise> { + /* check email is valid form */ + if (!/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Email is not the right format.' + ); + } + + /* check password validity */ + if (password && !passwordIsGoodEnough(password)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Password has to be at least 8 character long.' + ); + } + + /* check that username and password dont have space */ + if (username.indexOf(' ') !== -1 || password.indexOf(' ') !== -1) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Username or password cannot have spaces.' + ); + } + /* randomly generate a secret for Time-based One Time Password*/ + const otpSecret = mfa.generateSecret(); + + const user = await this.db.collections.users_collection.findOne({ + $or: [ + { username: username }, + { email: email } + ] + }); + if (user) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Username or email already exists.' + ); + } + const org = await this.db.collections.organisations_collection.findOne({ 'id': organisation, 'life.deletedTime': null }); + if (!org) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Organisation does not exist.' + ); + } + // fetch the config file + const userConfig = defaultSettings.userConfig; + + const userId: string = uuid(); + const hashedPassword: string = await bcrypt.hash(password, this.config.bcrypt.saltround); + const expiredAt = Date.now() + 86400 * 1000 /* millisec per day */ * (userConfig.defaultUserExpiredDays); + + const entry: IUser = { + id: userId, + username: username, + email: email, + firstname: firstname, + lastname: lastname, + organisation: organisation, + type: type, + emailNotificationsActivated: emailNotificationsActivated, + emailNotificationsStatus: { + expiringNotification: false + }, + resetPasswordRequests: [], + password: hashedPassword, + otpSecret: otpSecret, + profile: undefined, + description: description ?? '', + expiredAt: expiredAt, + life: { + createdTime: Date.now(), + createdUser: requester?.id ?? userId, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + let fileEntry: IFile; + if (profile) { + if (!Object.keys(enumFileTypes).includes((profile?.filename?.split('.').pop() || '').toUpperCase())) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'File type not supported.' + ); + } + fileEntry = await this.fileCore.uploadFile(entry, null, userId, profile, + enumFileTypes[(profile.filename.split('.').pop() || '').toUpperCase() as keyof typeof enumFileTypes], enumFileCategories.PROFILE_FILE); + entry.profile = fileEntry?.id; + } + await this.db.collections.users_collection.insertOne(entry); + // TODO: add drive for newly registered user + // const driveId = uuid(); + // await db.collections!.drives_collection.insertOne({ + // id: driveId, + // path: [driveId], + // restricted: true, + // name: 'My Drive', + // description: 'This is your own drive.', + // fileId: null, + // type: enumDriveNodeTypes.FOLDER, + // parent: null, + // children: [ + + // ], + // sharedUsers: [ + + // ], + // sharedGroups: [ + + // ], + // life: { + // createdTime: Date.now(), + // createdUser: userId, + // deletedTime: null, + // deletedUser: null + // }, + // metadata: { + + // }, + // managerId: userId + // }); + + /* send email to the registered user */ + // get QR Code for the otpSecret. + const oauth_uri = `otpauth://totp/${this.config.appName}:${username}?secret=${otpSecret}&issuer=Data%20Science%20Institute`; + const tmpobj = tmp.fileSync({ mode: 0o644, prefix: 'qrcodeimg-', postfix: '.png' }); + + QRCode.toFile(tmpobj.name, oauth_uri, {}, function (err) { + if (err) { + throw new CoreError( + enumCoreErrors.UNQUALIFIED_ERROR, + err.message + ); + } + }); + + const attachments = [{ filename: 'qrcode.png', path: tmpobj.name, cid: 'qrcode_cid' }]; + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: email, + subject: `[${this.config.appName}] Registration Successful`, + html: ` +

+ Dear ${firstname}, +

+

+ Welcome to the ${this.config.appName} data portal!
+ Your username is ${username}.
+

+

+ To login you will need to use a MFA authenticator app for one time passcode (TOTP).
+ Scan the QRCode below in your MFA application of choice to configure it:
+ QR code
+ If you need to type the token in use ${otpSecret.toLowerCase()} +

+
+

+ The ${this.config.appName} Team. +

+ `, + attachments: attachments + }); + tmpobj.removeCallback(); + + return entry; + } + + /** + * Edit an existing user. Note, this function will use all default values, so if you want to keep some fields the same, you need to first fetch the original values as the inputs. + * + * @param requester - The id of the requester. + * @param userId - The id of the user. + * @param username - The username of the user, should be unique. + * @param email - Optional. The emailAddress of the user. + * @param firstname - Optional. The first name of the user. + * @param lastname - Optional. The last name of the user. + * @param organisation - Optional. The id of the user's organisation. Should be one of the organisaiton in the database. + * @param type - Optional. The user type of the user. + * @param emailNotificationsActivated - Optional. Whether email notification service is activared. + * @param password - Optional. The password of the user, should be hashed. + * @param otpSecret - Optional. The otp secret of the user. + * @param profile - Optional. The image of the profile of the user. Could be null. + * @param description - Optional. The description of the user. + * @param expiredAt - Optional. The expired timestamps of the user. + * + * @return Partial - The object of IUser. Remove private information. + */ + public async editUser(requester: IUserWithoutToken | undefined, userId: string, username?: string, email?: string, firstname?: string, lastname?: string, organisation?: string, type?: enumUserTypes, emailNotificationsActivated?: boolean, password?: string, otpSecret?: string, profile?: FileUpload, description?: string, expiredAt?: number): Promise> { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + if (requester.type !== enumUserTypes.ADMIN && requester.id !== userId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only edit his/her own account.' + ); + } + + if (requester.type !== enumUserTypes.ADMIN && (type || expiredAt || organisation)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'Standard user can not change their type, expiration time and organisation. Please contact admins for help.' + ); + } + + if (password && !passwordIsGoodEnough(password)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Password has to be at least 8 character long.' + ); + } + + /* check that username and password dont have space */ + if ((username && username.indexOf(' ') !== -1) || (password && password.indexOf(' ') !== -1)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Username or password cannot have spaces.' + ); + } + + /* check email is valid form */ + if (email && !/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Email is not the right format.' + ); + } + + if (expiredAt && expiredAt < Date.now()) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Cannot set to a previous time.' + ); + } + + + const user = await this.db.collections.users_collection.findOne({ 'id': userId, 'life.deletedTime': null }); + if (!user) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'User does not exist.' + ); + } + const setObj: UpdateFilter = {}; + if (username && username !== user.username) { + const existUsername = await this.db.collections.users_collection.findOne({ 'username': username, 'life.deletedTime': null }); + if (existUsername) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Username already used.' + ); + } + setObj['username'] = username; + } + + if (email && email !== user.email) { + const existEmail = await this.db.collections.users_collection.findOne({ 'email': email, 'life.deletedTime': null }); + if (existEmail) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Email already used.' + ); + } + setObj['email'] = email; + } + + if (organisation) { + const org = await this.db.collections.organisations_collection.findOne({ 'id': organisation, 'life.deletedTime': null }); + if (!org) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Organisation does not exist.' + ); + } + setObj['organisation'] = organisation; + } + + if (password) { + const hashedPassword: string = await bcrypt.hash(password, this.config.bcrypt.saltround); + if (await bcrypt.compare(password, user.password)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'You need to select a new password.' + ); + } + setObj['password'] = hashedPassword; + } + + if (otpSecret) { + setObj['otpSecret'] = otpSecret; + } + + let fileEntry; + if (profile) { + if (!Object.keys(enumFileTypes).includes((profile?.filename?.split('.').pop() || '').toUpperCase())) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'File type not supported.' + ); + } + fileEntry = await this.fileCore.uploadFile(requester, null, user.id, profile, enumFileTypes[(profile.filename.split('.').pop() || '').toUpperCase() as keyof typeof enumFileTypes], enumFileCategories.PROFILE_FILE); + setObj['profile'] = fileEntry.id; + } + + if (expiredAt) { + setObj['expiredAt'] = expiredAt; + } + + const result = await this.db.collections.users_collection.findOneAndUpdate({ id: user.id }, { + $set: setObj + }, { + returnDocument: 'after' + }); + if (!result) { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } else { + if (expiredAt) { + /* send email to client */ + await this.mailer.sendMail(formatEmailRequestExpiryDateNotification({ + config: this.config, + to: result.email, + username: result.username + })); + } + return result; + } + } + + /** + * Delete an user. + * + * @param requester - The requester. + * @param userId - The id of the user. + * + * @return IGenericResponse - General response. + */ + public async deleteUser(requester: IUserWithoutToken | undefined, userId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + /* Admins can delete anyone, while general user can only delete themself */ + if (!(requester.type === enumUserTypes.ADMIN) && !(requester.id === userId)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only delete his/her own account.' + ); + } + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + /* Admins can delete anyone, while general user can only delete themself */ + if (!(requester.type === enumUserTypes.ADMIN) && !(requester.id === userId)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + const user = await this.db.collections.users_collection.findOne({ 'id': userId, 'life.deletedTime': null }); + if (!user) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'User does not exist.' + ); + } + + const session = this.db.client.startSession(); + session.startTransaction(); + try { + /* delete the user */ + await this.db.collections.users_collection.findOneAndUpdate({ 'id': userId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': Date.now(), 'life.deletedUser': requester, 'password': 'DeletedUserDummyPassword', 'otpSecret': 'DeletedUserDummpOtpSecret' } }, { returnDocument: 'after' }); + + /* delete all user records in roles related to the study */ + await this.db.collections.roles_collection.updateMany({ + 'life.deletedTime': null, + 'users': userId + }, { + $pull: { users: userId } + }); + + await session.commitTransaction(); + await session.endSession(); + return makeGenericReponse(userId, true, undefined, `User ${user.username} has been deleted.`); + } catch (error) { + // If an error occurred, abort the whole transaction and + // undo any changes that might have happened + await session.abortTransaction(); + await session.endSession(); + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + String(error) + ); + } + } + + /** + * Get keys of a user. + * + * @param requester - The requester. + * @param userId - The id of the user. + */ + public async getUserKeys(requester: IUserWithoutToken | undefined, userId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + if (requester.type !== enumUserTypes.ADMIN && requester.id !== userId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only get his/her own keys.' + ); + } + return await this.db.collections.pubkeys_collection.find({ 'associatedUserId': userId, 'life.deletedTime': null }).toArray(); + } + /** + * Register a pubkey to a user. + * + * @param requester - The id of the requester. + * @param pubkey - The public key. + * @param signature - The signature of the key. + * @param associatedUserId - The user whom to attach the publick key to. + * + * @return IPubkey - The object of ther registered key. + */ + public async registerPubkey(requester: IUserWithoutToken | undefined, pubkey: string, signature: string, associatedUserId: string): Promise { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + if (requester.type !== enumUserTypes.ADMIN && requester.id !== associatedUserId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only register his/her own public key.' + ); + } + + // refine the public-key parameter from browser + pubkey = pubkey.replace(/\\n/g, '\n'); + const alreadyExist = await this.db.collections.pubkeys_collection.findOne({ pubkey, 'life.deletedTime': null }); + if (alreadyExist) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'This public-key has already been registered.' + ); + } + + const user = await this.db.collections.users_collection.findOne({ 'id': requester.id, 'life.deletedTime': null }); + if (!user) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'User does not exist.' + ); + } + + /* Validate the signature with the public key */ + try { + const signature_verifier = await rsaverifier(pubkey, signature); + if (!signature_verifier) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Signature vs Public-key mismatched.' + ); + } + } catch (error) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Error: Signature or Public-key is incorrect.' + ); + } + + /* Generate a public key-pair for generating and authenticating JWT access token later */ + const keypair = rsakeygen(); + + const entry: IPubkey = { + id: uuid(), + pubkey: pubkey, + associatedUserId: associatedUserId, + jwtPubkey: keypair.publicKey, + jwtSeckey: keypair.privateKey, + refreshCounter: 0, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + + await this.db.collections.pubkeys_collection.insertOne(entry); + + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: user.email, + subject: `[${this.config.appName}] Public-key Registration!`, + html: ` +

+ Dear ${user.firstname}, +

+

+ You have successfully registered your public-key "${pubkey}" on ${this.config.appName}!
+ You will need to keep your private key secretly.
+ You will also need to sign a message (using your public-key) to authenticate the owner of the public key.
+

+ +
+

+ The ${this.config.appName} Team. +

+ ` + }); + return entry; + } + + /** + * Delete a pubkey. + * + * @param requester - The requester. + * @param userId - The id of the user. + * @param keyId - The id of the key. + * @returns IGenericResponse + */ + public async deletePubkey(requester: IUserWithoutToken | undefined, associatedUserId: string, keyId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + if (requester.type !== enumUserTypes.ADMIN && requester.id !== associatedUserId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only delete his/her own public key.' + ); + } + const key = await this.db.collections.pubkeys_collection.findOne({ + 'id': keyId, + 'associatedUserId': associatedUserId, + 'life.deletedTime': null + }); + if (!key) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Key does not exist.' + ); + } + if (key.associatedUserId !== associatedUserId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'The key does not match the user.' + ); + } + + await this.db.collections.pubkeys_collection.findOneAndUpdate({ id: keyId }, { + $set: { + 'life.deletedTime': Date.now(), + 'life.deletedUser': requester + } + }); + + return makeGenericReponse(keyId, true, undefined, undefined); + } + + /** + * Add a reset password request to the user. + * + * @param userId - The id of the user. + * @param resetPasswordRequest - The reset password request. + * @returns IGenericResponse + */ + public async addResetPasswordRequest(userId: string, resetPasswordRequest: IResetPasswordRequest) { + const invalidateAllTokens = await this.db.collections.users_collection.findOneAndUpdate( + { id: userId }, + { + $set: { + 'resetPasswordRequests.$[].used': true + } + } + ); + if (!invalidateAllTokens) { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } + const updateResult = await this.db.collections.users_collection.findOneAndUpdate( + { id: userId }, + { + $push: { + resetPasswordRequests: resetPasswordRequest + } + } + ); + if (!updateResult) { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } + + return makeGenericReponse(resetPasswordRequest.id, true, undefined, undefined); + } + + public async processResetPasswordRequest(token: string, email: string, password: string) { + /* check whether username and token is valid */ + /* not changing password too in one step (using findOneAndUpdate) because bcrypt is costly */ + const TIME_NOW = new Date().valueOf(); + const ONE_HOUR_IN_MILLISEC = 60 * 60 * 1000; + const user = await this.db.collections.users_collection.findOne({ + email, + 'resetPasswordRequests': { + $elemMatch: { + id: token, + timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, + used: false + } + }, + 'life.deletedTime': null + }); + if (!user) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'User does not exist.' + ); + } + + /* randomly generate a secret for Time-based One Time Password*/ + const otpSecret = mfa.generateSecret(); + + /* all ok; change the user's password */ + const hashedPw = await bcrypt.hash(password, this.config.bcrypt.saltround); + const updateResult = await this.db.collections.users_collection.findOneAndUpdate( + { + id: user.id, + resetPasswordRequests: { + $elemMatch: { + id: token, + timeOfRequest: { $gt: TIME_NOW - ONE_HOUR_IN_MILLISEC }, + used: false + } + } + }, + { $set: { 'password': hashedPw, 'otpSecret': otpSecret, 'resetPasswordRequests.$.used': true } }); + if (!updateResult) { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } + + return updateResult; + } + + /** + * Issue an access token. + * + * @param pubkey - The public key. + * @param signature - The signature of the key. + * @param life - The life of the token. + * @returns + */ + public async issueAccessToken(pubkey: string, signature: string, life?: number) { + // refine the public-key parameter from browser + pubkey = pubkey.replace(/\\n/g, '\n'); + + /* Validate the signature with the public key */ + if (!await rsaverifier(pubkey, signature)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Signature vs Public key mismatched.' + ); + } + + const pubkeyrec = await this.db.collections.pubkeys_collection.findOne({ pubkey, deleted: null }); + if (pubkeyrec === null || pubkeyrec === undefined) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'This public-key has not been registered yet.' + ); + } + + // payload of the JWT for storing user information + const payload = { + publicKey: pubkeyrec.jwtPubkey, + associatedUserId: pubkeyrec.associatedUserId, + refreshCounter: pubkeyrec.refreshCounter, + Issuer: 'IDEA-FAST DMP' + }; + + // update the counter + const fieldsToUpdate = { + refreshCounter: (pubkeyrec.refreshCounter + 1) + }; + const updateResult = await this.db.collections.pubkeys_collection.findOneAndUpdate({ pubkey, deleted: null }, { $set: fieldsToUpdate }, { returnDocument: 'after' }); + if (!updateResult) { + throw new CoreError( + enumCoreErrors.DATABASE_ERROR, + enumCoreErrors.DATABASE_ERROR + ); + } + // return the acccess token + const accessToken = { + accessToken: tokengen(payload, pubkeyrec.jwtSeckey, undefined, undefined, life) + }; + + return accessToken; + } + + /** + * Ask for a request to extend account expiration time. Send notifications to user and admin. + * + * @param userId - The id of the user. + * + * @return IGenericResponse + */ + public async requestExpiryDate(requester: IUserWithoutToken | undefined, userId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + if (requester.type !== enumUserTypes.ADMIN && requester.id !== userId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only request for his/her own account.' + ); + } + const user = await this.db.collections.users_collection.findOne({ 'id': userId, 'life.deletedTime': null }); + if (!user || !user.email || !user.username) { + /* even user is null. send successful response: they should know that a user doesn't exist */ + await new Promise(resolve => setTimeout(resolve, Math.random() * 6000)); + return makeGenericReponse(userId, false, undefined, 'User information is not correct.'); + } + /* send email to the DMP admin mailing-list */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ + config: this.config, + userEmail: user.email, + username: user.username + })); + + /* send email to client */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoClient({ + config: this.config, + to: user.email, + username: user.username + })); + + return makeGenericReponse(userId, true, undefined, 'Request successfully sent.'); + } + + /** + * Request for resetting password. + * + * @param forgotUsername - Whether user forget the username. + * @param forgotPassword - Whether user forgot the password. + * @param email - The email of the user. If using email to reset password. + * @param username - The username of the uer. If using username to reset password. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + public async requestUsernameOrResetPassword(forgotUsername: boolean, forgotPassword: boolean, origin: string, email?: string, username?: string) { + /* checking the args are right */ + if ((forgotUsername && !email) // should provide email if no username + || (forgotUsername && username) // should not provide username if it's forgotten. + || (!email && !username)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Inputs are invalid.' + ); + } else if (email && username) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Inputs are invalid.' + ); + } + + /* check user existence */ + const user = await this.db.collections.users_collection.findOne({ '$or': [{ email }, { username }], 'life.deletedTime': null }); + if (!user) { + /* even user is null. send successful response: they should know that a user doesn't exist */ + await new Promise(resolve => setTimeout(resolve, Math.random() * 6000)); + return makeGenericReponse(undefined, false, undefined, 'User does not exist.'); + } + + if (forgotPassword) { + /* make link to change password */ + const passwordResetToken = uuid(); + const resetPasswordRequest: IResetPasswordRequest = { + id: passwordResetToken, + timeOfRequest: new Date().valueOf(), + used: false + }; + await this.addResetPasswordRequest(user.id ?? '', resetPasswordRequest); + + /* send email to client */ + await this.mailer.sendMail(await formatEmailForForgottenPassword({ + config: this.config, + to: user.email ?? '', + resetPasswordToken: passwordResetToken, + username: user.username ?? '', + firstname: user.firstname ?? '', + origin: origin + })); + } else { + /* send email to client */ + await this.mailer.sendMail(formatEmailForFogettenUsername({ + config: this.config, + to: user.email ?? '', + username: user.username ?? '' + })); + } + return makeGenericReponse(user.id, true, undefined, 'Request of resetting password successfully sent.'); + } + + /** + * Login a user. + * + * @param req - The request object. + * @param username - The username of the user. + * @param password - The password of the user. + * @param totp - The one-time password of the user. + * @returns IUserWithoutToken + */ + public async login(req: Express.Request, username: string, password: string, totp: string, requestExpiryDate?: boolean) { + const user = await this.db.collections.users_collection.findOne({ username }); + if (!user || !user.password || !user.otpSecret || !user.email || !user.username) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'User does not exist.' + ); + } + + const passwordMatched = await bcrypt.compare(password, user.password); + if (!passwordMatched) { + if (process.env['NODE_ENV'] === 'development') + console.warn('Incorrect password. Continuing in development ...'); + else { + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'Incorrect password.' + ); + } + } + + /* validate the TOTP */ + const totpValidated = mfa.verifyTOTP(totp, user.otpSecret); + if (!totpValidated) { + if (process.env['NODE_ENV'] === 'development') + console.warn('Incorrect One-Time password. Continuing in development ...'); + else { + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'Incorrect One-Time password.' + ); + } + } + + /* validate if account expired */ + if (user.expiredAt && user.expiredAt < Date.now() && user.type === enumUserTypes.STANDARD) { + if (requestExpiryDate) { + /* send email to the DMP admin mailing-list */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ + config: this.config, + userEmail: user.email, + username: user.username + })); + /* send email to client */ + await this.mailer.sendMail(formatEmailRequestExpiryDatetoClient({ + config: this.config, + to: user.email, + username: user.username + })); + throw new CoreError( + enumCoreErrors.UNQUALIFIED_ERROR, + 'New expiry date has been requested! Wait for ADMIN to approve.' + ); + } + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'Account Expired. Please request a new expiry date!' + ); + } + + const { password: __unusedPassword, otpSecret: __unusedOtpSecret, ...filteredUser } = user; + return new Promise((resolve) => { + req.login(filteredUser, (err) => { + if (err) { + Logger.error(err); + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'Cannot log in. Please try again later.' + ); + } + resolve(filteredUser); + }); + }); + } + + public async logout(requester: IUserWithoutToken | undefined, req: Express.Request) { + if (!requester) { + return makeGenericReponse(undefined, false, undefined, 'Requester not known.'); + } + return new Promise((resolve) => { + req.logout((err) => { + if (err) { + Logger.error(err); + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'Cannot log out. Please try again later.' + ); + } else { + resolve(makeGenericReponse(requester.id)); + } + }); + }); + } + + /** + * Reset password. + * @param encryptedEmail - The encrypted email. + * @param token - The token. + * @param newPassword - The new password. + * @returns IGnericResponse + */ + public async resetPassword(encryptedEmail: string, token: string, newPassword: string) { + if (!passwordIsGoodEnough(newPassword)) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Password has to be at least 8 character long.' + ); + } + + /* check that username and password dont have space */ + if (newPassword.indexOf(' ') !== -1) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Password cannot have spaces.' + ); + } + + /* decrypt email */ + if (token.length < 16) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Token is invalid.' + ); + } + + // TODO + const salt = makeAESKeySalt(token); + const iv = makeAESIv(token); + let email; + try { + email = await decryptEmail(this.config.aesSecret, encryptedEmail, salt, iv); + } catch (e) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Token is invalid.' + ); + } + + const user = await this.processResetPasswordRequest(token, email, newPassword); + /* need to log user out of all sessions */ + // TO_DO + + /* send email to the registered user */ + // get QR Code for the otpSecret. + const oauth_uri = `otpauth://totp/${this.config.appName}:${user.username}?secret=${user.otpSecret}&issuer=Data%20Science%20Institute`; + const tmpobj = tmp.fileSync({ mode: 0o644, prefix: 'qrcodeimg-', postfix: '.png' }); + + QRCode.toFile(tmpobj.name, oauth_uri, {}, function (err) { + if (err) { + throw new CoreError( + enumCoreErrors.UNQUALIFIED_ERROR, + err.message + ); + } + }); + + const attachments = [{ filename: 'qrcode.png', path: tmpobj.name, cid: 'qrcode_cid' }]; + await this.mailer.sendMail({ + from: `${this.config.appName} <${this.config.nodemailer.auth.user}>`, + to: email, + subject: `[${this.config.appName}] Password reset`, + html: ` +

+ Dear ${user.firstname}, +

+

+ Your password on ${this.config.appName} is now reset!
+ You will need to update your MFA application for one-time passcode.
+

+

+ To update your MFA authenticator app you can scan the QRCode below to configure it:
+ QR code
+ If you need to type the token in use ${(user.otpSecret as string).toLowerCase()} +

+
+

+ The ${this.config.appName} Team. +

+ `, + attachments: attachments + }); + tmpobj.removeCallback(); + return makeGenericReponse(); + } +} + +function passwordIsGoodEnough(pw: string): boolean { + if (pw.length < 8) { + return false; + } + return true; +} + +async function formatEmailForForgottenPassword({ config, username, firstname, to, resetPasswordToken, origin }: { config: IConfiguration, resetPasswordToken: string, to: string, username: string, firstname: string, origin: string }) { + const keySalt = makeAESKeySalt(resetPasswordToken); + const iv = makeAESIv(resetPasswordToken); + const encryptedEmail = await encryptEmail(config.aesSecret, to, keySalt, iv); + + const link = `${origin}/reset/${encryptedEmail}/${resetPasswordToken}`; + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] password reset`, + html: ` +

+ Dear ${firstname}, +

+

+ Your username is ${username}. +

+

+ You can reset you password by click the following link (active for 1 hour):
+ ${link} +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailForFogettenUsername({ config, username, to }: { config: IConfiguration, username: string, to: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] password reset`, + html: ` +

+ Dear user, +

+

+ Your username is ${username}. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailRequestExpiryDatetoClient({ config, username, to }: { config: IConfiguration, username: string, to: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] New expiry date has been requested!`, + html: ` +

+ Dear user, +

+

+ New expiry date for your ${username} account has been requested. + You will get a notification email once the request is approved. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailRequestExpiryDatetoAdmin({ config, username, userEmail }: { config: IConfiguration, username: string, userEmail: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to: `${config.adminEmail}`, + subject: `[${config.appName}] New expiry date has been requested from ${username} account!`, + html: ` +

+ Dear ADMINs, +

+

+ A expiry date request from the ${username} account (whose email address is ${userEmail}) has been submitted. + Please approve or deny the request ASAP. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} + +function formatEmailRequestExpiryDateNotification({ config, username, to }: { config: IConfiguration, username: string, to: string }) { + return ({ + from: `${config.appName} <${config.nodemailer.auth.user}>`, + to, + subject: `[${config.appName}] New expiry date has been updated!`, + html: ` +

+ Dear user, +

+

+ New expiry date for your ${username} account has been updated. + You now can log in as normal. +

+
+

+ The ${config.appName} Team. +

+ ` + }); +} diff --git a/packages/itmat-cores/src/utils/query.ts b/packages/itmat-cores/src/utils/query.ts index 583464319..edb864b7b 100644 --- a/packages/itmat-cores/src/utils/query.ts +++ b/packages/itmat-cores/src/utils/query.ts @@ -1,6 +1,6 @@ -import { IStudy, IFieldEntry, IStandardization, ICohortSelection, IQueryString, IGroupedData, StandardizationFilterOptionParameters, StandardizationFilterOptions } from '@itmat-broker/itmat-types'; +import { IStudy, IField, IStandardization, ICohortSelection, IQueryString, IGroupedData, StandardizationFilterOptionParameters, StandardizationFilterOptions } from '@itmat-broker/itmat-types'; import { Filter } from 'mongodb'; -import { QueryMatcher } from '../core/permissionCore'; +import { QueryMatcher } from '../GraphQLCore/permissionCore'; /* queryString: format: string # returned foramt: raw, standardized, grouped, summary @@ -12,7 +12,7 @@ import { QueryMatcher } from '../core/permissionCore'; // if has study-level permission, non versioned data will also be returned -export function buildPipeline(query: IQueryString, studyId: string, permittedVersions: Array, permittedFields: IFieldEntry[], metadataFilter, isAdmin: boolean, includeUnversioned: boolean) { +export function buildPipeline(query: IQueryString, studyId: string, permittedVersions: Array, permittedFields: IField[], metadataFilter, isAdmin: boolean, includeUnversioned: boolean) { let fieldIds: string[] = permittedFields.map(el => el.fieldId); const fields = { _id: 0, m_subjectId: 1, m_visitId: 1 }; // We send back the requested fields, by default send all fields @@ -25,7 +25,7 @@ export function buildPipeline(query: IQueryString, studyId: string, permittedVer } }); } else if (query['table_requested'] !== undefined && query['table_requested'] !== undefined) { - fieldIds = permittedFields.filter(el => el.tableName === query['table_requested']).map(el => el.fieldId); + fieldIds = permittedFields.filter(el => el.metadata['tableName'] === query['table_requested']).map(el => el.fieldId); fieldIds.forEach((field: string) => { fields[field] = 1; }); @@ -49,19 +49,19 @@ export function buildPipeline(query: IQueryString, studyId: string, permittedVer } const groupFilter = [{ - $match: { m_fieldId: { $in: fieldIds } } + $match: { fieldId: { $in: fieldIds } } }, { - $sort: { uploadedAt: -1 } + $sort: { 'life.createdTime': -1 } }, { $group: { - _id: { m_subjectId: '$m_subjectId', m_visitId: '$m_visitId', m_fieldId: '$m_fieldId' }, + _id: { m_subjectId: '$properties.m_subjectId', m_visitId: '$properties.m_visitId', m_fieldId: '$fieldId' }, doc: { $first: '$$ROOT' } } }, { $project: { - m_subjectId: '$doc.m_subjectId', - m_visitId: '$doc.m_visitId', - m_fieldId: '$doc.m_fieldId', + m_subjectId: '$_id.m_subjectId', + m_visitId: '$_id.m_visitId', + m_fieldId: '$_id.m_fieldId', value: '$doc.value', _id: 0 } @@ -69,7 +69,7 @@ export function buildPipeline(query: IQueryString, studyId: string, permittedVer ]; if (isAdmin) { return [ - { $match: { m_fieldId: { $regex: /^(?!Device)\w+$/ }, m_versionId: { $in: permittedVersions }, m_studyId: studyId } }, + { $match: { fieldId: { $regex: /^(?!Device)\w+$/ }, dataVersion: { $in: permittedVersions }, studyId: studyId } }, ...groupFilter, { $match: match } // { $project: fields } @@ -77,7 +77,7 @@ export function buildPipeline(query: IQueryString, studyId: string, permittedVer } else { if (includeUnversioned) { return [ - { $match: { m_fieldId: { $regex: /^(?!Device)\w+$/ }, m_versionId: { $in: permittedVersions }, m_studyId: studyId } }, + { $match: { fieldId: { $regex: /^(?!Device)\w+$/ }, dataVersion: { $in: permittedVersions }, studyId: studyId } }, { $match: { $or: [metadataFilter, { m_versionId: null }] } }, ...groupFilter, { $match: match } @@ -85,7 +85,7 @@ export function buildPipeline(query: IQueryString, studyId: string, permittedVer ]; } else { return [ - { $match: { m_fieldId: { $regex: /^(?!Device)\w+$/ }, m_versionId: { $in: permittedVersions }, m_studyId: studyId } }, + { $match: { fieldId: { $regex: /^(?!Device)\w+$/ }, dataVersion: { $in: permittedVersions }, studyId: studyId } }, { $match: metadataFilter }, ...groupFilter, { $match: match } @@ -191,7 +191,7 @@ export function translateMetadata(metadata: QueryMatcher[]) { return match; } -export function dataStandardization(study: IStudy, fields: IFieldEntry[], data: IGroupedData, queryString: IQueryString, standardizations: IStandardization[] | null) { +export function dataStandardization(study: IStudy, fields: IField[], data: IGroupedData, queryString: IQueryString, standardizations: IStandardization[] | null) { if (!queryString['format'] || queryString['format'] === 'raw') { return data; } else if (queryString['format'] === 'grouped' || queryString['format'] === 'summary') { @@ -206,9 +206,9 @@ export function dataStandardization(study: IStudy, fields: IFieldEntry[], data: -export function standardize(study: IStudy, fields: IFieldEntry[], data: IGroupedData, standardizations: IStandardization[]) { +export function standardize(study: IStudy, fields: IField[], data: IGroupedData, standardizations: IStandardization[]) { const records = {}; - const mergedFields: IFieldEntry[] = [...fields]; + const mergedFields: IField[] = [...fields]; const seqNumMap: Map = new Map(); for (const field of mergedFields) { let fieldDef = {}; diff --git a/packages/itmat-cores/src/utils/userLoginUtils.ts b/packages/itmat-cores/src/utils/userLoginUtils.ts index 1821ea850..474593aac 100644 --- a/packages/itmat-cores/src/utils/userLoginUtils.ts +++ b/packages/itmat-cores/src/utils/userLoginUtils.ts @@ -22,6 +22,6 @@ export class UserLoginUtils { } private async _getUser(username: string): Promise { - return await this.db.collections.users_collection.findOne({ deleted: null, username }, { projection: { _id: 0, deleted: 0, password: 0 } }); + return await this.db.collections.users_collection.findOne({ 'life.deletedTime': null, username }, { projection: { '_id': 0, 'life.deletedTime': 0, 'password': 0 } }); } } diff --git a/packages/itmat-interface/config/config.sample.json b/packages/itmat-interface/config/config.sample.json index 622ca4650..c08c4f464 100644 --- a/packages/itmat-interface/config/config.sample.json +++ b/packages/itmat-interface/config/config.sample.json @@ -17,7 +17,8 @@ "files_collection": "FILES_COLLECTION", "sessions_collection": "SESSIONS_COLLECTION", "pubkeys_collection": "PUBKEY_COLLECTION", - "standardizations_collection": "STANDARDIZATION_COLLECTION" + "standardizations_collection": "STANDARDIZATION_COLLECTION", + "configs_collection": "CONFIG_COLLECTION" } }, "server": { diff --git a/packages/itmat-interface/express-user.d.ts b/packages/itmat-interface/express-user.d.ts index a1740828a..6e7b3469d 100644 --- a/packages/itmat-interface/express-user.d.ts +++ b/packages/itmat-interface/express-user.d.ts @@ -9,7 +9,9 @@ declare global { interface Request { user?: User; - headers: IncomingHttpHeaders + headers: IncomingHttpHeaders; + login(user: User, done: (err: unknown) => void): void; + logout(done: (err: unknown) => void): void; } } } \ No newline at end of file diff --git a/packages/itmat-interface/src/graphql/resolvers/index.ts b/packages/itmat-interface/src/graphql/resolvers/index.ts index a48fcdacc..64100ce84 100644 --- a/packages/itmat-interface/src/graphql/resolvers/index.ts +++ b/packages/itmat-interface/src/graphql/resolvers/index.ts @@ -28,7 +28,7 @@ const modules = [ const bounceNotLoggedInDecorator = (funcName: string, reducerFunction: DMPResolver): DMPResolver => { return (parent, args, context, info) => { - const uncheckedFunctionWhitelist = ['login', 'rsaSigner', 'keyPairGenwSignature', 'issueAccessToken', 'whoAmI', 'getOrganisations', 'requestUsernameOrResetPassword', 'resetPassword', 'createUser', 'writeLog', 'validateResetPassword']; + const uncheckedFunctionWhitelist = ['login', 'rsaSigner', 'keyPairGenwSignature', 'issueAccessToken', 'getOrganisations', 'requestUsernameOrResetPassword', 'resetPassword', 'createUser', 'validateResetPassword']; const requester = context.req.user; if (!requester && !uncheckedFunctionWhitelist.includes(funcName)) { diff --git a/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts index 2fbe85ea0..9c5ed02d3 100644 --- a/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts @@ -1,4 +1,4 @@ -import { LOG_ACTION, LOG_STATUS, LOG_TYPE, userTypes } from '@itmat-broker/itmat-types'; +import { LOG_ACTION, LOG_STATUS, LOG_TYPE, enumUserTypes } from '@itmat-broker/itmat-types'; import { DMPResolversMap } from './context'; import { db } from '../../database/database'; import { LogCore } from '@itmat-broker/itmat-cores'; @@ -7,7 +7,7 @@ const logCore = Object.freeze(new LogCore(db)); export const logResolvers: DMPResolversMap = { Query: { - getLogs: async (_parent, args: { requesterName: string, requesterType: userTypes, logType: LOG_TYPE, actionType: LOG_ACTION, status: LOG_STATUS }, context) => { + getLogs: async (_parent, args: { requesterName: string, requesterType: enumUserTypes, logType: LOG_TYPE, actionType: LOG_ACTION, status: LOG_STATUS }, context) => { return await logCore.getLogs(context.req.user, args.requesterName, args.requesterType, args.logType, args.actionType, args.status); } } diff --git a/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts index f2369165a..c56da1593 100644 --- a/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts @@ -26,9 +26,6 @@ export const studyResolvers: DMPResolversMap = { getOntologyTree: async (_parent, { studyId, projectId, treeName, versionId }: { studyId: string, projectId?: string, treeName?: string, versionId?: string }, context) => { return await studyCore.getOntologyTree(context.req.user, studyId, projectId, treeName, versionId); }, - checkDataComplete: async (_parent, { studyId }: { studyId: string }, context) => { - return await studyCore.checkDataComplete(context.req.user, studyId); - }, getDataRecords: async (_parent, { studyId, queryString, versionId, projectId }: { queryString, studyId: string, versionId: string | null | undefined, projectId?: string }, context) => { return await studyCore.getDataRecords(context.req.user, queryString, studyId, versionId, projectId); } diff --git a/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts index 3a68de6b3..22613e68d 100644 --- a/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts @@ -10,7 +10,11 @@ const userCore = Object.freeze(new UserCore(db, mailer, config)); export const userResolvers: DMPResolversMap = { Query: { whoAmI: (_parent, _args, context) => { - return context.req.user; + return { + ...context.req.user, + createdAt: context.req.user?.life.createdTime, + deleted: context.req.user?.life.deletedTime + }; }, getUsers: async (_parent, args: { userId?: string }) => { return await userCore.getUsers(args.userId); diff --git a/packages/itmat-interface/src/index.ts b/packages/itmat-interface/src/index.ts index 9bc007b65..fad51c2b2 100644 --- a/packages/itmat-interface/src/index.ts +++ b/packages/itmat-interface/src/index.ts @@ -5,7 +5,7 @@ import http from 'http'; import ITMATInterfaceRunner from './interfaceRunner'; import config from './utils/configManager'; import { db } from './database/database'; -import { IUser, userTypes } from '@itmat-broker/itmat-types'; +import { IUser, enumUserTypes } from '@itmat-broker/itmat-types'; import { mailer } from './emailer/emailer'; let interfaceRunner = new ITMATInterfaceRunner(config); @@ -90,19 +90,15 @@ if (module.hot) { async function emailNotification() { const now = Date.now().valueOf(); const threshold = now + 7 * 24 * 60 * 60 * 1000; - // update info if not set before - await db.collections.users_collection.updateMany({ deleted: null, emailNotificationsStatus: null }, { - $set: { emailNotificationsStatus: { expiringNotification: false } } - }); const users = await db.collections.users_collection.find({ 'expiredAt': { $lte: threshold, $gt: now }, - 'type': { $ne: userTypes.ADMIN }, + 'type': { $ne: enumUserTypes.ADMIN }, 'emailNotificationsActivated': true, 'emailNotificationsStatus.expiringNotification': false, - 'deleted': null + 'life.deletedTime': null }).toArray(); for (const user of users) { await mailer.sendMail({ diff --git a/packages/itmat-interface/src/server/commonMiddleware.ts b/packages/itmat-interface/src/server/commonMiddleware.ts new file mode 100644 index 000000000..518226076 --- /dev/null +++ b/packages/itmat-interface/src/server/commonMiddleware.ts @@ -0,0 +1,31 @@ +import jwt from 'jsonwebtoken'; +import { GraphQLError } from 'graphql'; +import { ApolloServerErrorCode } from '@apollo/server/errors'; +import { userRetrieval } from '@itmat-broker/itmat-cores'; +import { db } from '../database/database'; + +export const tokenAuthentication = async (token: string) => { + if (token !== '') { + // get the decoded payload ignoring signature, no symmetric secret or asymmetric key needed + const decodedPayload = jwt.decode(token); + // obtain the public-key of the robot user in the JWT payload + let pubkey; + if (decodedPayload !== null && typeof decodedPayload === 'object') { + pubkey = decodedPayload['publicKey']; + } else { + throw new GraphQLError('JWT verification failed. ', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + } + + // verify the JWT + jwt.verify(token, pubkey, function (error) { + if (error) { + throw new GraphQLError('JWT verification failed. ' + error, { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); + } + }); + // store the associated user with the JWT to context + const associatedUser = await userRetrieval(db, pubkey); + return associatedUser; + } else { + return null; + } +}; \ No newline at end of file diff --git a/packages/itmat-interface/src/server/router.ts b/packages/itmat-interface/src/server/router.ts index aa95830e1..ddfff3a41 100644 --- a/packages/itmat-interface/src/server/router.ts +++ b/packages/itmat-interface/src/server/router.ts @@ -1,8 +1,6 @@ import { ApolloServer } from '@apollo/server'; -import { ApolloServerErrorCode } from '@apollo/server/errors'; import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; -import { GraphQLError } from 'graphql'; import { graphqlUploadExpress, GraphQLUpload } from 'graphql-upload-minimal'; import { execute, subscribe } from 'graphql'; import { WebSocketServer } from 'ws'; @@ -21,15 +19,18 @@ import { resolvers } from '../graphql/resolvers'; import { typeDefs } from '../graphql/typeDefs'; import { fileDownloadControllerInstance } from '../rest/fileDownload'; import { BigIntResolver as scalarResolvers } from 'graphql-scalars'; -import jwt from 'jsonwebtoken'; import { createProxyMiddleware, RequestHandler } from 'http-proxy-middleware'; import qs from 'qs'; import { IUser } from '@itmat-broker/itmat-types'; import { ApolloServerContext } from '../graphql/ApolloServerContext'; import { DMPContext } from '../graphql/resolvers/context'; import { logPluginInstance } from '../log/logPlugin'; -import { IConfiguration, spaceFixing, userRetrieval } from '@itmat-broker/itmat-cores'; +import { IConfiguration, spaceFixing } from '@itmat-broker/itmat-cores'; import { userLoginUtils } from '../utils/userLoginUtils'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import { tokenAuthentication } from './commonMiddleware'; +import { tRPCRouter } from '../trpc/tRPCRouter'; +import { createtRPCContext } from '../trpc/trpc'; export class Router { private readonly app: Express; @@ -189,7 +190,7 @@ export class Router { const data = 'username:token'; preq.setHeader('authorization', `Basic ${Buffer.from(data).toString('base64')}`); }, - error: function (err, req, res, target) { + error: function (err, _req, _res, target) { console.error(err, target); } } @@ -216,25 +217,8 @@ export class Router { expressMiddleware(gqlServer, { context: async ({ req, res }): Promise => { const token: string = req.headers.authorization || ''; - if ((token !== '') && (req.user === undefined)) { - // get the decoded payload ignoring signature, no symmetric secret or asymmetric key needed - const decodedPayload = jwt.decode(token); - // obtain the public-key of the robot user in the JWT payload - let pubkey; - if (decodedPayload !== null && typeof decodedPayload === 'object') { - pubkey = decodedPayload['publicKey']; - } else { - throw new GraphQLError('JWT verification failed. ', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); - } - - // verify the JWT - jwt.verify(token, pubkey, function (error) { - if (error) { - throw new GraphQLError('JWT verification failed. ' + error, { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); - } - }); - // store the associated user with the JWT to context - const associatedUser = await userRetrieval(db, pubkey); + const associatedUser = await tokenAuthentication(token); + if (associatedUser) { req.user = associatedUser; } return ({ req, res }); @@ -265,6 +249,26 @@ export class Router { // next(); // }); + // trpc + this.app.use('/trpc', (req, _res, next) => { + (async () => { + try { + // in further merge the token authentication of graphql, trpc and file together + const token: string = req.headers.authorization || ''; + const associatedUser = await tokenAuthentication(token); + if (associatedUser) { + req.user = associatedUser; + } + next(); + } catch (error) { + next(error); + } + })().catch(next); + }, trpcExpress.createExpressMiddleware({ + router: tRPCRouter, + createContext: createtRPCContext + })); + this.app.get('/file/:fileId', fileDownloadControllerInstance.fileDownloadController); } diff --git a/packages/itmat-interface/src/trpc/middleware.ts b/packages/itmat-interface/src/trpc/middleware.ts new file mode 100644 index 000000000..03770b0c9 --- /dev/null +++ b/packages/itmat-interface/src/trpc/middleware.ts @@ -0,0 +1 @@ +// This file is a placeholder for further middleware used in the tRPC server. \ No newline at end of file diff --git a/packages/itmat-interface/src/trpc/tRPCRouter.ts b/packages/itmat-interface/src/trpc/tRPCRouter.ts new file mode 100644 index 000000000..02d4f7002 --- /dev/null +++ b/packages/itmat-interface/src/trpc/tRPCRouter.ts @@ -0,0 +1,8 @@ +import { router } from './trpc'; +import { userRouter } from './userProcedure'; + +export const tRPCRouter = router({ + user: userRouter +}); + +export type APPTRPCRouter = typeof tRPCRouter; \ No newline at end of file diff --git a/packages/itmat-interface/src/trpc/trpc.ts b/packages/itmat-interface/src/trpc/trpc.ts new file mode 100644 index 000000000..5d579cc4a --- /dev/null +++ b/packages/itmat-interface/src/trpc/trpc.ts @@ -0,0 +1,21 @@ +import { inferAsyncReturnType, initTRPC } from '@trpc/server'; +import type { CreateNextContextOptions } from '@trpc/server/adapters/next'; + +export const createtRPCContext = async (opts: CreateNextContextOptions) => { + return { + user: opts.req.user, + req: opts.req, + res: opts.res + }; +}; + +type Context = inferAsyncReturnType; +const t = initTRPC.context().create(); + +export const router = t.router; +export const publicProcedure = t.procedure; + +export const baseProcedure = t.procedure.use(async (opts) => { + // Move on to the next middleware or procedure + return opts.next(opts); +}); \ No newline at end of file diff --git a/packages/itmat-interface/src/trpc/userProcedure.ts b/packages/itmat-interface/src/trpc/userProcedure.ts new file mode 100644 index 000000000..814116947 --- /dev/null +++ b/packages/itmat-interface/src/trpc/userProcedure.ts @@ -0,0 +1,291 @@ +import { TRPCUserCore } from '@itmat-broker/itmat-cores'; +import { baseProcedure, router } from './trpc'; +import { db } from '../database/database'; +import { mailer } from '../emailer/emailer'; +import config from '../utils/configManager'; +import { objStore } from '../objStore/objStore'; +import { z } from 'zod'; +import { FileUploadSchema, enumUserTypes } from '@itmat-broker/itmat-types'; + +const userCore = new TRPCUserCore(db, mailer, config, objStore); + +export const userRouter = router({ + /** + * Return the object of IUser of the current requester. + * + * @return Record - The object of IUser. + */ + whoAmI: baseProcedure.query(async (opts) => { + return opts.ctx.req.user ?? null; + }), + /** + * Get a user. + * + * @param userId - The id of the user. If null, return all users. + * @return IUserWithoutToken + */ + getUser: baseProcedure.input(z.object({ + userId: z.optional(z.string()), + username: z.optional(z.string()), + email: z.optional(z.string()) + })).query(async (opts) => { + return await userCore.getUser(opts.ctx.user, opts.input.userId, opts.input.username, opts.input.email); + }), + /** + * Get all users. + * + * @param includedDeleted - If true, return all users including deleted ones. + * @return IUserWithoutToken + */ + getUsers: baseProcedure.input(z.object({ + includedDeleted: z.optional(z.boolean()) + })).query(async (opts) => { + return await userCore.getUsers(opts.ctx.user, opts.input.includedDeleted); + }), + /** + * Refresh the existing session to avoid timeout. Express will update the session as long as there is a new query in. + * + * @return IGenericResponse - The obejct of IGenericResponse. + */ + recoverSessionExpireTime: baseProcedure.query(() => { + return; + }), + /** + * Validate the token from the reset password request. + * + * @param encryptedEmail - The encrypted email. + * @param token - The token for resetting password. + * @returns IGenericResponse + */ + validateResetPassword: baseProcedure.input(z.object({ + encryptedEmail: z.string(), + token: z.string() + })).query(async (opts) => { + return await userCore.validateResetPassword(opts.input.encryptedEmail, opts.input.token); + }), + /** + * Ask for a request to extend account expiration time. Send notifications to user and admin. + * + * @param userId - The id of the user. + * + * @return IGenericResponse - The object of IGenericResponse + */ + requestExpiryDate: baseProcedure.input(z.object({ + userId: z.string() + })).mutation(async (opts) => { + return await userCore.requestExpiryDate(opts.ctx.user, opts.input.userId); + }), + /** + * Request for resetting password. + * + * @param forgotUsername - Whether user forget the username. + * @param forgotPassword - Whether user forgot the password. + * @param email - The email of the user. If using email to reset password. + * @param username - The username of the uer. If using username to reset password. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + requestUsernameOrResetPassword: baseProcedure.input(z.object({ + forgotUsername: z.boolean(), + forgotPassword: z.boolean(), + email: z.optional(z.string()), + username: z.optional(z.string()) + })).mutation(async (opts) => { + return await userCore.requestUsernameOrResetPassword(opts.input.forgotUsername, opts.input.forgotPassword, opts.ctx.req.headers.origin, opts.input.email, opts.input.username); + }), + /** + * Log in to the system. + * + * @param username - The username of the user. + * @param password - The password of the user. + * @param totp - The totp of the user. + * @param requestexpirydate - Whether to request for extend the expiration time of the user. + * + * @return Partial - The object of Partial + */ + login: baseProcedure.input(z.object({ + username: z.string(), + password: z.string(), + totp: z.string(), + requestexpirydate: z.optional(z.boolean()) + })).mutation(async (opts) => { + return await userCore.login(opts.ctx.req, opts.input.username, opts.input.password, opts.input.totp, opts.input.requestexpirydate); + }), + /** + * Logout an account. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + logout: baseProcedure.mutation(async (opts) => { + return await userCore.logout(opts.ctx.user, opts.ctx.req); + }), + /** + * Create a user. + * + * @param username - The username of the user. + * @param firstname - The firstname of the user. + * @param lastname - The lastname of the user. + * @param email - The email of the user. + * @param password - The password of the user. + * @param description - The description of the user. + * @param organisation - The organisation of the user. + * @param profile - The profile of the user. + * + * @return IUser + */ + createUser: baseProcedure.input(z.object({ + username: z.string(), + firstname: z.string(), + lastname: z.string(), + email: z.string(), + password: z.string(), + description: z.optional(z.string()), + organisation: z.string(), + profile: z.optional(FileUploadSchema) + })).mutation(async (opts) => { + return await userCore.createUser( + opts.ctx.user, + opts.input.username, + opts.input.email, + opts.input.firstname, + opts.input.lastname, + opts.input.organisation, + enumUserTypes.STANDARD, + false, + opts.input.password, + opts.input.profile + ); + }), + /** + * Delete a user. + * + * @param userId - The id of the user. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + deleteUser: baseProcedure.input(z.object({ + userId: z.string() + })).mutation(async (opts) => { + return await userCore.deleteUser(opts.ctx.user, opts.input.userId); + }), + /** + * Reset the password of an account. + * + * @param encryptedEmail - The encrypted email of the user. + * @param token - The id of the reset password request of the user. + * @param newPassword - The new password. + * + * @return IGenericResponse - The object of IGenericResponse. + */ + resetPassword: baseProcedure.input(z.object({ + encryptedEmail: z.string(), + token: z.string(), + newPassword: z.string() + })).mutation(async (opts) => { + return await userCore.resetPassword(opts.input.encryptedEmail, opts.input.token, opts.input.newPassword); + }), + /** + * Edit a user. Besides description, other fields whose values is null will not be updated. + * + * @param userId - The id of the user. + * @param username - The username of the user. + * @param type - The type of the user. + * @param firstname - The first name of the user. + * @param lastname - The last name of the user. + * @param email - The email of the user. + * @param emailNotificationsActivated - Whether the email notification is activated. + * @param password - The password of the user. + * @param description - The description of the user. + * @param organisaiton - The organisation of the user. + * @param expiredAt - The expiration time of the user. + * @param profile - The profile of the user. + * + * @return Partial - The object of IUser. + */ + editUser: baseProcedure.input(z.object({ + userId: z.string(), + username: z.optional(z.string()), + type: z.optional(z.nativeEnum(enumUserTypes)), + firstname: z.optional(z.string()), + lastname: z.optional(z.string()), + email: z.optional(z.string()), + password: z.optional(z.string()), + description: z.optional(z.string()), + organisation: z.optional(z.string()), + emailNotificationsActivated: z.optional(z.boolean()), + otpSecret: z.optional(z.string()), + expiredAt: z.optional(z.number()), + profile: z.optional(FileUploadSchema) + })).mutation(async (opts) => { + return await userCore.editUser( + opts.ctx.user, + opts.input.userId, + opts.input.username, + opts.input.email, + opts.input.firstname, + opts.input.lastname, + opts.input.organisation, + opts.input.type, + opts.input.emailNotificationsActivated, + opts.input.password, + opts.input.otpSecret, + opts.input.profile, + opts.input.description, + opts.input.expiredAt + + ); + }), + /** + * Get keys of a user. + * + * @param userId - The id of the user. + * @return IPubkeys[] + */ + getUserKeys: baseProcedure.input(z.object({ + userId: z.string() + })).query(async (opts) => { + return await userCore.getUserKeys(opts.ctx.user, opts.input.userId); + }), + /** + * Register a public key. + * + * @param pubkey - The public key. + * @param signature - The signature of the public key. + * @param associatedUserId - The id of the user. + * @return IPubkey + */ + registerPubkey: baseProcedure.input(z.object({ + pubkey: z.string(), + signature: z.string(), + associatedUserId: z.string() + })).mutation(async (opts) => { + return await userCore.registerPubkey(opts.ctx.user, opts.input.pubkey, opts.input.signature, opts.input.associatedUserId); + }), + /** + * Issue an access token. + * + * @param pubkey - The public key. + * @param signature - The signature of the public key. + * @param life - The life of the token. + * @return IAccessToken + */ + issueAccessToken: baseProcedure.input(z.object({ + pubkey: z.string(), + signature: z.string(), + life: z.optional(z.number()) + })).mutation(async (opts) => { + return await userCore.issueAccessToken(opts.input.pubkey, opts.input.signature, opts.input.life); + }), + /** + * Delete a public key. + * + * @param keyId - The id of the public key. + * @param associatedUserId - The id of the user. + */ + deletePubkey: baseProcedure.input(z.object({ + keyId: z.string(), + associatedUserId: z.string() + })).mutation(async (opts) => { + return await userCore.deletePubkey(opts.ctx.user, opts.input.keyId, opts.input.associatedUserId); + }) +}); \ No newline at end of file diff --git a/packages/itmat-interface/test/serverTests/_loginHelper.ts b/packages/itmat-interface/test/GraphQLTests/_loginHelper.ts similarity index 100% rename from packages/itmat-interface/test/serverTests/_loginHelper.ts rename to packages/itmat-interface/test/GraphQLTests/_loginHelper.ts diff --git a/packages/itmat-interface/test/serverTests/file.test.ts b/packages/itmat-interface/test/GraphQLTests/file.test.ts similarity index 95% rename from packages/itmat-interface/test/serverTests/file.test.ts rename to packages/itmat-interface/test/GraphQLTests/file.test.ts index 9093d53c7..6b5cabbe2 100644 --- a/packages/itmat-interface/test/serverTests/file.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/file.test.ts @@ -14,7 +14,7 @@ import path from 'path'; import { v4 as uuid } from 'uuid'; import { errorCodes } from '@itmat-broker/itmat-cores'; import { Db, MongoClient } from 'mongodb'; -import { studyType, IStudy, IUser, IRole, IFile, IFieldEntry, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; +import { studyType, IStudy, IUser, IRole, IFile, IField, atomicOperation, IPermissionManagementOptions } from '@itmat-broker/itmat-types'; import { UPLOAD_FILE, CREATE_STUDY, DELETE_FILE } from '@itmat-broker/itmat-models'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { setupDatabase } from '@itmat-broker/itmat-setup'; @@ -79,7 +79,7 @@ if (global.hasMinio) { let createdStudyClinical; let createdStudyAny: { id: any; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile: { id: any; otpSecret: any; username?: string; type?: string; firstname?: string; lastname?: string; password?: string; email?: string; description?: string; emailNotificationsActivated?: boolean; organisation?: string; deleted?: null; }; + let authorisedUserProfile: IUser; beforeEach(async () => { /* setup: create a study to upload file to */ const studyname = uuid(); @@ -133,7 +133,7 @@ if (global.hasMinio) { }); // create field for both studies - await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ + await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ fieldId: 'Device_McRoberts', fieldName: 'Device_McRoberts', dataType: 'file', @@ -162,7 +162,7 @@ if (global.hasMinio) { metadata: { } }]); - await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ + await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ fieldId: 'Device_McRoberts', fieldName: 'Device_McRoberts', dataType: 'file', @@ -204,8 +204,14 @@ if (global.hasMinio) { description: 'I am a new user.', emailNotificationsActivated: true, organisation: 'organisation_system', - deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -243,7 +249,7 @@ if (global.hasMinio) { afterEach(async () => { await mongoClient.collection(config.database.collections.studies_collection).deleteMany({}); - await mongoClient.collection(config.database.collections.field_dictionary_collection).deleteMany({}); + await mongoClient.collection(config.database.collections.field_dictionary_collection).deleteMany({}); await mongoClient.collection(config.database.collections.files_collection).deleteMany({}); }); @@ -471,7 +477,6 @@ if (global.hasMinio) { expect(res.body.errors[0].message).toBe('File size mismatch'); expect(res.body.data.uploadFile).toEqual(null); }); - }); describe('DOWNLOAD FILES', () => { @@ -479,7 +484,7 @@ if (global.hasMinio) { let createdStudy; let createdFile: { id: any; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile; + let authorisedUserProfile: IUser; beforeEach(async () => { /* setup: create studies to upload file to */ @@ -499,7 +504,7 @@ if (global.hasMinio) { description: 'test description', type: studyType.SENSOR }); - await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ + await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ fieldId: 'Device_McRoberts', fieldName: 'Device_McRoberts', dataType: 'file', @@ -561,8 +566,14 @@ if (global.hasMinio) { description: 'I am a new user.', emailNotificationsActivated: true, organisation: 'organisation_system', - deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -600,7 +611,7 @@ if (global.hasMinio) { afterEach(async () => { await mongoClient.collection(config.database.collections.studies_collection).deleteMany({}); - await mongoClient.collection(config.database.collections.field_dictionary_collection).deleteMany({}); + await mongoClient.collection(config.database.collections.field_dictionary_collection).deleteMany({}); await mongoClient.collection(config.database.collections.files_collection).deleteMany({}); }); @@ -663,7 +674,7 @@ if (global.hasMinio) { let createdStudy; let createdFile: { id: any; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile; + let authorisedUserProfile: IUser; beforeEach(async () => { /* Clear old values */ await db.collections.roles_collection.deleteMany({}); @@ -684,7 +695,7 @@ if (global.hasMinio) { description: 'test description', type: studyType.SENSOR }); - await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ + await mongoClient.collection(config.database.collections.field_dictionary_collection).insertMany([{ fieldId: 'Device_McRoberts', fieldName: 'Device_McRoberts', dataType: 'file', @@ -751,8 +762,14 @@ if (global.hasMinio) { description: 'I am a new user.', emailNotificationsActivated: true, organisation: 'organisation_system', - deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -790,7 +807,7 @@ if (global.hasMinio) { afterEach(async () => { await mongoClient.collection(config.database.collections.studies_collection).deleteMany({}); - await mongoClient.collection(config.database.collections.field_dictionary_collection).deleteMany({}); + await mongoClient.collection(config.database.collections.field_dictionary_collection).deleteMany({}); await mongoClient.collection(config.database.collections.files_collection).deleteMany({}); }); diff --git a/packages/itmat-interface/test/serverTests/job.test.ts b/packages/itmat-interface/test/GraphQLTests/job.test.ts similarity index 97% rename from packages/itmat-interface/test/serverTests/job.test.ts rename to packages/itmat-interface/test/GraphQLTests/job.test.ts index 116928ed6..3c609feb3 100644 --- a/packages/itmat-interface/test/serverTests/job.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/job.test.ts @@ -170,7 +170,7 @@ describe('JOB API', () => { test('Create a query curation job (user with privilege)', async () => { /* setup: creating a privileged user */ const username = uuid(); - const authorisedUserProfile = { + const authorisedUserProfile: IUser = { username, type: 'STANDARD', firstname: `${username}_firstname`, @@ -181,8 +181,13 @@ describe('JOB API', () => { description: 'I am a new user.', emailNotificationsActivated: true, organisation: 'organisation_system', - deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 200000000, + createdUserId: 'admin', + deletedTime: null, + deletedUserId: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); diff --git a/packages/itmat-interface/test/serverTests/log.test.ts b/packages/itmat-interface/test/GraphQLTests/log.test.ts similarity index 91% rename from packages/itmat-interface/test/serverTests/log.test.ts rename to packages/itmat-interface/test/GraphQLTests/log.test.ts index b3c1e20a5..9d851880b 100644 --- a/packages/itmat-interface/test/serverTests/log.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/log.test.ts @@ -13,7 +13,7 @@ import { setupDatabase } from '@itmat-broker/itmat-setup'; import config from '../../config/config.sample.json'; import { errorCodes, generateTOTP } from '@itmat-broker/itmat-cores'; import { GET_LOGS, LOGIN, DELETE_USER } from '@itmat-broker/itmat-models'; -import { userTypes, IUser, ILogEntry, LOG_STATUS, LOG_ACTION, LOG_TYPE, USER_AGENT } from '@itmat-broker/itmat-types'; +import { enumUserTypes, IUser, ILogEntry, LOG_STATUS, LOG_ACTION, LOG_TYPE, USER_AGENT } from '@itmat-broker/itmat-types'; import { Express } from 'express'; let app: Express; @@ -72,16 +72,20 @@ describe('LOG API', () => { firstname: 'test', lastname: 'user', organisation: 'organisation_system', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, description: 'I am an test user.', emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, - deleted: null, password: '$2b$04$ps9ownz6PqJFD/LExsmgR.ZLk11zhtRdcpUwypWVfWJ4ZW6/Zzok2', otpSecret: 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA', resetPasswordRequests: [], - createdAt: 1591134065000, expiredAt: 2501134065000, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -102,7 +106,7 @@ describe('LOG API', () => { if (!lastLog) return; expect(lastLog.requesterName).toEqual('test_user'); - expect(lastLog.requesterType).toEqual(userTypes.ADMIN); + expect(lastLog.requesterType).toEqual(enumUserTypes.ADMIN); expect(lastLog.logType).toEqual(LOG_TYPE.REQUEST_LOG); expect(lastLog.actionType).toEqual(LOG_ACTION.login); expect(JSON.parse(lastLog.actionData)).toEqual({ @@ -127,8 +131,8 @@ describe('LOG API', () => { // write initial data for testing const logSample = [{ id: '001', - requesterName: userTypes.SYSTEM, - requesterType: userTypes.SYSTEM, + requesterName: enumUserTypes.SYSTEM, + requesterType: enumUserTypes.SYSTEM, logType: LOG_TYPE.SYSTEM_LOG, userAgent: USER_AGENT.MOZILLA, actionType: LOG_ACTION.startSERVER, diff --git a/packages/itmat-interface/test/serverTests/permission.test.ts b/packages/itmat-interface/test/GraphQLTests/permission.test.ts similarity index 97% rename from packages/itmat-interface/test/serverTests/permission.test.ts rename to packages/itmat-interface/test/GraphQLTests/permission.test.ts index 9ee8ac180..b42587fb7 100644 --- a/packages/itmat-interface/test/serverTests/permission.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/permission.test.ts @@ -68,7 +68,7 @@ describe('ROLE API', () => { let setupStudy: { id: any; name?: string; createdBy?: any; lastModified?: number; deleted?: null; currentDataVersion?: number; dataVersions?: never[]; }; let setupProject: { id: any; studyId?: string; createdBy?: any; patientMapping?: Record; name?: string; lastModified?: number; deleted?: null; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile: { otpSecret: any; id: any; username?: string; type?: string; firstname?: string; lastname?: string; password?: string; email?: string; description?: string; emailNotificationsActivated?: boolean; organisation?: string; deleted?: null; }; + let authorisedUserProfile: IUser; beforeEach(async () => { const studyName = uuid(); setupStudy = { @@ -107,8 +107,14 @@ describe('ROLE API', () => { description: 'I am a new user.', emailNotificationsActivated: true, organisation: 'organisation_system', - deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -672,7 +678,7 @@ describe('ROLE API', () => { let setupStudy: { id: any; name?: string; createdBy?: any; lastModified?: number; deleted?: null; currentDataVersion?: number; dataVersions?: never[]; }; let setupRole: { id: any; _id?: any; name: any; studyId: any; projectId?: null; permissions?: never[]; createdBy?: any; users?: never[]; deleted?: null; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile; + let authorisedUserProfile: IUser; beforeEach(async () => { const studyName = uuid(); setupStudy = { @@ -699,8 +705,14 @@ describe('ROLE API', () => { description: 'I am a new user.', emailNotificationsActivated: true, organisation: 'organisation_system', - deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -1169,7 +1181,7 @@ describe('ROLE API', () => { test('Add user to role (admin)', async () => { /* setup: create a user to be added to role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -1181,7 +1193,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1279,7 +1298,7 @@ describe('ROLE API', () => { test('Add user to role (privileged user)', async () => { /* setup: create a user to be added to role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -1291,7 +1310,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1389,7 +1415,7 @@ describe('ROLE API', () => { test('Add user to role (user without privilege) (should fail)', async () => { /* setup: create a user to be added to role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -1401,7 +1427,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1471,7 +1504,7 @@ describe('ROLE API', () => { test('Remove user from role (admin)', async () => { /* setup: create a user to be removed from role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -1483,7 +1516,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const updatedRole = await mongoClient.collection(config.database.collections.roles_collection).findOneAndUpdate({ @@ -2210,7 +2250,7 @@ describe('ROLE API', () => { test('Combination of edits (admin)', async () => { /* setup: create a user to be removed from role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -2222,7 +2262,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -2377,7 +2424,7 @@ describe('ROLE API', () => { let setupProject: { id: any; studyId?: string; createdBy?: any; patientMapping?: Record; name?: string; lastModified?: number; deleted?: null; }; let setupRole: { id: any; _id?: any; name: any; projectId?: string; studyId?: string; permissions?: never[]; createdBy?: any; users?: never[]; deleted?: null; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile; + let authorisedUserProfile: IUser; beforeEach(async () => { /* setup: creating a setup study */ const studyName = uuid(); @@ -2419,7 +2466,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -2782,7 +2836,7 @@ describe('ROLE API', () => { test('Add user to role (privileged user)', async () => { /* setup: create a user to be added to role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -2794,7 +2848,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -2892,7 +2953,7 @@ describe('ROLE API', () => { test('Add user to role (user without privilege) (should fail)', async () => { /* setup: create a user to be added to role */ const newUsername = uuid(); - const newUser = { + const newUser: IUser = { username: newUsername, type: 'STANDARD', firstname: `${newUsername}_firstname`, @@ -2904,7 +2965,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${newUsername}` + id: `new_user_id_${newUsername}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -2961,7 +3029,7 @@ describe('ROLE API', () => { let setupStudy; let setupRole: { id: any; projectId?: null; studyId?: string; name?: string; permissions?: never[]; createdBy?: any; users?: never[]; deleted?: null; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile; + let authorisedUserProfile: IUser; beforeEach(async () => { const studyName = uuid(); setupStudy = { @@ -2989,7 +3057,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); @@ -3118,7 +3193,7 @@ describe('ROLE API', () => { let setupProject; let setupRole: { id: any; projectId?: string; studyId?: string; name?: string; permissions?: never[]; createdBy?: any; users?: never[]; deleted?: null; }; let authorisedUser: request.SuperTest; - let authorisedUserProfile; + let authorisedUserProfile: IUser; beforeEach(async () => { /* setup: creating a setup study */ const studyName = uuid(); @@ -3160,7 +3235,14 @@ describe('ROLE API', () => { emailNotificationsActivated: true, organisation: 'organisation_system', deleted: null, - id: `new_user_id_${username}` + id: `new_user_id_${username}`, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); diff --git a/packages/itmat-interface/test/serverTests/standardization.test.ts b/packages/itmat-interface/test/GraphQLTests/standardization.test.ts similarity index 97% rename from packages/itmat-interface/test/serverTests/standardization.test.ts rename to packages/itmat-interface/test/GraphQLTests/standardization.test.ts index ea3aa8254..4b8659d95 100644 --- a/packages/itmat-interface/test/serverTests/standardization.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/standardization.test.ts @@ -30,7 +30,7 @@ import { DELETE_STANDARDIZATION, CREATE_ONTOLOGY_TREE } from '@itmat-broker/itmat-models'; -import { userTypes, IUser, studyType, IStudy, IProject, IRole, IPermissionManagementOptions, atomicOperation } from '@itmat-broker/itmat-types'; +import { enumUserTypes, IUser, studyType, IStudy, IProject, IRole, IPermissionManagementOptions, atomicOperation } from '@itmat-broker/itmat-types'; import { Express } from 'express'; @@ -354,7 +354,7 @@ describe('STUDY API', () => { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -365,10 +365,15 @@ describe('STUDY API', () => { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedProjectUser_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); createdUserAuthorised = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); @@ -448,7 +453,7 @@ describe('STUDY API', () => { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorised.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorised.username}_firstname`, lastname: `${createdUserAuthorised.username}_lastname`, organisation: 'organisation_system', @@ -469,7 +474,7 @@ describe('STUDY API', () => { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -480,10 +485,15 @@ describe('STUDY API', () => { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedStudyUser_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); createdUserAuthorisedStudy = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); @@ -563,7 +573,7 @@ describe('STUDY API', () => { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorisedStudy.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorisedStudy.username}_firstname`, lastname: `${createdUserAuthorisedStudy.username}_lastname`, organisation: 'organisation_system', @@ -587,7 +597,7 @@ describe('STUDY API', () => { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -598,10 +608,15 @@ describe('STUDY API', () => { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedStudyUserManageProject_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: adminId, + deletedTime: null, + deletedUserId: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -682,7 +697,7 @@ describe('STUDY API', () => { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorisedStudyManageProjects.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorisedStudyManageProjects.username}_firstname`, lastname: `${createdUserAuthorisedStudyManageProjects.username}_lastname`, organisation: 'organisation_system', @@ -705,7 +720,7 @@ describe('STUDY API', () => { const res = await admin.post('/graphql').send({ query: print(WHO_AM_I) }); expect(res.body.data.whoAmI).toStrictEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -721,15 +736,14 @@ describe('STUDY API', () => { }], studies: [{ id: createdStudy.id, - name: createdStudy.name, - type: studyType.SENSOR + name: createdStudy.name }] }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); } /* connecting users */ @@ -790,7 +804,7 @@ describe('STUDY API', () => { const res = await admin.post('/graphql').send({ query: print(WHO_AM_I) }); expect(res.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -806,13 +820,13 @@ describe('STUDY API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); // study data is NOT deleted for audit purposes - unless explicitly requested separately const roles = await db.collections.roles_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); const projects = await db.collections.projects_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); - const study = await db.collections.studies_collection.findOne({ id: createdStudy.id, deleted: null }); + const study = await db.collections.studies_collection.findOne({ 'id': createdStudy.id, 'life.deletedTime': null }); expect(roles).toEqual([]); expect(projects).toEqual([]); expect(study).toBe(null); @@ -1198,7 +1212,8 @@ describe('STUDY API', () => { ] } }); - await authorisedUserStudy.post('/graphql').send({ + console.log(await db.collections.field_dictionary_collection.find({}).toArray()); + const t = await authorisedUserStudy.post('/graphql').send({ query: print(UPLOAD_DATA_IN_ARRAY), variables: { studyId: createdStudy.id, @@ -1219,6 +1234,7 @@ describe('STUDY API', () => { ] } }); + console.log(JSON.stringify(t.body.data.uploadDataInArray)); await admin.post('/graphql').send({ query: print(CREATE_ONTOLOGY_TREE), variables: { @@ -1439,6 +1455,7 @@ describe('STUDY API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); + console.log(JSON.stringify(res.body.data.getDataRecords)); expect(res.body.data.getDataRecords.data.QS.sort((a: { entry_reserved: string; entry_inc: number; }, b: { entry_reserved: any; entry_inc: number; }) => { return a.entry_reserved !== b.entry_reserved ? a.entry_reserved.localeCompare(b.entry_reserved) : a.entry_inc - b.entry_inc; diff --git a/packages/itmat-interface/test/serverTests/study.test.ts b/packages/itmat-interface/test/GraphQLTests/study.test.ts similarity index 85% rename from packages/itmat-interface/test/serverTests/study.test.ts rename to packages/itmat-interface/test/GraphQLTests/study.test.ts index c761c003c..be06c4bca 100644 --- a/packages/itmat-interface/test/serverTests/study.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/study.test.ts @@ -16,7 +16,6 @@ import config from '../../config/config.sample.json'; import { v4 as uuid } from 'uuid'; import { GET_STUDY_FIELDS, - GET_PROJECT_PATIENT_MAPPING, GET_STUDY, GET_PROJECT, GET_USERS, @@ -33,7 +32,6 @@ import { DELETE_DATA_RECORDS, GET_DATA_RECORDS, CREATE_NEW_DATA_VERSION, - CHECK_DATA_COMPLETE, CREATE_NEW_FIELD, DELETE_FIELD, CREATE_ONTOLOGY_TREE, @@ -41,19 +39,23 @@ import { GET_ONTOLOGY_TREE } from '@itmat-broker/itmat-models'; import { - userTypes, + enumUserTypes, studyType, - enumValueType, + enumDataTypes, IDataEntry, IUser, IFile, - IFieldEntry, + IField, IStudyDataVersion, IStudy, IProject, IRole, IPermissionManagementOptions, - atomicOperation + atomicOperation, + enumReservedUsers, + IData, + enumFileTypes, + enumFileCategories } from '@itmat-broker/itmat-types'; import { Express } from 'express'; import path from 'path'; @@ -139,7 +141,7 @@ if (global.hasMinio) { expect(resWhoAmI.body.data.errors).toBeUndefined(); expect(resWhoAmI.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -151,19 +153,18 @@ if (global.hasMinio) { projects: [], studies: [{ id: createdStudy.id, - name: studyName, - type: studyType.SENSOR + name: studyName }] }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); /* cleanup: delete study */ - await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ name: studyName, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); }); test('Edit study (admin)', async () => { @@ -190,24 +191,27 @@ if (global.hasMinio) { expect(editStudy.body.data.editStudy).toEqual({ id: createdStudy.id, name: studyName, - description: 'edited description', - type: studyType.SENSOR + description: 'edited description' }); /* cleanup: delete study */ - await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ name: studyName, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); }); test('Create study that violate unique name constraint (admin)', async () => { const studyName = uuid(); - const newStudy = { + const newStudy: IStudy = { id: `id_${studyName}`, name: studyName, - createdBy: 'admin', - lastModified: 200000002, - deleted: null, currentDataVersion: -1, - dataVersions: [] + dataVersions: [], + life: { + createdTime: 200000002, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.studies_collection).insertOne(newStudy); @@ -225,19 +229,23 @@ if (global.hasMinio) { expect(study).toEqual([newStudy]); /* cleanup: delete study */ - await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ name: studyName, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); }); test('Create study that violate unique name constraint (case insensitive) (admin)', async () => { const studyName = uuid(); - const newStudy = { + const newStudy: IStudy = { id: `id_${studyName}`, name: studyName, - createdBy: 'admin', - lastModified: 200000002, - deleted: null, currentDataVersion: -1, - dataVersions: [] + dataVersions: [], + life: { + createdTime: 200000002, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.studies_collection).insertOne(newStudy); @@ -255,7 +263,7 @@ if (global.hasMinio) { expect(study).toEqual([newStudy]); /* cleanup: delete study */ - await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ name: studyName, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); }); test('Create study (user) (should fail)', async () => { @@ -300,21 +308,24 @@ if (global.hasMinio) { expect(editStudy.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); /* cleanup: delete study */ - await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ name: studyName, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); }); test('Delete study (no projects) (admin)', async () => { /* setup: create a study to be deleted */ const studyName = uuid(); - const newStudy = { + const newStudy: IStudy = { id: `id_${studyName}`, name: studyName, - createdBy: 'admin', - lastModified: 200000002, - deleted: null, currentDataVersion: -1, dataVersions: [], - type: studyType.SENSOR + life: { + createdTime: 200000002, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.studies_collection).insertOne(newStudy); @@ -323,7 +334,7 @@ if (global.hasMinio) { expect(resWhoAmI.body.data.errors).toBeUndefined(); expect(resWhoAmI.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -335,15 +346,14 @@ if (global.hasMinio) { projects: [], studies: [{ id: newStudy.id, - name: studyName, - type: studyType.SENSOR + name: studyName }] }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); /* test */ @@ -359,14 +369,14 @@ if (global.hasMinio) { }); const study = await mongoClient.collection(config.database.collections.studies_collection).findOne({ id: newStudy.id }); - expect(typeof study.deleted).toBe('number'); + expect(typeof study?.life.deletedTime).toBe('number'); const resWhoAmIAfter = await admin.post('/graphql').send({ query: print(WHO_AM_I) }); expect(resWhoAmIAfter.status).toBe(200); expect(resWhoAmIAfter.body.data.errors).toBeUndefined(); expect(resWhoAmIAfter.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -382,21 +392,25 @@ if (global.hasMinio) { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); }); test('Delete study that has been deleted (no projects) (admin)', async () => { /* setup: create a study to be deleted */ const studyName = uuid(); - const newStudy = { + const newStudy: IStudy = { id: `id_${studyName}`, name: studyName, - createdBy: 'admin', - lastModified: 200000002, - deleted: new Date().valueOf(), currentDataVersion: -1, - dataVersions: [] + dataVersions: [], + life: { + createdTime: 200000002, + createdUserId: 'admin', + deletedTime: 10000, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.studies_collection).insertOne(newStudy); @@ -425,14 +439,18 @@ if (global.hasMinio) { test('Delete study (user) (should fail)', async () => { /* setup: create a study to be deleted */ const studyName = uuid(); - const newStudy = { + const newStudy: IStudy = { id: `id_${studyName}`, name: studyName, - createdBy: 'admin', - lastModified: 200000002, - deleted: null, currentDataVersion: -1, - dataVersions: [] + dataVersions: [], + life: { + createdTime: 200000002, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.studies_collection).insertOne(newStudy); @@ -447,16 +465,16 @@ if (global.hasMinio) { /* confirms that the created study is still alive */ const createdStudy = await mongoClient.collection(config.database.collections.studies_collection).findOne({ name: studyName }); - expect(createdStudy.deleted).toBe(null); + expect(createdStudy?.life.deletedTime).toBe(null); /* cleanup: delete study */ - await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ name: studyName, deleted: null }, { $set: { deleted: new Date().valueOf() } }); + await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); }); }); describe('MANIPULATING PROJECTS EXISTENCE', () => { let testCounter = 0; - let setupStudy: { id: any; name?: string; createdBy?: string; lastModified?: number; deleted?: null; currentDataVersion?: number; dataVersions?: { id: string; contentId: string; version: string; tag: string; updateDate: string; }[]; }; + let setupStudy: IStudy; let setupProject: { id: any; studyId?: string; createdBy?: string; name?: string; patientMapping?: { patient001: string; }; lastModified?: number; deleted?: null; }; beforeEach(async () => { testCounter++; @@ -465,9 +483,6 @@ if (global.hasMinio) { setupStudy = { id: `id_${studyName}`, name: studyName, - createdBy: 'admin', - lastModified: 200000002, - deleted: null, currentDataVersion: 0, dataVersions: [ { @@ -477,7 +492,14 @@ if (global.hasMinio) { tag: '1', updateDate: '1628049066475' } - ] + ], + life: { + createdTime: 20000001, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.studies_collection).insertOne(setupStudy); @@ -496,7 +518,7 @@ if (global.hasMinio) { }); afterEach(async () => { - await mongoClient.collection(config.database.collections.studies_collection).updateOne({ id: setupStudy.id }, { $set: { deleted: 10000 } }); + await mongoClient.collection(config.database.collections.studies_collection).updateOne({ id: setupStudy.id }, { $set: { 'life.deletedTime': 10000 } }); await mongoClient.collection(config.database.collections.projects_collection).updateOne({ id: setupProject.id }, { $set: { deleted: 10000 } }); }); @@ -553,7 +575,7 @@ if (global.hasMinio) { const username = uuid(); const authorisedUserProfile: IUser = { username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -564,17 +586,21 @@ if (global.hasMinio) { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `new_user_id_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); const roleId = uuid(); const newRole = { id: roleId, - projectId: null, studyId: setupStudy.id, name: `${roleId}_rolename`, permissions: { @@ -670,7 +696,7 @@ if (global.hasMinio) { const username = uuid(); const authorisedUserProfile: IUser = { username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -681,17 +707,21 @@ if (global.hasMinio) { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `new_user_id_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(authorisedUserProfile); const roleId = uuid(); const newRole = { id: roleId, - projectId: null, studyId: setupStudy.id, name: `${roleId}_rolename`, permissions: { @@ -754,17 +784,14 @@ if (global.hasMinio) { let createdStudy: { id: any; name: any; }; let createdRole_study: { _id: any; id: any; name: any; }; let createdRole_study_manageProject: { _id: any; id: any; name: any; }; - let createdRole_study_self_access: { _id: any; id: any; name: any; }; let createdRole_project: { _id: any; id: any; name: any; }; let createdUserAuthorised: { id: any; firstname: any; lastname: any; username: string; otpSecret: string; }; // profile let createdUserAuthorisedStudy: { id: any; firstname: any; lastname: any; username: string; otpSecret: string; }; // profile let createdUserAuthorisedStudyManageProjects: { id: any; firstname: any; lastname: any; username: string; otpSecret: string; }; // profile - let createdUserAuthorisedToOneOrg: { id: any; firstname: any; lastname: any; username: string; otpSecret: string; }; // profile let authorisedUser: request.SuperTest; // client let authorisedUserStudy: request.SuperTest; // client let authorisedUserStudyManageProject: request.SuperTest; // client - let authorisedUserToOneOrg: request.SuperTest; // client - let mockFields: IFieldEntry[]; + let mockFields: IField[]; let mockFiles: IFile[]; let mockDataVersion: IStudyDataVersion; const newMockDataVersion: IStudyDataVersion = { // this is not added right away; but multiple tests uses this @@ -803,50 +830,82 @@ if (global.hasMinio) { version: '0.0.1', updateDate: '5000000' }; - const mockData: IDataEntry[] = [ + const mockData: IData[] = [ { id: 'mockData1_1', - m_subjectId: 'mock_patient1', - m_visitId: 'mockvisitId', - m_studyId: createdStudy.id, - m_versionId: mockDataVersion.id, - m_fieldId: '31', + studyId: createdStudy.id, + fieldId: '31', + dataVersion: mockDataVersion.id, value: 'male', - metadata: {}, - deleted: null + properties: { + m_subjectId: 'mock_patient1', + m_visitId: 'mockvisitId' + + }, + life: { + createdTime: 10000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }, { id: 'mockData1_2', - m_subjectId: 'mock_patient1', - m_visitId: 'mockvisitId', - m_studyId: createdStudy.id, - m_versionId: mockDataVersion.id, - m_fieldId: '49', + studyId: createdStudy.id, + fieldId: '49', + dataVersion: mockDataVersion.id, value: 'England', - metadata: {}, - deleted: null + properties: { + m_subjectId: 'mock_patient1', + m_visitId: 'mockvisitId' + + }, + life: { + createdTime: 10001, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }, { id: 'mockData2_1', - m_subjectId: 'mock_patient2', - m_visitId: 'mockvisitId', - m_studyId: createdStudy.id, - m_versionId: mockDataVersion.id, - m_fieldId: '31', + studyId: createdStudy.id, + fieldId: '31', + dataVersion: mockDataVersion.id, value: 'female', - metadata: {}, - deleted: null + properties: { + m_subjectId: 'mock_patient2', + m_visitId: 'mockvisitId' + + }, + life: { + createdTime: 10002, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }, { id: 'mockData2_2', - m_subjectId: 'mock_patient2', - m_visitId: 'mockvisitId', - m_studyId: createdStudy.id, - m_versionId: mockDataVersion.id, - m_fieldId: '49', + studyId: createdStudy.id, + fieldId: '49', + dataVersion: mockDataVersion.id, value: 'France', - metadata: {}, - deleted: null + properties: { + m_subjectId: 'mock_patient2', + m_visitId: 'mockvisitId' + + }, + life: { + createdTime: 10003, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} } ]; mockFields = [ @@ -855,53 +914,79 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldId: '31', fieldName: 'Sex', - dataType: enumValueType.STRING, - possibleValues: [], + dataType: enumDataTypes.STRING, + categoricalOptions: [], unit: 'person', comments: 'mockComments1', - dateAdded: '10000', - dateDeleted: null, - dataVersion: 'mockDataVersionId' + dataVersion: 'mockDataVersionId', + life: { + createdTime: 10000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }, { id: 'mockfield2', studyId: createdStudy.id, fieldId: '32', fieldName: 'Race', - dataType: enumValueType.STRING, - possibleValues: [], + dataType: enumDataTypes.STRING, + categoricalOptions: [], unit: 'person', comments: 'mockComments2', - dateAdded: '20000', - dateDeleted: null, - dataVersion: 'mockDataVersionId' + dataVersion: 'mockDataVersionId', + life: { + createdTime: 20000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} } ]; mockFiles = [ { id: 'mockfile1_id', - fileName: 'I7N3G6G-MMM7N3G6G-20200704-20210429.txt', studyId: createdStudy.id, + userId: null, + fileName: 'I7N3G6G-MMM7N3G6G-20200704-20210429.txt', fileSize: '1000', description: 'Just a test file1', - uploadTime: '1599345644000', - uploadedBy: adminId, + properties: {}, uri: 'fakeuri', - deleted: null, - hash: 'b0dc2ae76cdea04dcf4be7fcfbe36e2ce8d864fe70a1895c993ce695274ba7a0' + hash: 'b0dc2ae76cdea04dcf4be7fcfbe36e2ce8d864fe70a1895c993ce695274ba7a0', + fileType: enumFileTypes.TXT, + fileCategory: enumFileCategories.STUDY_DATA_FILE, + life: { + createdTime: 1599345644000, + createdUser: adminId, + deletedTime: null, + deletedUser: null + }, + metadata: {} }, { id: 'mockfile2_id', - fileName: 'GR6R4AR-MMMS3JSPP-20200601-20200703.json', studyId: createdStudy.id, + userId: null, + fileName: 'GR6R4AR-MMMS3JSPP-20200601-20200703.json', fileSize: '1000', description: 'Just a test file2', - uploadTime: '1599345644000', - uploadedBy: adminId, + properties: {}, uri: 'fakeuri2', - deleted: null, - hash: '4ae25be36354ee0aec8dc8deac3f279d2e9d6415361da996cf57eb6142cfb1a3' + hash: '4ae25be36354ee0aec8dc8deac3f279d2e9d6415361da996cf57eb6142cfb1a3', + fileType: enumFileTypes.JSON, + fileCategory: enumFileCategories.STUDY_DATA_FILE, + life: { + createdTime: 1599345644000, + createdUser: adminId, + deletedTime: null, + deletedUser: null + }, + metadata: {} } ]; await db.collections.studies_collection.updateOne({ id: createdStudy.id }, { @@ -944,7 +1029,6 @@ if (global.hasMinio) { await db.collections.field_dictionary_collection.insertMany(mockFields); await db.collections.files_collection.insertMany(mockFiles); } - /* 2. create projects for the study */ { const projectName = uuid(); @@ -973,8 +1057,7 @@ if (global.hasMinio) { query: print(ADD_NEW_ROLE), variables: { roleName, - studyId: createdStudy.id, - projectId: null + studyId: createdStudy.id } }); expect(res.status).toBe(200); @@ -984,8 +1067,8 @@ if (global.hasMinio) { expect(createdRole_study).toEqual({ _id: createdRole_study._id, id: createdRole_study.id, - projectId: null, studyId: createdStudy.id, + projectId: null, name: roleName, permissions: { data: { @@ -1042,8 +1125,7 @@ if (global.hasMinio) { query: print(ADD_NEW_ROLE), variables: { roleName, - studyId: createdStudy.id, - projectId: null + studyId: createdStudy.id } }); expect(res.status).toBe(200); @@ -1053,78 +1135,8 @@ if (global.hasMinio) { expect(createdRole_study_manageProject).toEqual({ _id: createdRole_study_manageProject._id, id: createdRole_study_manageProject.id, - projectId: null, - studyId: createdStudy.id, - name: roleName, - permissions: { - data: { - fieldIds: [], - hasVersioned: false, - uploaders: ['^.*$'], - operations: [], - subjectIds: [], - visitIds: [] - }, - manage: { - [IPermissionManagementOptions.own]: [atomicOperation.READ], - [IPermissionManagementOptions.role]: [], - [IPermissionManagementOptions.job]: [], - [IPermissionManagementOptions.query]: [], - [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] - } - }, - description: '', - createdBy: adminId, - users: [], - deleted: null, - metadata: {} - }); - expect(res.body.data.addRole).toEqual({ - id: createdRole_study_manageProject.id, - name: roleName, - permissions: { - data: { - fieldIds: [], - hasVersioned: false, - uploaders: ['^.*$'], - operations: [], - subjectIds: [], - visitIds: [] - }, - manage: { - [IPermissionManagementOptions.own]: [atomicOperation.READ], - [IPermissionManagementOptions.role]: [], - [IPermissionManagementOptions.job]: [], - [IPermissionManagementOptions.query]: [], - [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] - } - }, studyId: createdStudy.id, projectId: null, - users: [] - }); - } - - /* create another role for access data with self organisation only */ - { - const roleName = uuid(); - const res = await admin.post('/graphql').send({ - query: print(ADD_NEW_ROLE), - variables: { - roleName, - studyId: createdStudy.id, - projectId: null - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - - createdRole_study_self_access = await mongoClient.collection(config.database.collections.roles_collection).findOne({ name: roleName }); - expect(createdRole_study_self_access).toEqual({ - _id: createdRole_study_self_access._id, - id: createdRole_study_self_access.id, - projectId: null, - studyId: createdStudy.id, name: roleName, permissions: { data: { @@ -1150,7 +1162,7 @@ if (global.hasMinio) { metadata: {} }); expect(res.body.data.addRole).toEqual({ - id: createdRole_study_self_access.id, + id: createdRole_study_manageProject.id, name: roleName, permissions: { data: { @@ -1249,7 +1261,7 @@ if (global.hasMinio) { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1260,10 +1272,15 @@ if (global.hasMinio) { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedProjectUser_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); createdUserAuthorised = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); @@ -1343,7 +1360,7 @@ if (global.hasMinio) { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorised.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorised.username}_firstname`, lastname: `${createdUserAuthorised.username}_lastname`, organisation: 'organisation_system', @@ -1357,6 +1374,17 @@ if (global.hasMinio) { studies: [] } }); + const tag = `metadata.${'role:'.concat(createdRole_project.id)}`; + await db.collections.field_dictionary_collection.updateMany({}, { + $set: { + [tag]: true + } + }); + await db.collections.data_collection.updateMany({}, { + $set: { + [tag]: true + } + }); } /* 5. create an authorised study user (no role yet) */ @@ -1364,7 +1392,7 @@ if (global.hasMinio) { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1375,15 +1403,19 @@ if (global.hasMinio) { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedStudyUser_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); createdUserAuthorisedStudy = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); } - /* 6. add authorised user to role */ { const res = await admin.post('/graphql').send({ @@ -1458,7 +1490,7 @@ if (global.hasMinio) { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorisedStudy.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorisedStudy.username}_firstname`, lastname: `${createdUserAuthorisedStudy.username}_lastname`, organisation: 'organisation_system', @@ -1475,14 +1507,24 @@ if (global.hasMinio) { }] } }); + const tag = `metadata.${'role:'.concat(createdRole_study.id)}`; + await db.collections.field_dictionary_collection.updateMany({}, { + $set: { + [tag]: true + } + }); + await db.collections.data_collection.updateMany({}, { + $set: { + [tag]: true + } + }); } - /* 5. create an authorised study user that can manage projects (no role yet) */ { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1493,42 +1535,21 @@ if (global.hasMinio) { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedStudyUserManageProject_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); createdUserAuthorisedStudyManageProjects = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); } - /* 5. create an authorised study user that can access data from self organisation only */ - { - const username = uuid(); - const newUser: IUser = { - username: username, - type: userTypes.STANDARD, - firstname: `${username}_firstname`, - lastname: `${username}_lastname`, - password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', - otpSecret: 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA', - email: `${username}'@user.io'`, - resetPasswordRequests: [], - description: 'I am an authorised study user to access self org data.', - emailNotificationsActivated: true, - emailNotificationsStatus: { expiringNotification: false }, - organisation: 'organisation_user', - deleted: null, - id: `AuthorisedStudyUserAccessSelfData_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 - }; - - await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); - createdUserAuthorisedToOneOrg = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); - } - /* 6. add authorised user to role */ { const res = await admin.post('/graphql').send({ @@ -1603,7 +1624,7 @@ if (global.hasMinio) { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorisedStudyManageProjects.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorisedStudyManageProjects.username}_firstname`, lastname: `${createdUserAuthorisedStudyManageProjects.username}_lastname`, organisation: 'organisation_system', @@ -1621,104 +1642,12 @@ if (global.hasMinio) { } }); } - /* 7. add authorised user to role */ - { - const res = await admin.post('/graphql').send({ - query: print(EDIT_ROLE), - variables: { - roleId: createdRole_study_self_access.id, - userChanges: { - add: [createdUserAuthorisedToOneOrg.id], - remove: [] - }, - permissionChanges: { - data: { - fieldIds: ['^.*$'], - hasVersioned: false, - uploaders: ['^.*$'], - operations: [atomicOperation.READ], - subjectIds: ['^K.*$'], - visitIds: ['^.*$'] - }, - manage: { - [IPermissionManagementOptions.own]: [atomicOperation.READ, atomicOperation.WRITE], - [IPermissionManagementOptions.role]: [], - [IPermissionManagementOptions.job]: [], - [IPermissionManagementOptions.query]: [], - [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] - } - } - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.editRole).toEqual({ - id: createdRole_study_self_access.id, - name: createdRole_study_self_access.name, - studyId: createdStudy.id, - projectId: null, - description: '', - permissions: { - data: { - fieldIds: ['^.*$'], - hasVersioned: false, - uploaders: ['^.*$'], - operations: [atomicOperation.READ], - subjectIds: ['^K.*$'], - visitIds: ['^.*$'] - }, - manage: { - [IPermissionManagementOptions.own]: [atomicOperation.READ, atomicOperation.WRITE], - [IPermissionManagementOptions.role]: [], - [IPermissionManagementOptions.job]: [], - [IPermissionManagementOptions.query]: [], - [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] - } - }, - users: [{ - id: createdUserAuthorisedToOneOrg.id, - organisation: 'organisation_user', - firstname: createdUserAuthorisedToOneOrg.firstname, - lastname: createdUserAuthorisedToOneOrg.lastname - }] - }); - const resUser = await admin.post('/graphql').send({ - query: print(GET_USERS), - variables: { - fetchDetailsAdminOnly: false, - userId: createdUserAuthorisedToOneOrg.id, - fetchAccessPrivileges: true - } - }); - expect(resUser.status).toBe(200); - expect(resUser.body.errors).toBeUndefined(); - expect(resUser.body.data.getUsers).toHaveLength(1); - expect(resUser.body.data.getUsers[0]).toEqual({ - id: createdUserAuthorisedToOneOrg.id, - type: userTypes.STANDARD, - firstname: `${createdUserAuthorisedToOneOrg.username}_firstname`, - lastname: `${createdUserAuthorisedToOneOrg.username}_lastname`, - organisation: 'organisation_user', - access: { - id: `user_access_obj_user_id_${createdUserAuthorisedToOneOrg.id}`, - projects: [{ - id: createdProject.id, - name: createdProject.name, - studyId: createdStudy.id - }], - studies: [{ - id: createdStudy.id, - name: createdStudy.name - }] - } - }); - } /* fsdafs: admin who am i */ { const res = await admin.post('/graphql').send({ query: print(WHO_AM_I) }); expect(res.body.data.whoAmI).toStrictEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -1734,15 +1663,14 @@ if (global.hasMinio) { }], studies: [{ id: createdStudy.id, - name: createdStudy.name, - type: studyType.SENSOR + name: createdStudy.name }] }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); } /* connecting users */ @@ -1752,8 +1680,6 @@ if (global.hasMinio) { await connectAgent(authorisedUserStudy, createdUserAuthorisedStudy.username, 'admin', createdUserAuthorisedStudy.otpSecret); authorisedUserStudyManageProject = request.agent(app); await connectAgent(authorisedUserStudyManageProject, createdUserAuthorisedStudyManageProjects.username, 'admin', createdUserAuthorisedStudyManageProjects.otpSecret); - authorisedUserToOneOrg = request.agent(app); - await connectAgent(authorisedUserToOneOrg, createdUserAuthorisedToOneOrg.username, 'admin', createdUserAuthorisedToOneOrg.otpSecret); }); afterAll(async () => { @@ -1771,7 +1697,7 @@ if (global.hasMinio) { /* delete values in db */ await db.collections.field_dictionary_collection.deleteMany({ studyId: createdStudy.id }); - await db.collections.data_collection.deleteMany({ m_studyId: createdStudy.id }); + await db.collections.data_collection.deleteMany({ studyId: createdStudy.id }); await db.collections.files_collection.deleteMany({ studyId: createdStudy.id }); /* study user cannot delete study */ @@ -1805,7 +1731,7 @@ if (global.hasMinio) { const res = await admin.post('/graphql').send({ query: print(WHO_AM_I) }); expect(res.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -1821,13 +1747,13 @@ if (global.hasMinio) { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); // study data is NOT deleted for audit purposes - unless explicitly requested separately const roles = await db.collections.roles_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); const projects = await db.collections.projects_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); - const study = await db.collections.studies_collection.findOne({ id: createdStudy.id, deleted: null }); + const study = await db.collections.studies_collection.findOne({ 'id': createdStudy.id, 'life.deletedTime': null }); expect(roles).toEqual([]); expect(projects).toEqual([]); expect(study).toBe(null); @@ -1882,7 +1808,7 @@ if (global.hasMinio) { createdBy: adminId, jobs: [], description: 'test description', - type: studyType.SENSOR, + type: null, projects: [ { id: createdProject.id, @@ -1912,8 +1838,8 @@ if (global.hasMinio) { [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] } }, - projectId: null, studyId: createdStudy.id, + projectId: null, users: [{ id: createdUserAuthorisedStudy.id, organisation: 'organisation_system', @@ -1943,8 +1869,8 @@ if (global.hasMinio) { [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] } }, - projectId: null, studyId: createdStudy.id, + projectId: null, users: [{ id: createdUserAuthorisedStudyManageProjects.id, organisation: 'organisation_system', @@ -1952,37 +1878,6 @@ if (global.hasMinio) { lastname: createdUserAuthorisedStudyManageProjects.lastname, username: createdUserAuthorisedStudyManageProjects.username }] - }, - { - id: createdRole_study_self_access.id, - name: createdRole_study_self_access.name, - description: '', - permissions: { - data: { - fieldIds: ['^.*$'], - hasVersioned: false, - uploaders: ['^.*$'], - operations: [atomicOperation.READ], - subjectIds: ['^K.*$'], - visitIds: ['^.*$'] - }, - manage: { - [IPermissionManagementOptions.own]: [atomicOperation.READ, atomicOperation.WRITE], - [IPermissionManagementOptions.role]: [], - [IPermissionManagementOptions.job]: [], - [IPermissionManagementOptions.query]: [], - [IPermissionManagementOptions.ontologyTrees]: [atomicOperation.READ] - } - }, - projectId: null, - studyId: createdStudy.id, - users: [{ - id: createdUserAuthorisedToOneOrg.id, - organisation: 'organisation_user', - firstname: createdUserAuthorisedToOneOrg.firstname, - lastname: createdUserAuthorisedToOneOrg.lastname, - username: createdUserAuthorisedToOneOrg.username - }] } ], files: [], @@ -2069,92 +1964,6 @@ if (global.hasMinio) { } }); - test('Get study (user that can only access self org data)', async () => { - const res = await authorisedUserToOneOrg.post('/graphql').send({ - query: print(GET_STUDY), - variables: { studyId: createdStudy.id } - }); - expect(res.status).toBe(200); - // expect(res.body.errors).toBeUndefined(); - // expect(res.body.data.getStudy.files[0]).toEqual({ - // id: 'mockfile1_id', - // fileName: 'I7N3G6G-MMM7N3G6G-20200704-20210429.txt', - // studyId: createdStudy.id, - // projectId: null, - // fileSize: '1000', - // description: 'Just a test file1', - // uploadTime: '1599345644000', - // uploadedBy: adminId, - // hash: 'b0dc2ae76cdea04dcf4be7fcfbe36e2ce8d864fe70a1895c993ce695274ba7a0' - // } - // ); - }); - - test('Get patient mapping (admin)', async () => { - const res = await admin.post('/graphql').send({ - query: print(GET_PROJECT_PATIENT_MAPPING), - variables: { projectId: createdProject.id } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.getProject).toEqual({ - id: createdProject.id, - patientMapping: { - mock_patient1: createdProject.patientMapping.mock_patient1, - mock_patient2: createdProject.patientMapping.mock_patient2 - } - }); - const { patientMapping } = res.body.data.getProject; - expect(typeof patientMapping.mock_patient1).toBe('string'); - expect(patientMapping.mock_patient1).not.toBe('mock_patient1'); // should not be the same as before mapped - expect(typeof patientMapping.mock_patient2).toBe('string'); - expect(patientMapping.mock_patient2).not.toBe('mock_patient2'); // should not be the same as before mapped - }); - - test('Get patient mapping (user without privilege) (should fail)', async () => { - const res = await user.post('/graphql').send({ - query: print(GET_PROJECT_PATIENT_MAPPING), - variables: { projectId: createdProject.id } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - expect(res.body.data.getProject).toBe(null); - }); - - test('Get patient mapping (user with project data privilege) (should fail)', async () => { - // patient mapping is obscured from users that can only access project data. They should only see the mapped id - const res = await authorisedUser.post('/graphql').send({ - query: print(GET_PROJECT_PATIENT_MAPPING), - variables: { projectId: createdProject.id } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - expect(res.body.data.getProject).toBe(null); - }); - - test('Get patient mapping (user with study data privilege)', async () => { - const res = await authorisedUserStudy.post('/graphql').send({ - query: print(GET_PROJECT_PATIENT_MAPPING), - variables: { projectId: createdProject.id } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.getProject).toEqual({ - id: createdProject.id, - patientMapping: { - mock_patient1: createdProject.patientMapping.mock_patient1, - mock_patient2: createdProject.patientMapping.mock_patient2 - } - }); - const { patientMapping } = res.body.data.getProject; - expect(typeof patientMapping.mock_patient1).toBe('string'); - expect(patientMapping.mock_patient1).not.toBe('mock_patient1'); // should not be the same as before mapped - expect(typeof patientMapping.mock_patient2).toBe('string'); - expect(patientMapping.mock_patient2).not.toBe('mock_patient2'); // should not be the same as before mapped - }); - test('Get study (user without privilege)', async () => { const res = await user.post('/graphql').send({ query: print(GET_STUDY), @@ -2229,19 +2038,13 @@ if (global.hasMinio) { fieldId: '32', fieldName: 'Race', tableName: null, - dataType: enumValueType.STRING, + dataType: 'str', possibleValues: [], unit: 'person', comments: 'mockComments2', dateAdded: '20000', dateDeleted: null, - dataVersion: 'mockDataVersionId', - metadata: { - [`role:${createdRole_study.id}`]: true, - [`role:${createdRole_project.id}`]: true, - [`role:${createdRole_study_manageProject.id}`]: false, - [`role:${createdRole_study_self_access.id}`]: true - } + dataVersion: 'mockDataVersionId' }, { id: 'mockfield1', @@ -2249,19 +2052,13 @@ if (global.hasMinio) { fieldId: '31', fieldName: 'Sex', tableName: null, - dataType: enumValueType.STRING, + dataType: 'str', possibleValues: [], unit: 'person', comments: 'mockComments1', dateAdded: '10000', dateDeleted: null, - dataVersion: 'mockDataVersionId', - metadata: { - [`role:${createdRole_study.id}`]: true, - [`role:${createdRole_project.id}`]: true, - [`role:${createdRole_study_manageProject.id}`]: false, - [`role:${createdRole_study_self_access.id}`]: true - } + dataVersion: 'mockDataVersionId' } ].sort((a, b) => a.id.localeCompare(b.id))); }); @@ -2283,14 +2080,13 @@ if (global.hasMinio) { fieldId: '31', fieldName: 'Sex', tableName: null, - dataType: enumValueType.STRING, + dataType: 'str', possibleValues: [], unit: 'person', comments: 'mockComments1', dateAdded: '10000', dateDeleted: null, - dataVersion: 'mockDataVersionId', - metadata: null + dataVersion: 'mockDataVersionId' }, { id: 'mockfield2', @@ -2298,14 +2094,13 @@ if (global.hasMinio) { fieldId: '32', fieldName: 'Race', tableName: null, - dataType: enumValueType.STRING, + dataType: 'str', possibleValues: [], unit: 'person', comments: 'mockComments2', dateAdded: '20000', dateDeleted: null, - dataVersion: 'mockDataVersionId', - metadata: null + dataVersion: 'mockDataVersionId' } ].sort((a, b) => a.id.localeCompare(b.id))); }); @@ -2331,14 +2126,18 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldId: '32', fieldName: 'Race', - tableName: null, - dataType: enumValueType.STRING, - possibleValues: [], + dataType: enumDataTypes.STRING, + categoricalOptions: [], unit: 'person', comments: 'mockComments1', - dateAdded: '30000', - dateDeleted: '30000', - dataVersion: null + dataVersion: null, + life: { + createdTime: 30000, + createdUserId: 'admin', + deletedTime: 30000, + deletedUserId: 'admin' + }, + metadata: {} }); await db.collections.field_dictionary_collection.insertOne({ @@ -2346,14 +2145,18 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldId: '33', fieldName: 'Weight', - tableName: null, - dataType: enumValueType.DECIMAL, - possibleValues: [], + dataType: enumDataTypes.DECIMAL, + categoricalOptions: [], unit: 'kg', comments: 'mockComments3', - dateAdded: '30000', - dateDeleted: null, - dataVersion: null + dataVersion: null, + life: { + createdTime: 30000, + createdUserId: 'admin', + deletedTime: null, + deletedUserId: null + }, + metadata: {} }); // user with study privilege can access all latest field, including unversioned @@ -2385,14 +2188,13 @@ if (global.hasMinio) { fieldId: '32', fieldName: 'Race', tableName: null, - dataType: enumValueType.STRING, + dataType: 'str', possibleValues: [], unit: 'person', comments: 'mockComments2', dateAdded: '20000', dateDeleted: null, - dataVersion: 'mockDataVersionId', - metadata: null + dataVersion: 'mockDataVersionId' }, { id: 'mockfield1', @@ -2400,14 +2202,13 @@ if (global.hasMinio) { fieldId: '31', fieldName: 'Sex', tableName: null, - dataType: enumValueType.STRING, + dataType: 'str', possibleValues: [], unit: 'person', comments: 'mockComments1', dateAdded: '10000', dateDeleted: null, - dataVersion: 'mockDataVersionId', - metadata: null + dataVersion: 'mockDataVersionId' } ].sort((a, b) => a.id.localeCompare(b.id))); // clear database @@ -2532,14 +2333,19 @@ if (global.hasMinio) { firstname: 'FDataCurator', lastname: 'LDataCurator', organisation: 'organisation_system', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, description: 'just a data curator', resetPasswordRequests: [], emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, - deleted: null, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await db.collections.users_collection.insertOne(userDataCurator); @@ -2747,7 +2553,7 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); expect(res.body.data.deleteField.fieldId).toBe('8'); - const fieldsInDb = await db.collections.field_dictionary_collection.find({ studyId: createdStudy.id, dateDeleted: { $ne: null } }).toArray(); + const fieldsInDb = await db.collections.field_dictionary_collection.find({ 'studyId': createdStudy.id, 'life.deletedTime': { $ne: null } }).toArray(); expect(fieldsInDb).toHaveLength(1); expect(fieldsInDb[0].fieldId).toBe('8'); // clear database @@ -2820,7 +2626,7 @@ if (global.hasMinio) { let authorisedUser: request.SuperTest; let authorisedProjectUser: request.SuperTest; let unauthorisedUser: request.SuperTest; - let mockFields: any[]; + let mockFields: IField[]; let mockDataVersion: IStudyDataVersion; const fieldTreeId = uuid(); const oneRecord = [{ @@ -2895,8 +2701,7 @@ if (global.hasMinio) { query: print(ADD_NEW_ROLE), variables: { roleName, - studyId: createdStudy.id, - projectId: null + studyId: createdStudy.id } }); expect(res.status).toBe(200); @@ -2906,8 +2711,8 @@ if (global.hasMinio) { expect(createdRole_study_accessData).toEqual({ _id: createdRole_study_accessData._id, id: createdRole_study_accessData.id, - projectId: null, studyId: createdStudy.id, + projectId: null, name: roleName, permissions: { data: { @@ -2963,7 +2768,7 @@ if (global.hasMinio) { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -2974,10 +2779,15 @@ if (global.hasMinio) { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: `AuthorisedStudyUserManageProject_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -2989,7 +2799,7 @@ if (global.hasMinio) { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -3003,10 +2813,14 @@ if (global.hasMinio) { metadata: { wp: 'wp5.1' }, - deleted: null, id: `AuthorisedStudyUserManageProject_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -3087,7 +2901,7 @@ if (global.hasMinio) { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorisedProfile.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorisedProfile.username}_firstname`, lastname: `${createdUserAuthorisedProfile.username}_lastname`, organisation: 'organisation_system', @@ -3115,13 +2929,17 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldId: '31', fieldName: 'Age', - dataType: enumValueType.INTEGER, - possibleValues: [], + dataType: enumDataTypes.INTEGER, + categoricalOptions: [], unit: 'person', comments: 'mockComments1', - dateAdded: 100000000, - dateDeleted: null, dataVersion: 'mockDataVersionId', + life: { + createdTime: 100000000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, metadata: {} }, { @@ -3129,13 +2947,17 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldId: '32', fieldName: 'Sex', - dataType: enumValueType.STRING, - possibleValues: [], + dataType: enumDataTypes.STRING, + categoricalOptions: [], unit: 'person', comments: 'mockComments2', - dateAdded: 100000000, - dateDeleted: null, dataVersion: 'mockDataVersionId', + life: { + createdTime: 100000001, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, metadata: {} }, { @@ -3143,13 +2965,17 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldId: '33', fieldName: 'DeviceTest', - dataType: enumValueType.FILE, - possibleValues: [], + dataType: enumDataTypes.FILE, + categoricalOptions: [], unit: 'person', comments: 'mockComments3', - dateAdded: 100000000, - dateDeleted: null, dataVersion: 'mockDataVersionId', + life: { + createdTime: 100000002, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, metadata: {} } ]; @@ -3217,7 +3043,7 @@ if (global.hasMinio) { const username = uuid(); const newUser: IUser = { username: username, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${username}_firstname`, lastname: `${username}_lastname`, password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -3231,10 +3057,14 @@ if (global.hasMinio) { metadata: { wp: 'wp5.2' }, - deleted: null, id: `AuthorisedProjectUser_${username}`, - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); createdUserAuthorisedProject = await mongoClient.collection(config.database.collections.users_collection).findOne({ username }); @@ -3383,7 +3213,7 @@ if (global.hasMinio) { expect(resUser.body.data.getUsers).toHaveLength(1); expect(resUser.body.data.getUsers[0]).toEqual({ id: createdUserAuthorisedProject.id, - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: `${createdUserAuthorisedProject.username}_firstname`, lastname: `${createdUserAuthorisedProject.username}_lastname`, organisation: 'organisation_system', @@ -3453,7 +3283,7 @@ if (global.hasMinio) { const res = await admin.post('/graphql').send({ query: print(WHO_AM_I) }); expect(res.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -3469,13 +3299,13 @@ if (global.hasMinio) { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); // study data is NOT deleted for audit purposes - unless explicitly requested separately const roles = await db.collections.roles_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); const projects = await db.collections.projects_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); - const study = await db.collections.studies_collection.findOne({ id: createdStudy.id, deleted: null }); + const study = await db.collections.studies_collection.findOne({ 'id': createdStudy.id, 'life.deletedTime': null }); expect(roles).toEqual([]); expect(projects).toEqual([]); expect(study).toBe(null); @@ -3620,41 +3450,10 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); // check both data collection and file collection - const fileFirst = await db.collections.files_collection.findOne({ studyId: createdStudy.id, deleted: null }); - const dataFirst = await db.collections.data_collection.findOne({ m_studyId: createdStudy.id, m_visitId: '1', m_fieldId: '33' }); - expect(dataFirst?.metadata?.add[0]).toBe(fileFirst.id); - - // upload again and check whether the old file has been deleted - const resSecond = await authorisedUser.post('/graphql') - .field('operations', JSON.stringify({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { - studyId: createdStudy.id, - data: [ - { - fieldId: '33', - subjectId: 'I7N3G6G', - visitId: '1', - file: null, - metadata: { - deviceId: 'MMM7N3G6G', - startDate: '1590966000000', - endDate: '1593730800000', - participantId: 'I7N3G6G', - postFix: 'test' - } - } - ] - } - })) - .field('map', JSON.stringify({ 1: ['variables.data.0.file'] })) - .attach('1', path.join(__dirname, '../filesForTests/I7N3G6G-MMM7N3G6G-20200704-20200721.txt')); - expect(resSecond.status).toBe(200); - expect(resSecond.body.errors).toBeUndefined(); - const fileSecond = await db.collections.files_collection.find({ studyId: createdStudy.id, deleted: null }).toArray(); - const dataSecond = await db.collections.data_collection.findOne({ m_studyId: createdStudy.id, m_visitId: '1', m_fieldId: '33' }); - expect(fileSecond).toHaveLength(2); - expect(dataSecond?.metadata?.add).toEqual([fileSecond[0].id, fileSecond[1].id]); + const fileFirst = await db.collections.files_collection.findOne({ 'studyId': createdStudy.id, 'life.deletedTime': null }); + const dataFirst = await db.collections.data_collection.findOne({ 'studyId': createdStudy.id, 'properties.m_visitId': '1', 'fieldId': '33' }); + expect(dataFirst?.value).toBe(fileFirst.id); + expect(dataFirst?.life.deletedTime).toBe(null); }); test('Create New data version with data only (user with study privilege)', async () => { @@ -3676,7 +3475,7 @@ if (global.hasMinio) { expect(studyInDb.dataVersions).toHaveLength(2); expect(studyInDb.dataVersions[1].version).toBe('1'); expect(studyInDb.dataVersions[1].tag).toBe('testTag'); - const dataInDb = await db.collections.data_collection.find({ m_studyId: createdStudy.id, m_versionId: createRes.body.data.createNewDataVersion.id }).toArray(); + const dataInDb = await db.collections.data_collection.find({ studyId: createdStudy.id, dataVersion: createRes.body.data.createNewDataVersion.id }).toArray(); expect(dataInDb).toHaveLength(6); }); @@ -3687,7 +3486,7 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldInput: { fieldId: '34', fieldName: 'Height', - dataType: enumValueType.DECIMAL, + dataType: 'dec', unit: 'cm' } } @@ -3717,7 +3516,7 @@ if (global.hasMinio) { studyId: createdStudy.id, fieldInput: { fieldId: '34', fieldName: 'Height', - dataType: enumValueType.DECIMAL, + dataType: 'dec', unit: 'cm' } } @@ -3746,7 +3545,7 @@ if (global.hasMinio) { expect(studyInDb.dataVersions).toHaveLength(2); expect(studyInDb.dataVersions[1].version).toBe('1'); expect(studyInDb.dataVersions[1].tag).toBe('testTag'); - const dataInDb = await db.collections.data_collection.find({ m_studyId: createdStudy.id, m_versionId: createRes.body.data.createNewDataVersion.id }).toArray(); + const dataInDb = await db.collections.data_collection.find({ studyId: createdStudy.id, dataVersion: createRes.body.data.createNewDataVersion.id }).toArray(); expect(dataInDb).toHaveLength(7); const fieldsInDb = await db.collections.field_dictionary_collection.find({ studyId: createdStudy.id, dataVersion: { $in: [createRes.body.data.createNewDataVersion.id, 'mockDataVersionId'] } }).toArray(); expect(fieldsInDb).toHaveLength(4); @@ -3851,7 +3650,6 @@ if (global.hasMinio) { { code: null, description: 'GR6R4AR-2-31', id: null, successful: true }, { code: null, description: 'GR6R4AR-2-32', id: null, successful: true } ]); - const deleteRes = await admin.post('/graphql').send({ query: print(DELETE_DATA_RECORDS), variables: { studyId: createdStudy.id } @@ -3875,8 +3673,8 @@ if (global.hasMinio) { { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-32 is deleted.' }, { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-33 is deleted.' }, { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-34 is deleted.' }]); - const dataInDb = await db.collections.data_collection.find({ 31: null }).sort({ uploadedAt: -1 }).toArray(); - expect(dataInDb).toHaveLength(16); // 2 visits * 2 subjects * 2 fields * 2 (delete or not) = 16 records + const dataInDb = await db.collections.data_collection.find({}).sort({ 'life.createdTime': -1 }).toArray(); + expect(dataInDb).toHaveLength(22); // 2 visits * 2 subjects * 2 fields * 2 (delete or not) + 6 (original records) = 22 records }); test('Delete data records: records not exist', async () => { @@ -3894,7 +3692,6 @@ if (global.hasMinio) { { code: null, description: 'GR6R4AR-2-31', id: null, successful: true }, { code: null, description: 'GR6R4AR-2-32', id: null, successful: true } ]); - const deleteRes = await admin.post('/graphql').send({ query: print(DELETE_DATA_RECORDS), variables: { studyId: createdStudy.id, subjectIds: ['I7N3G6G'], visitIds: ['1', '2'] } @@ -3910,8 +3707,8 @@ if (global.hasMinio) { { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-32 is deleted.' }, { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-33 is deleted.' }, { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-34 is deleted.' }]); - const dataInDb = await db.collections.data_collection.find({ m_subjectId: 'I7N3G6G' }).sort({ uploadedAt: -1 }).toArray(); - expect(dataInDb).toHaveLength(8); // two data records, two deleted records + const dataInDb = await db.collections.data_collection.find({}).sort({ 'life.createdTime': -1 }).toArray(); + expect(dataInDb).toHaveLength(14); // 8 deleted records and 6 original records }); test('Get data records (user with study privilege)', async () => { @@ -4357,52 +4154,6 @@ if (global.hasMinio) { } }); }); - - test('Check data complete (admin)', async () => { - await admin.post('/graphql').send({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { studyId: createdStudy.id, data: multipleRecords } - }); - // edit a field so that the datatype mismatched with the exisiting value - // this happens when a field is deleted first and then modified and added, while some data has been uploaded before deleting, causing conflicts - await db.collections.field_dictionary_collection.findOneAndUpdate({ - studyId: createdStudy.id, - fieldId: '32' - }, { - $set: { - dataType: enumValueType.DECIMAL - } - }); - - const checkRes = await admin.post('/graphql').send({ - query: print(CHECK_DATA_COMPLETE), - variables: { studyId: createdStudy.id } - }); - expect(checkRes.status).toBe(200); - expect(checkRes.body.errors).toBeUndefined(); - expect(checkRes.body.data.checkDataComplete.sort((a, b) => { - return a.subjectId === b.subjectId ? a.visitId.localeCompare(b.visitId) : a.subjectId.localeCompare(b.subjectId); - })).toEqual([ - { - subjectId: 'GR6R4AR', - visitId: '2', - fieldId: '32', - error: 'Field 32: Cannot parse as decimal.' - }, - { - subjectId: 'I7N3G6G', - visitId: '1', - fieldId: '32', - error: 'Field 32: Cannot parse as decimal.' - }, - { - subjectId: 'I7N3G6G', - visitId: '2', - fieldId: '32', - error: 'Field 32: Cannot parse as decimal.' - } - ]); - }); }); }); } else diff --git a/packages/itmat-interface/test/serverTests/users.test.ts b/packages/itmat-interface/test/GraphQLTests/users.test.ts similarity index 93% rename from packages/itmat-interface/test/serverTests/users.test.ts rename to packages/itmat-interface/test/GraphQLTests/users.test.ts index 99ff2bc3b..da5f1a1f8 100644 --- a/packages/itmat-interface/test/serverTests/users.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/users.test.ts @@ -23,7 +23,7 @@ import { RESET_PASSWORD, LOGIN } from '@itmat-broker/itmat-models'; -import { IResetPasswordRequest, IUser, userTypes } from '@itmat-broker/itmat-types'; +import { IResetPasswordRequest, IUser, enumUserTypes } from '@itmat-broker/itmat-types'; import type { Express } from 'express'; let app: Express; @@ -473,7 +473,7 @@ describe('USERS API', () => { expect(whoami.body.data.whoAmI.id).toBeDefined(); expect(whoami.body.data.whoAmI).toEqual({ username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -489,7 +489,7 @@ describe('USERS API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); /* cleanup */ @@ -541,7 +541,7 @@ describe('USERS API', () => { expect(whoami.body.data.whoAmI.id).toBeDefined(); expect(whoami.body.data.whoAmI).toEqual({ username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -557,7 +557,7 @@ describe('USERS API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); /* test */ @@ -609,8 +609,8 @@ describe('USERS API', () => { const client_not_logged_in = request.agent(app); const res = await client_not_logged_in.post('/graphql').send({ query: print(WHO_AM_I) }); expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.whoAmI).toBe(null); + expect(res.body.errors).toHaveLength(1); + expect(res.body.errors[0].message).toBe(errorCodes.NOT_LOGGED_IN); }); test('Who am I (admin)', async () => { @@ -620,7 +620,7 @@ describe('USERS API', () => { adminId = res.body.data.whoAmI.id; expect(res.body.data.whoAmI).toEqual({ username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -636,7 +636,7 @@ describe('USERS API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); }); @@ -648,7 +648,7 @@ describe('USERS API', () => { userId = res.body.data.whoAmI.id; expect(res.body.data.whoAmI).toEqual({ username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -664,7 +664,7 @@ describe('USERS API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); }); @@ -672,7 +672,7 @@ describe('USERS API', () => { const userSecret = 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA'; const newUser: IUser = { username: 'expired_user', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Fexpired user', lastname: 'Lexpired user', password: '$2b$04$ps9ownz6PqJFD/LExsmgR.ZLk11zhtRdcpUwypWVfWJ4ZW6/Zzok2', @@ -717,7 +717,7 @@ describe('USERS API', () => { const adminSecret = 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA'; const newUser: IUser = { username: 'expired_admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fexpired admin', lastname: 'Lexpired admin', password: '$2b$04$ps9ownz6PqJFD/LExsmgR.ZLk11zhtRdcpUwypWVfWJ4ZW6/Zzok2', @@ -728,10 +728,15 @@ describe('USERS API', () => { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: 'expiredId1', - createdAt: 1591134065000, - expiredAt: 1501134065000 + expiredAt: 1591134065000, + life: { + createdTime: 1501134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const newloggedoutuser = request.agent(app); @@ -761,9 +766,9 @@ describe('USERS API', () => { }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, - createdAt: 1591134065000, - expiredAt: 1501134065000, - metadata: null + expiredAt: 1591134065000, + createdAt: 1501134065000, + metadata: {} }); expect(res.body.errors).toBeUndefined(); await admin.post('/graphql').send( @@ -794,7 +799,7 @@ describe('USERS API', () => { expect(res.body.data.getUsers).toEqual([ { username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -805,11 +810,11 @@ describe('USERS API', () => { id: adminId, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }, { username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -820,7 +825,7 @@ describe('USERS API', () => { id: userId, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ]); }); @@ -831,7 +836,7 @@ describe('USERS API', () => { expect(res.body.data.getUsers).toEqual([ { username: 'admin', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -847,11 +852,11 @@ describe('USERS API', () => { }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }, { username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -867,7 +872,7 @@ describe('USERS API', () => { }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ]); }); @@ -881,7 +886,7 @@ describe('USERS API', () => { null, { username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -892,7 +897,7 @@ describe('USERS API', () => { id: userId, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ]); }); @@ -909,7 +914,7 @@ describe('USERS API', () => { null, { username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -925,7 +930,7 @@ describe('USERS API', () => { }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ]); }); @@ -936,14 +941,14 @@ describe('USERS API', () => { expect(res.body.error).toBeUndefined(); expect(res.body.data.getUsers).toEqual([ { - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', id: adminId }, { - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -958,14 +963,14 @@ describe('USERS API', () => { expect(res.body.error).toBeUndefined(); expect(res.body.data.getUsers).toEqual([ { - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', id: adminId }, { - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -981,7 +986,7 @@ describe('USERS API', () => { expect(res.body.data.getUsers).toEqual([ { username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -997,7 +1002,7 @@ describe('USERS API', () => { }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ]); }); @@ -1021,7 +1026,7 @@ describe('USERS API', () => { expect(res.body.errors).toBeUndefined(); expect(res.body.data.getUsers).toEqual([ { - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'Fadmin', lastname: 'Ladmin', organisation: 'organisation_system', @@ -1037,7 +1042,7 @@ describe('USERS API', () => { expect(res.body.data.getUsers).toEqual([ { username: 'standardUser', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'Tai Man', lastname: 'Chan', organisation: 'organisation_system', @@ -1053,7 +1058,7 @@ describe('USERS API', () => { }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ]); }); @@ -1064,7 +1069,7 @@ describe('USERS API', () => { expect(res.body.errors).toBeUndefined(); expect(res.body.data.getUsers).toEqual([ { - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, organisation: 'organisation_system', firstname: 'Tai Man', lastname: 'Chan', @@ -1093,7 +1098,7 @@ describe('USERS API', () => { organisation: 'DSI-ICL', emailNotificationsActivated: false, email: 'user0email@email.io', - type: userTypes.STANDARD + type: enumUserTypes.STANDARD } }); @@ -1127,7 +1132,7 @@ describe('USERS API', () => { organisation: 'DSI-ICL', emailNotificationsActivated: false, email: 'fake@email.io', - type: userTypes.STANDARD + type: enumUserTypes.STANDARD } }); @@ -1152,7 +1157,7 @@ describe('USERS API', () => { organisation: 'DSI-ICL', emailNotificationsActivated: false, email: 'fak@e@semail.io', - type: userTypes.STANDARD + type: enumUserTypes.STANDARD } }); expect(res.status).toBe(200); @@ -1173,7 +1178,7 @@ describe('USERS API', () => { organisation: 'DSI-ICL', emailNotificationsActivated: false, email: 'fake@email.io', - type: userTypes.STANDARD + type: enumUserTypes.STANDARD } }); expect(res.status).toBe(200); @@ -1186,7 +1191,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FChan Siu Man', lastname: 'LChan Siu Man', password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1216,7 +1221,7 @@ describe('USERS API', () => { organisation: 'DSI-ICL', emailNotificationsActivated: false, email: 'fake@email.io', - type: userTypes.STANDARD + type: enumUserTypes.STANDARD } }); expect(res.status).toBe(200); @@ -1229,7 +1234,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_333333', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FChan Ming Ming', lastname: 'LChan Ming Ming', password: 'fakepassword', @@ -1272,7 +1277,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_3', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FChan Ming Man', lastname: 'LChan Ming Man', password: 'fakepassword', @@ -1297,7 +1302,7 @@ describe('USERS API', () => { variables: { id: 'fakeid2222', username: 'fakeusername', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'FMan', lastname: 'LMan', email: 'hey@uk.io', @@ -1314,7 +1319,7 @@ describe('USERS API', () => { expect(res.body.data.editUser).toEqual( { username: 'fakeusername', - type: userTypes.ADMIN, + type: enumUserTypes.ADMIN, firstname: 'FMan', lastname: 'LMan', organisation: 'DSI-ICL', @@ -1330,7 +1335,7 @@ describe('USERS API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} } ); }); @@ -1339,7 +1344,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_4444', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FMing Man San', lastname: 'LMing Man San', password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1350,10 +1355,15 @@ describe('USERS API', () => { emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: 'fakeid44444', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const createdUser = request.agent(app); @@ -1380,7 +1390,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_4', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FMing Man', lastname: 'LMing Man', password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1394,7 +1404,13 @@ describe('USERS API', () => { deleted: null, id: 'fakeid4', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const createdUser = request.agent(app); @@ -1415,7 +1431,7 @@ describe('USERS API', () => { expect(res.body.errors).toBeUndefined(); expect(res.body.data.editUser).toEqual({ username: 'new_user_4', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FMing Man', lastname: 'LMing Man', organisation: 'organisation_system', @@ -1431,7 +1447,7 @@ describe('USERS API', () => { emailNotificationsStatus: { expiringNotification: false }, createdAt: 1591134065000, expiredAt: 1991134065000, - metadata: null + metadata: {} }); const modifieduser = await mongoClient.collection(config.database.collections.users_collection).findOne({ username: 'new_user_4' }); expect(modifieduser.password).not.toBe(newUser.password); @@ -1442,7 +1458,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_5', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FMing Man Chon', lastname: 'LMing Man Chon', password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1456,7 +1472,13 @@ describe('USERS API', () => { deleted: null, id: 'fakeid5', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const createdUser = request.agent(app); @@ -1486,7 +1508,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_6', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FMing Man', lastname: 'LMing Man', password: '$2b$04$j0aSK.Dyq7Q9N.r6d0uIaOGrOe7sI4rGUn0JNcaXcPCv.49Otjwpi', @@ -1500,7 +1522,13 @@ describe('USERS API', () => { deleted: null, id: 'fakeid6', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); const createdUser = request.agent(app); @@ -1526,7 +1554,7 @@ describe('USERS API', () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_7', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FMing Man Tai', lastname: 'LMing Man Tai', password: 'fakepassword', @@ -1540,7 +1568,13 @@ describe('USERS API', () => { deleted: null, id: 'fakeid7', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1564,7 +1598,7 @@ describe('USERS API', () => { /* setup: create a new user to be deleted */ const newUser: IUser = { username: 'new_user_8', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FChan Mei', lastname: 'LChan Mei', password: 'fakepassword', @@ -1578,7 +1612,13 @@ describe('USERS API', () => { deleted: null, id: 'fakeid8', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1591,7 +1631,7 @@ describe('USERS API', () => { expect(getUserRes.body.data.getUsers).toEqual([{ firstname: 'FChan Mei', lastname: 'LChan Mei', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, organisation: 'organisation_system', id: newUser.id }]); @@ -1624,7 +1664,7 @@ describe('USERS API', () => { /* setup: create a "deleted" new user to be deleted */ const newUser: IUser = { username: 'new_user_9', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FChan Mei Fong', lastname: 'LChan Mei Fong', password: 'fakepassword', @@ -1638,7 +1678,13 @@ describe('USERS API', () => { deleted: (new Date()).valueOf(), id: 'fakeid9', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1680,7 +1726,7 @@ describe('USERS API', () => { /* setup: create a new user to be deleted */ const newUser: IUser = { username: 'new_user_10', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, firstname: 'FChan Mei Yi', lastname: 'LChan Mei Yi', password: 'fakepassword', @@ -1694,7 +1740,13 @@ describe('USERS API', () => { deleted: null, id: 'fakeid10', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + } }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); @@ -1707,7 +1759,7 @@ describe('USERS API', () => { expect(getUserRes.body.data.getUsers).toEqual([{ firstname: 'FChan Mei Yi', lastname: 'LChan Mei Yi', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, organisation: 'organisation_system', id: newUser.id }]); @@ -1734,7 +1786,7 @@ describe('USERS API', () => { expect(getUserResAfter.body.data.getUsers).toEqual([{ firstname: 'FChan Mei Yi', lastname: 'LChan Mei Yi', - type: userTypes.STANDARD, + type: enumUserTypes.STANDARD, organisation: 'organisation_system', id: newUser.id }]); diff --git a/packages/itmat-interface/test/trpcTests/_loginHelper.ts b/packages/itmat-interface/test/trpcTests/_loginHelper.ts new file mode 100644 index 000000000..42ce22cbf --- /dev/null +++ b/packages/itmat-interface/test/trpcTests/_loginHelper.ts @@ -0,0 +1,42 @@ +import { generateTOTP } from '@itmat-broker/itmat-cores'; +import { SuperTest, Test } from 'supertest'; + +export async function connectAdmin(agent: SuperTest) { + const adminSecret = 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA'; + return connectAgent(agent, 'admin', 'admin', adminSecret); +} + +export async function connectUser(agent: SuperTest) { + const userSecret = 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA'; + return connectAgent(agent, 'standardUser', 'admin', userSecret); +} + +export async function connectAgent(agent: SuperTest, user: string, pw: string, secret: string): Promise { + const otp = generateTOTP(secret).toString(); + try { + const res = await agent.post('/trpc/user.login') + .set('Content-type', 'application/json') + .send({ + username: user, + password: pw, + totp: otp + }); + if (res.status !== 200) { + throw new Error('Login failed'); + } + } catch (err) { + throw new Error('Login request failed'); + } +} + +export async function disconnectAgent(agent: SuperTest): Promise { + try { + const res = await agent.post('/trpc/user.logout'); + if (res.status !== 200) { + throw new Error('Logout failed'); + } + } catch (err) { + throw new Error('Logout request failed'); + } +} + diff --git a/packages/itmat-interface/test/trpcTests/helper.ts b/packages/itmat-interface/test/trpcTests/helper.ts new file mode 100644 index 000000000..3e8af6297 --- /dev/null +++ b/packages/itmat-interface/test/trpcTests/helper.ts @@ -0,0 +1,3 @@ +export function encodeQueryParams(s: unknown): string { + return encodeURIComponent(JSON.stringify(s)); +} diff --git a/packages/itmat-interface/test/trpcTests/user.test.ts b/packages/itmat-interface/test/trpcTests/user.test.ts new file mode 100644 index 000000000..6fb6732e9 --- /dev/null +++ b/packages/itmat-interface/test/trpcTests/user.test.ts @@ -0,0 +1,476 @@ +/** + * @with Minio + */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { db } from '../../src/database/database'; +import { Express } from 'express'; +import { objStore } from '../../src/objStore/objStore'; +import request from 'supertest'; +import { connectAdmin, connectUser } from './_loginHelper'; +import { Router } from '../../src/server/router'; +import { Db, MongoClient } from 'mongodb'; +import { setupDatabase } from '@itmat-broker/itmat-setup'; +import config from '../../config/config.sample.json'; +import { v4 as uuid } from 'uuid'; +import { IOrganisation, enumUserTypes } from '@itmat-broker/itmat-types'; +import { encodeQueryParams } from './helper'; +import { errorCodes } from '@itmat-broker/itmat-cores'; + +if (global.hasMinio) { + let app: Express; + let mongodb: MongoMemoryServer; + let admin: request.SuperTest; + let user: request.SuperTest; + let mongoConnection: MongoClient; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let mongoClient: Db; + let adminProfile; + let userProfile; + let organisation: IOrganisation; + + afterAll(async () => { + await db.closeConnection(); + await mongoConnection?.close(); + await mongodb.stop(); + + /* claer all mocks */ + jest.clearAllMocks(); + }); + + beforeAll(async () => { // eslint-disable-line no-undef + /* Creating a in-memory MongoDB instance for testing */ + const dbName = uuid(); + mongodb = await MongoMemoryServer.create({ instance: { dbName } }); + const connectionString = mongodb.getUri(); + await setupDatabase(connectionString, dbName); + /* Wiring up the backend server */ + config.objectStore.port = global.minioContainerPort; + config.database.mongo_url = connectionString; + config.database.database = dbName; + await db.connect(config.database, MongoClient); + await objStore.connect(config.objectStore); + const router = new Router(config); + await router.init(); + /* Connect mongo client (for test setup later / retrieve info later) */ + mongoConnection = await MongoClient.connect(connectionString); + mongoClient = mongoConnection.db(dbName); + + /* Connecting clients for testing later */ + app = router.getApp(); + admin = request.agent(app); + user = request.agent(app); + await connectAdmin(admin); + await connectUser(user); + + // add the root node for each user + const users = await db.collections.users_collection.find({}).toArray(); + adminProfile = users.filter(el => el.type === enumUserTypes.ADMIN)[0]; + userProfile = users.filter(el => el.type === enumUserTypes.STANDARD)[0]; + + // add an organisation + organisation = { + id: uuid(), + name: 'My Org', + shortname: '', + location: [], + profile: null, + metadata: { + }, + life: { + createdTime: 0, + createdUser: 'SYSTEMAGENT', + deletedUser: null, + deletedTime: null + } + }; + await db.collections.organisations_collection.insertOne(organisation); + }); + + afterEach(async () => { + await db.collections.studies_collection.deleteMany({}); + await db.collections.files_collection.deleteMany({}); + await db.collections.roles_collection.deleteMany({}); + await db.collections.field_dictionary_collection.deleteMany({}); + await db.collections.data_collection.deleteMany({}); + await db.collections.jobs_collection.deleteMany({}); + delete userProfile._id; + await db.collections.users_collection.findOneAndUpdate({ id: userProfile.id }, { + $set: userProfile + }); + }); + + describe('tRPC User APIs', () => { + test('Create a user', async () => { + if (process.env.SKIP_EMAIL_TEST) { + return; + } + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'test', + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test.com', + password: 'test_password', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(200); + expect(response.body.result.data.username).toBe('test'); + const user = await db.collections.users_collection.findOne({ username: 'test' }); + expect(user).toBeDefined(); + expect(userProfile.id).toBe(response.body.result.data.id); + }); + test('Create a user (invalid email format)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'test', + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test', + password: 'test_password', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Email is not the right format.'); + }); + test('Create a user (invalid password format)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'test', + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test.com', + password: 'test', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Password has to be at least 8 character long.'); + }); + test('Create a user (username have spaces)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'te st', + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test.com', + password: 'test_password', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Username or password cannot have spaces.'); + }); + test('Create a user (password have spaces)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'test', + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test.com', + password: 'test_pa ssword', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Username or password cannot have spaces.'); + }); + test('Create a user (username already registered)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: userProfile.username, + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test.com', + password: 'test_password', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Username or email already exists.'); + }); + test('Create a user (email already registered)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'test', + firstname: 'firstname', + lastname: 'lastname', + email: userProfile.email, + password: 'test_password', + description: '', + organisation: organisation.id + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Username or email already exists.'); + }); + test('Create a user (organisation does not exist)', async () => { + const response = await admin.post('/trpc/user.createUser') + .send({ + username: 'test', + firstname: 'firstname', + lastname: 'lastname', + email: 'test@test.com', + password: 'test_password', + description: '', + organisation: 'random' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Organisation does not exist.'); + }); + test('Edit a user (admin)', async () => { + const response = await admin.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description', + type: enumUserTypes.MANAGER + }); + expect(response.status).toBe(200); + expect(response.body.result.data.username).toBe('edit_username'); + const editedUser = await db.collections.users_collection.findOne({ id: userProfile.id }); + expect(editedUser?.username).toBe('edit_username'); + }); + test('Edit a user (user)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description' + }); + expect(response.status).toBe(200); + expect(response.body.result.data.username).toBe('edit_username'); + const editedUser = await db.collections.users_collection.findOne({ id: userProfile.id }); + expect(editedUser?.username).toBe('edit_username'); + }); + test('Edit a user (user edit others accounts)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: adminProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('User can only edit his/her own account.'); + }); + test('Edit a user (user edit unpermitted fields)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description', + type: enumUserTypes.ADMIN + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Standard user can not change their type, expiration time and organisation. Please contact admins for help.'); + }); + test('Edit a user (password not long enough)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_', + description: 'edit_description' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Password has to be at least 8 character long.'); + }); + test('Edit a user (email not right format)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@', + password: 'edit_password', + description: 'edit_description' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Email is not the right format.'); + }); + test('Edit a user (username already used)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: adminProfile.username, + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Username already used.'); + }); + test('Edit a user (email already used)', async () => { + const response = await user.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: adminProfile.email, + password: 'edit_password', + description: 'edit_description' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Email already used.'); + }); + test('Edit a user (organisation does not exist)', async () => { + const response = await admin.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description', + organisation: 'random' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Organisation does not exist.'); + }); + test('Edit a user (old password)', async () => { + await admin.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description' + }); + const response = await admin.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + password: 'edit_password' + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('You need to select a new password.'); + }); + test('Edit a user (expired time is outdated)', async () => { + const response = await admin.post('/trpc/user.editUser') + .send({ + userId: userProfile.id, + username: 'edit_username', + firstname: 'edit_firstname', + lastname: 'edit_lastname', + email: 'edit_email@test.com', + password: 'edit_password', + description: 'edit_description', + expiredAt: Date.now() - 1000 + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Cannot set to a previous time.'); + }); + test('Get a user by admin (through userId)', async () => { + const paramteres = { + userId: userProfile.id + }; + const response = await admin.get('/trpc/user.getUser?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(200); + expect(response.body.result.data).toBeDefined(); + expect(response.body.result.data.username).toBe(userProfile.username); + }); + test('Get a user by user (through userId)', async () => { + const paramteres = { + userId: adminProfile.id + }; + const response = await user.get('/trpc/user.getUser?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(errorCodes.NO_PERMISSION_ERROR); + }); + test('Get a user (through username)', async () => { + const paramteres = { + username: userProfile.username + }; + const response = await user.get('/trpc/user.getUser?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(200); + expect(response.body.result.data).toBeDefined(); + expect(response.body.result.data.username).toBe(userProfile.username); + }); + test('Get a user (through email)', async () => { + const paramteres = { + email: userProfile.email + }; + const response = await user.get('/trpc/user.getUser?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(200); + expect(response.body.result.data).toBeDefined(); + expect(response.body.result.data.username).toBe(userProfile.username); + }); + test('Get a user by user (user does not exist)', async () => { + const paramteres = { + email: 'random' + }; + const response = await user.get('/trpc/user.getUser?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(errorCodes.NO_PERMISSION_ERROR); + }); + test('Get a user by admin (user does not exist)', async () => { + const paramteres = { + email: 'random' + }; + const response = await admin.get('/trpc/user.getUser?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('User does not exist.'); + }); + test('Get all users (admin)', async () => { + const paramteres = { + }; + const response = await admin.get('/trpc/user.getUsers?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(200); + expect(response.body.result.data).toHaveLength(2); + }); + test('Get all users (user)', async () => { + const paramteres = { + }; + const response = await user.get('/trpc/user.getUsers?input=' + encodeQueryParams(paramteres)) + .query({ + }); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(errorCodes.NO_PERMISSION_ERROR); + }); + }); +} else { + test(`${__filename.split(/[\\/]/).pop()} skipped because it requires Minio on Docker`, () => { + expect(true).toBe(true); + }); +} \ No newline at end of file diff --git a/packages/itmat-job-executor/src/database/database.ts b/packages/itmat-job-executor/src/database/database.ts index 65fbbd5f0..40df0458d 100644 --- a/packages/itmat-job-executor/src/database/database.ts +++ b/packages/itmat-job-executor/src/database/database.ts @@ -1,4 +1,4 @@ -import type { IFile, IJobEntry, IProject, IQueryEntry, IDataEntry, IFieldEntry } from '@itmat-broker/itmat-types'; +import type { IFile, IJobEntry, IProject, IQueryEntry, IDataEntry, IField } from '@itmat-broker/itmat-types'; import { Database as DatabaseBase, IDatabaseBaseConfig } from '@itmat-broker/itmat-commons'; import type { Collection } from 'mongodb'; @@ -15,7 +15,7 @@ export interface IDatabaseConfig extends IDatabaseBaseConfig { export interface IDatabaseCollectionConfig { jobs_collection: Collection, - field_dictionary_collection: Collection, + field_dictionary_collection: Collection, files_collection: Collection, data_collection: Collection, queries_collection: Collection, diff --git a/packages/itmat-models/src/graphql/fields.ts b/packages/itmat-models/src/graphql/fields.ts index 1285ca626..374a0255f 100644 --- a/packages/itmat-models/src/graphql/fields.ts +++ b/packages/itmat-models/src/graphql/fields.ts @@ -14,7 +14,6 @@ export const FIELD_FRAGMENT = gql` code description } - metadata unit comments dateAdded diff --git a/packages/itmat-models/src/graphql/study.ts b/packages/itmat-models/src/graphql/study.ts index 160b03820..c01b9760b 100644 --- a/packages/itmat-models/src/graphql/study.ts +++ b/packages/itmat-models/src/graphql/study.ts @@ -120,7 +120,6 @@ export const EDIT_STUDY = gql` id name description - type } } `; diff --git a/packages/itmat-models/src/graphql/user.ts b/packages/itmat-models/src/graphql/user.ts index 7f8c023c4..4ea89c096 100644 --- a/packages/itmat-models/src/graphql/user.ts +++ b/packages/itmat-models/src/graphql/user.ts @@ -22,7 +22,6 @@ export const USER_FRAGMENT = gql` studies { id name - type } }, metadata, diff --git a/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts b/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts index eba685d33..3cbb02e41 100644 --- a/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts +++ b/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts @@ -13,15 +13,15 @@ const collections = { name: 'USER_COLLECTION', indexes: [ { key: { id: 1 }, unique: true }, - { key: { username: 1, deleted: 1 }, unique: true }, - { key: { email: 1, deleted: 1 }, unique: true } + { key: { 'username': 1, 'life.deletedTime': 1 }, unique: true }, + { key: { 'email': 1, 'life.deletedTime': 1 }, unique: true } ] }, studies_collection: { name: 'STUDY_COLLECTION', indexes: [ { key: { id: 1 }, unique: true }, - { key: { name: 1, deleted: 1 }, unique: true } + { key: { 'name': 1, 'life.deletedTime': 1 }, unique: true } ] }, projects_collection: { @@ -47,7 +47,7 @@ const collections = { name: 'DATA_COLLECTION', indexes: [ { key: { id: 1 }, unique: true }, - { key: { m_studyId: 1, m_versionId: 1, m_subjectId: 1, m_visitId: 1, m_fieldId: 1, uploadedAt: -1 }, unique: true } + { key: { 'studyId': 1, 'fieldId': 1, 'life.createdTime': 1, 'life.deletedTime': 1, 'properties': 1 }, unique: true } ] }, roles_collection: { @@ -94,6 +94,13 @@ const collections = { indexes: [ { key: { id: 1 }, unique: true } ] + }, + configs_collection: { + name: 'CONFIG_COLLECTION', + indexes: [ + { key: { id: 1 }, unique: true }, + { key: { type: 1, key: 1 }, unique: true } + ] } }; diff --git a/packages/itmat-setup/src/databaseSetup/seed/organisations.ts b/packages/itmat-setup/src/databaseSetup/seed/organisations.ts index 71562762c..2a103a83d 100644 --- a/packages/itmat-setup/src/databaseSetup/seed/organisations.ts +++ b/packages/itmat-setup/src/databaseSetup/seed/organisations.ts @@ -2,15 +2,26 @@ export const seedOrganisations = [{ id: 'organisation_system', name: 'System', shortname: 'Sys', - containOrg: '', - deleted: null + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: { + siteIDMarker: 'I' + } }, { id: 'organisation_user', name: 'user', shortname: 'Use', - containOrg: '', - metadata: { - siteIDMarker: 'I' + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null }, - deleted: null + metadata: { + siteIDMarker: 'Z' + } }]; diff --git a/packages/itmat-setup/src/databaseSetup/seed/users.ts b/packages/itmat-setup/src/databaseSetup/seed/users.ts index 25f7d5b13..f3f1286fa 100644 --- a/packages/itmat-setup/src/databaseSetup/seed/users.ts +++ b/packages/itmat-setup/src/databaseSetup/seed/users.ts @@ -11,10 +11,15 @@ export const seedUsers = [{ description: 'I am an admin user.', emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, - deleted: null, id: 'replaced_at_runtime2', - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }, { username: 'standardUser', @@ -29,8 +34,14 @@ export const seedUsers = [{ emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, organisation: 'organisation_system', - deleted: null, id: 'replaced_at_runtime1', createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }]; diff --git a/packages/itmat-types/src/types/base.ts b/packages/itmat-types/src/types/base.ts new file mode 100644 index 000000000..8351692f9 --- /dev/null +++ b/packages/itmat-types/src/types/base.ts @@ -0,0 +1,12 @@ +export interface ILifeCircle { + createdTime: number; + createdUser: string; + deletedTime: number | null; + deletedUser: string | null; +} + +export interface IBase { + id: string; + life: ILifeCircle; + metadata: Record; +} \ No newline at end of file diff --git a/packages/itmat-types/src/types/config.ts b/packages/itmat-types/src/types/config.ts new file mode 100644 index 000000000..e7919869f --- /dev/null +++ b/packages/itmat-types/src/types/config.ts @@ -0,0 +1,212 @@ +import { IBase, ILifeCircle } from './base'; +import { v4 as uuid } from 'uuid'; +import { enumReservedUsers } from './user'; + +export interface IConfig extends IBase { + type: enumConfigType; + key: string | null; // studyid for study; userid for user; null for system + properties: ISystemConfig | IStudyConfig | IUserConfig | IDocConfig | ICacheConfig | IDomainConfig; +} + +export enum enumConfigType { + SYSTEMCONFIG = 'SYSTEMCONFIG', + STUDYCONFIG = 'STUDYCONFIG', + USERCONFIG = 'USERCONFIG', + FILECONFIG = 'FILECONFIG', + ORGANISATIONCONFIG = 'ORGANISATIONCONFIG', + DOCCONFIG = 'DOCCONFIG', + CACHECONFIG = 'CACHECONFIG', + DOMAINCONFIG = 'DOMAINCONFIG' +} + +export interface ISystemConfig extends IBase { + defaultBackgroundColor: string; // hex code + defaultMaximumFileSize: number; + defaultFileBucketId: string; + defaultProfileBucketId: string; + logoLink: string | null; // TODO: fetch file from database; + logoSize: string[]; // width * height + archiveAddress: string; + defaultEventTimeConsumptionBar: number[]; + defaultUserExpireDays: number; +} + +export interface IStudyConfig extends IBase { + defaultStudyProfile: string | null; + defaultMaximumFileSize: number; + defaultRepresentationForMissingValue: string; + defaultFileColumns: Array<{ title: string, type: string }>; + defaultFileColumnsPropertyColor: string | null; + defaultFileDirectoryStructure: { + pathLabels: string[], + description: string | null + }, + defaultVersioningKeys: string[]; // data clips with same values of such keys will be considered as the same values with different versions +} + +export interface IUserConfig extends IBase { + defaultUserExpiredDays: number + defaultMaximumFileSize: number; + defaultMaximumFileRepoSize: number; + defaultMaximumRepoSize: number; + defaultFileBucketId: string; + defaultMaximumQPS: number; + + // LXD containers + defaultLXDMaximumContainers: number; + defaultLXDMaximumContainerCPUCores: number; + defaultLXDMaximumContainerDiskSize: number; + defaultLXDMaximumContainerMemory: number; + defaultLXDMaximumContainerLife: number; +} + +export interface IOrganisationConfig extends IBase { + defaultFileBucketId: string; +} + +export interface ICacheConfig extends IBase { + defaultFileBucketId: string; + defaultMaximumFileSize: number; +} + +export interface IDocConfig extends IBase { + defaultFileBucketId: string; + defaultMaximumFileSize: number; +} + +export interface IDomainConfig extends IBase { + defaultFileBucketId: string +} +export interface IDefaultSettings extends IBase { + systemConfig: ISystemConfig; + studyConfig: IStudyConfig; + userConfig: IUserConfig; + docConfig: IDocConfig; +} + +// default settings +export class DefaultSettings implements IDefaultSettings { + public readonly id: string = uuid(); + public readonly life: ILifeCircle = { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }; + public readonly metadata: Record = {}; + + public readonly systemConfig: ISystemConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultBackgroundColor: '#FFFFFF', + defaultMaximumFileSize: 1 * 1024 * 1024 * 1024, // 1 GB + defaultFileBucketId: 'system', + defaultProfileBucketId: 'profile', + logoLink: null, + logoSize: ['24px', '24px'], + archiveAddress: '', + defaultEventTimeConsumptionBar: [50, 100], + defaultUserExpireDays: 90 + }; + + public readonly studyConfig: IStudyConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultStudyProfile: null, + defaultMaximumFileSize: 8 * 1024 * 1024 * 1024, // 8 GB, + defaultRepresentationForMissingValue: '99999', + defaultFileColumns: [], + defaultFileColumnsPropertyColor: 'black', + defaultFileDirectoryStructure: { + pathLabels: [], + description: null + }, + defaultVersioningKeys: [] + }; + + public readonly userConfig: IUserConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultUserExpiredDays: 90, + defaultMaximumFileSize: 100 * 1024 * 1024, // 100 MB + defaultMaximumFileRepoSize: 500 * 1024 * 1024, // 500 MB + defaultMaximumRepoSize: 10 * 1024 * 1024 * 1024, // 10GB + defaultFileBucketId: 'user', + defaultMaximumQPS: 500, + defaultLXDMaximumContainers: 2, + defaultLXDMaximumContainerCPUCores: 2, + defaultLXDMaximumContainerDiskSize: 50 * 1024 * 1024 * 1024, + defaultLXDMaximumContainerMemory: 8 * 1024 * 1024 * 1024, + defaultLXDMaximumContainerLife: 8 * 60 * 60 + }; + + public readonly docConfig: IDocConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultFileBucketId: 'doc', + defaultMaximumFileSize: 100 * 1024 * 1024 // 100 MB + }; + + public readonly organisationConfig: IOrganisationConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultFileBucketId: 'organisation' + }; + + public readonly cacheConfig: ICacheConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultFileBucketId: 'cache', + defaultMaximumFileSize: 100 * 1024 * 1024 // 100 MB + }; + + public readonly domainConfig: IDomainConfig = { + id: uuid(), + life: { + createdTime: Date.now(), + createdUser: enumReservedUsers.SYSTEM, + deletedTime: null, + deletedUser: null + }, + metadata: {}, + defaultFileBucketId: 'domain' + }; +} + +export const defaultSettings = Object.freeze(new DefaultSettings()); diff --git a/packages/itmat-types/src/types/coreErrors.ts b/packages/itmat-types/src/types/coreErrors.ts new file mode 100644 index 000000000..b08c552ce --- /dev/null +++ b/packages/itmat-types/src/types/coreErrors.ts @@ -0,0 +1,42 @@ +import { TRPCError } from '@trpc/server'; +import { TRPC_ERROR_CODE_KEY } from '@trpc/server/dist/rpc'; +export enum enumCoreErrors { + DATABASE_ERROR = 'DATABASE_ERROR', + NOT_LOGGED_IN = 'NOT_LOGGED_IN', + CLIENT_MALFORMED_INPUT = 'CLIENT_MALFORMED_INPUT', + CLIENT_ACTION_ON_NON_EXISTENT_ENTRY = 'CLIENT_ACTION_ON_NON_EXISTENT_ENTRY', + AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', + NO_PERMISSION_ERROR = 'NO_PERMISSION_ERROR', + FILE_STREAM_ERROR = 'FILE_STREAM_ERROR', + OBJ_STORE_ERROR = 'OBJ_STORE_ERROR', + UNQUALIFIED_ERROR = 'UNQUALIFIED_ERROR' +} + +export enum enumRequestErrorCodes { + BAD_REQUEST = 'BAD_REQUEST', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + TIMEOUT = 'TIMEOUT', + CONFLICT = 'CONFLICT', + PRECONDITION_FAILED = 'PRECONDITION_FAILED', + PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE', + METHOD_NOT_SUPPORTED = 'METHOD_NOT_SUPPORTED', + UNPROCESSABLE_CONTENT = 'UNPROCESSABLE_CONTENT', + TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', + CLIENT_CLOSED_REQUEST = 'CLIENT_CLOSED_REQUEST', + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR' +} + +/** + * This is to keep the consitency of the GraphQL error codes and tRPC error codes. + * If possible, merge it in future. + */ +export class CoreError extends TRPCError { + errorCode: enumCoreErrors; + constructor(errorCode: enumCoreErrors, message: string, httpErrorCode: TRPC_ERROR_CODE_KEY = 'BAD_REQUEST') { + super({ message: message, code: httpErrorCode }); + this.errorCode = errorCode; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/itmat-types/src/types/data.ts b/packages/itmat-types/src/types/data.ts index 1e0ed7127..8acbce300 100644 --- a/packages/itmat-types/src/types/data.ts +++ b/packages/itmat-types/src/types/data.ts @@ -1,3 +1,5 @@ +import { IBase } from './base'; + export interface IDataEntry { id: string; m_studyId: string; @@ -15,6 +17,14 @@ export interface IDataEntry { uploadedAt: number; } +export interface IData extends IBase { + studyId: string; + fieldId: string; + dataVersion: string | null; + value: unknown; + properties: Record; +} + export interface IGroupedData { [key: string]: { [key: string]: { diff --git a/packages/itmat-types/src/types/field.ts b/packages/itmat-types/src/types/field.ts index 5e4733816..252c5e163 100644 --- a/packages/itmat-types/src/types/field.ts +++ b/packages/itmat-types/src/types/field.ts @@ -1,37 +1,39 @@ -export interface IFieldEntry { - id: string; +import { IBase } from './base'; +import { IValueVerifier } from './utils'; + +export interface IField extends IBase { studyId: string; - fieldId: string; fieldName: string; - tableName?: string; - dataType: enumValueType; - possibleValues?: IValueDescription[] | null; + fieldId: string; + description?: string; + dataType: enumDataTypes; + categoricalOptions?: ICategoricalOption[]; unit?: string; comments?: string; - metadata?: Record; - dateAdded: number; - dateDeleted: number | null; dataVersion: string | null; + verifier?: IValueVerifier[][]; + properties?: IFieldProperty[]; // mostly used for file data } -export interface IValueDescription { - id: string; - code: string; - description: string +export interface IFieldProperty { + name: string; + verifier: IValueVerifier[][] | null; + description: string | null; + required: boolean; } -export enum enumItemType { - IMAGE = 'I', - CLINICAL = 'C' +export enum enumDataTypes { + INTEGER = 'INTEGER', + DECIMAL = 'DECIMAL', + STRING = 'STRING', + BOOLEAN = 'BOOLEAN', + DATETIME = 'DATETIME', + FILE = 'FILE', + JSON = 'JSON', + CATEGORICAL = 'CATEGORICAL' } -export enum enumValueType { - INTEGER = 'int', - DECIMAL = 'dec', - STRING = 'str', - BOOLEAN = 'bool', - DATETIME = 'date', - FILE = 'file', - JSON = 'json', - CATEGORICAL = 'cat' +export interface ICategoricalOption extends IBase { + code: string; + description: string; } diff --git a/packages/itmat-types/src/types/file.ts b/packages/itmat-types/src/types/file.ts index fad6ac3eb..cc18292c3 100644 --- a/packages/itmat-types/src/types/file.ts +++ b/packages/itmat-types/src/types/file.ts @@ -1,14 +1,53 @@ -export interface IFile { - id: string; +import { Readable } from 'stream'; +import { IBase } from './base'; + +export interface IFile extends IBase { + studyId: string | null; // null for system and user file + userId: string | null; // null for system and study file fileName: string; - studyId: string; - projectId?: string; - fileSize?: string; - description: string; - uploadTime: string; - uploadedBy: string; // userId + fileSize: number; + description?: string; + properties: Record; uri: string; - deleted: number | null; hash: string; - metadata: Record + fileType: enumFileTypes; + fileCategory: enumFileCategories; + sharedUsers?: string[]; } + + + +export enum enumFileTypes { + CSV = 'CSV', + ZIP = 'ZIP', + RAR = 'RAR', + UNKNOWN = 'UNKNOWN', + MARKDOWN = 'MARKDOWN', + TXT = 'TXT', + JSON = 'JSON', + PDF = 'PDF', + DOCX = 'DOCX', + // images + JPG = 'JPG', + JPEG = 'JPEG', + PNG = 'PNG', + WEBP = 'WEBP', + // videos + MP4 = 'MP4', + AVI = 'AVI' +} + +export enum enumFileCategories { + STUDY_DATA_FILE = 'STUDY_DATA_FILE', + USER_DRIVE_FILE = 'USER_REPO_FILE', + DOC_FILE = 'DOC_FILE', + CACHE = 'CACHE', + PROFILE_FILE = 'DOMAIN_PROFILE_FILE' +} + +export interface FileUpload { + createReadStream: () => Readable; + filename: string; + mimetype: string; + encoding: string; +} \ No newline at end of file diff --git a/packages/itmat-types/src/types/index.ts b/packages/itmat-types/src/types/index.ts index 8319c3fd8..1c5e1dd1e 100644 --- a/packages/itmat-types/src/types/index.ts +++ b/packages/itmat-types/src/types/index.ts @@ -10,6 +10,11 @@ import * as Pubkey from './pubkey'; import * as Data from './data'; import * as Standardization from './standardization'; import * as Common from './common'; +import * as Base from './base'; +import * as CoreErrors from './coreErrors'; +import * as Config from './config'; +import * as ZodSchema from './zod'; +import * as Utils from './utils'; export * from './field'; export * from './file'; @@ -23,4 +28,10 @@ export * from './pubkey'; export * from './data'; export * from './standardization'; export * from './common'; -export const Types = { File, Job, Log, User, Organisation, Pubkey, Study, Query, Field, Data, Standardization, Common }; +export * from './base'; +export * from './coreErrors'; +export * from './config'; +export * from './zod'; +export * from './utils'; + +export const Types = { File, Job, Log, User, Organisation, Pubkey, Study, Query, Field, Data, Standardization, Common, Base, CoreErrors, Config, ZodSchema, Utils }; diff --git a/packages/itmat-types/src/types/log.ts b/packages/itmat-types/src/types/log.ts index 74e5ea3a8..04949267b 100644 --- a/packages/itmat-types/src/types/log.ts +++ b/packages/itmat-types/src/types/log.ts @@ -1,9 +1,9 @@ -import { userTypes } from './user'; +import { enumUserTypes } from './user'; export interface ILogEntry { id: string, requesterName: string, - requesterType: userTypes, + requesterType: enumUserTypes, userAgent: USER_AGENT, logType: LOG_TYPE, actionType: LOG_ACTION, diff --git a/packages/itmat-types/src/types/organisation.ts b/packages/itmat-types/src/types/organisation.ts index a89d63c11..c2f833abe 100644 --- a/packages/itmat-types/src/types/organisation.ts +++ b/packages/itmat-types/src/types/organisation.ts @@ -1,12 +1,7 @@ -export type OrganisationMetadata = { - siteIDMarker?: string; -} +import { IBase } from './base'; -export interface IOrganisation { - id: string; +export interface IOrganisation extends IBase { name: string; - shortname: string | null; - containOrg: string | null; - deleted: number | null; - metadata: OrganisationMetadata; -} + shortname?: string; + profile?: string; +} \ No newline at end of file diff --git a/packages/itmat-types/src/types/pubkey.ts b/packages/itmat-types/src/types/pubkey.ts index 77f6bf1ad..2d44adecc 100644 --- a/packages/itmat-types/src/types/pubkey.ts +++ b/packages/itmat-types/src/types/pubkey.ts @@ -1,11 +1,11 @@ -export interface IPubkey { - id: string; +import { IBase } from './base'; + +export interface IPubkey extends IBase { pubkey: string; jwtPubkey: string; jwtSeckey: string; refreshCounter: number; associatedUserId: string | null; - deleted: number | null; } export type AccessToken = { diff --git a/packages/itmat-types/src/types/study.ts b/packages/itmat-types/src/types/study.ts index d6fda4611..b0fc0f3a2 100644 --- a/packages/itmat-types/src/types/study.ts +++ b/packages/itmat-types/src/types/study.ts @@ -1,3 +1,4 @@ +import { IBase } from './base'; import { IUser } from './user'; import { FileUpload } from 'graphql-upload-minimal'; @@ -7,26 +8,19 @@ export enum studyType { ANY = 'ANY' } -export interface IStudy { - id: string; +export interface IStudy extends IBase { name: string; - createdBy: string; - lastModified: number; - deleted: number | null; currentDataVersion: number; // index; dataVersions[currentDataVersion] gives current version; // -1 if no data dataVersions: IStudyDataVersion[]; - description: string; + description?: string; + profile?: string; ontologyTrees?: IOntologyTree[]; - type: studyType; - metadata: Record } -export interface IStudyDataVersion { - id: string; // uuid - contentId: string; // same contentId = same data +export interface IStudyDataVersion extends IBase { + id: string; version: string; tag?: string; - updateDate: string; } export enum atomicOperation { diff --git a/packages/itmat-types/src/types/user.ts b/packages/itmat-types/src/types/user.ts index 2812c71d0..7832b224e 100644 --- a/packages/itmat-types/src/types/user.ts +++ b/packages/itmat-types/src/types/user.ts @@ -1,28 +1,31 @@ -export enum userTypes { +import { IBase } from './base'; + +export enum enumUserTypes { ADMIN = 'ADMIN', STANDARD = 'STANDARD', + SYSTEM = 'SYSTEM', + OBSERVER = 'OBSERVER' +} + +export enum enumReservedUsers { SYSTEM = 'SYSTEM' } -export interface IUserWithoutToken { - id: string; +export interface IUserWithoutToken extends IBase { username: string; email: string; firstname: string; lastname: string; organisation: string; - type: userTypes; - description: string; + type: enumUserTypes; emailNotificationsActivated: boolean; - emailNotificationsStatus: { expiringNotification: boolean } | null; - deleted: number | null; - createdAt: number; - expiredAt: number; - resetPasswordRequests: IResetPasswordRequest[]; - metadata?: { - logPermission: boolean; - [key: string]: unknown + emailNotificationsStatus: { + expiringNotification: boolean }; + resetPasswordRequests: IResetPasswordRequest[]; + profile?: string; + expiredAt: number; + description?: string; } export interface IResetPasswordRequest { diff --git a/packages/itmat-types/src/types/utils.ts b/packages/itmat-types/src/types/utils.ts new file mode 100644 index 000000000..0c033ab42 --- /dev/null +++ b/packages/itmat-types/src/types/utils.ts @@ -0,0 +1,47 @@ +// Value verifier +export interface IValueVerifier { + formula: IAST; + condition: enumConditionOps; + value: string | number; + parameters: Record; +} + +export enum enumConditionOps { + NUMERICALEQUAL = 'numerical:=', + NUMERICALNOTEQUAL = 'numerical:!=', + NUMERICALLESSTHAN = 'numerical:<', + NUMERICALGREATERTHAN = 'numerical:>', + NUMERICALNOTLESSTHAN = 'numerical:>=', + NUMERICALNOTGREATERTHAN = 'numerical:<=', + STRINGREGEXMATCH = 'string:=regex=', + STRINGEQUAL = 'string:=', + GENERALISNULL = 'general:=null', + GENERALISNOTNULL = 'general:!=null' +} + +export interface IAST { + type: enumASTNodeTypes; + operator: enumMathOps | null, + value: string | number | null; + parameters: Record; + children: IAST[] | null; // null for lead node; OPERATION type should not be a lead node. +} + +export enum enumASTNodeTypes { + OPERATION = 'OPERATION', + VARIABLE = 'VARIABLE', + SELF = 'SELF', // the input value + VALUE = 'VALUE', + MAP = 'MAP' +} + +export enum enumMathOps { + NUMERICALADD = 'numerical:+', + NUMERICALMINUS = 'numerical:-', + NUMERICALMULTIPLY = 'numerical:*', + NUMERICALDIVIDE = 'numerical:/', + NUMERICALPOW = 'numerical:^', + STRINGCONCAT = 'string:+', + STRINGSUBSTR = 'string:substr', + TYPECONVERSION = 'string:=>' +} \ No newline at end of file diff --git a/packages/itmat-types/src/types/zod.ts b/packages/itmat-types/src/types/zod.ts new file mode 100644 index 000000000..315b251e1 --- /dev/null +++ b/packages/itmat-types/src/types/zod.ts @@ -0,0 +1,9 @@ +import { Readable } from 'stream'; +import { z } from 'zod'; + +export const FileUploadSchema = z.object({ + createReadStream: z.function().returns(z.instanceof(Readable)), + filename: z.string(), + mimetype: z.string(), + encoding: z.string() +}); \ No newline at end of file diff --git a/packages/itmat-ui-react/proxy.conf.js b/packages/itmat-ui-react/proxy.conf.js index c6a27f8a0..7caae434f 100644 --- a/packages/itmat-ui-react/proxy.conf.js +++ b/packages/itmat-ui-react/proxy.conf.js @@ -6,6 +6,11 @@ module.exports = { secure: false, changeOrigin: true }, + '/trpc': { + target: API_SERVER, + secure: false, + changeOrigin: true + }, '/file': { target: API_SERVER, secure: false, diff --git a/packages/itmat-ui-react/src/components/datasetDetail/index.tsx b/packages/itmat-ui-react/src/components/datasetDetail/index.tsx index c4ed9ba6f..9ac887f9d 100644 --- a/packages/itmat-ui-react/src/components/datasetDetail/index.tsx +++ b/packages/itmat-ui-react/src/components/datasetDetail/index.tsx @@ -2,7 +2,7 @@ import { FunctionComponent } from 'react'; import { Query } from '@apollo/client/react/components'; import { NavLink, Route, Routes, useParams, Navigate } from 'react-router-dom'; import { GET_STUDY, WHO_AM_I } from '@itmat-broker/itmat-models'; -import { IJobEntry, IProject, IStudy, IUserWithoutToken, userTypes } from '@itmat-broker/itmat-types'; +import { IJobEntry, IProject, IStudy, IUserWithoutToken, enumUserTypes } from '@itmat-broker/itmat-types'; import LoadSpinner from '../reusable/loadSpinner'; import css from './projectPage.module.css'; import { DashboardTabContent, DataManagementTabContentFetch, ProjectsTabContent, AdminTabContent, FieldManagementTabContentFetch } from './tabContent'; @@ -31,7 +31,7 @@ export const DatasetDetailPage: FunctionComponent = () => { if (loading) return ; if (error) return

{error.toString()}

; if (!sessionData) { return null; } - if (sessionData.whoAmI.type === userTypes.ADMIN) { + if (sessionData.whoAmI.type === enumUserTypes.ADMIN) { return ( <> isActive ? css.active : undefined}>DASHBOARD diff --git a/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataSummary.tsx b/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataSummary.tsx index 81a62ff1d..b601cc674 100644 --- a/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataSummary.tsx +++ b/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataSummary.tsx @@ -18,7 +18,7 @@ export const DataSummaryVisual: FunctionComponent<{ studyId: string; selectedVer - + ; diff --git a/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataTab.tsx b/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataTab.tsx index b21beb450..71d4c3708 100644 --- a/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataTab.tsx +++ b/packages/itmat-ui-react/src/components/datasetDetail/tabContent/data/dataTab.tsx @@ -3,7 +3,7 @@ import { generateCascader } from '../../../../utils/tools'; import { useQuery, useMutation } from '@apollo/client/react/hooks'; import { Query } from '@apollo/client/react/components'; import { GET_STUDY, GET_STUDY_FIELDS, GET_DATA_RECORDS, CREATE_NEW_DATA_VERSION, SET_DATAVERSION_AS_CURRENT, WHO_AM_I, GET_ONTOLOGY_TREE } from '@itmat-broker/itmat-models'; -import { userTypes, IOntologyRoute, ICohortSelection, INewFieldSelection } from '@itmat-broker/itmat-types'; +import { enumUserTypes, IOntologyRoute, ICohortSelection, INewFieldSelection } from '@itmat-broker/itmat-types'; import LoadSpinner from '../../../reusable/loadSpinner'; import { Subsection, SubsectionWithComment } from '../../../reusable/subsection/subsection'; import { DataSummaryVisual } from './dataSummary'; @@ -154,7 +154,7 @@ export const DataManagementTabContentFetch: FunctionComponent = () => {
- {whoAmIData.whoAmI.type === userTypes.ADMIN ? + {whoAmIData.whoAmI.type === enumUserTypes.ADMIN ?

- query={GET_STUDY_FIELDS} variables={{ studyId, fieldTreeId: selectedTree }}> + query={GET_STUDY_FIELDS} variables={{ studyId, fieldTreeId: selectedTree }}> {({ data, loading, error }) => { if (loading) { return ; } if (error) { return

{JSON.stringify(error)}

; } diff --git a/packages/itmat-ui-react/src/components/datasetDetail/tabContent/files/fileTab.tsx b/packages/itmat-ui-react/src/components/datasetDetail/tabContent/files/fileTab.tsx index 8c36c2f5d..19d64f777 100644 --- a/packages/itmat-ui-react/src/components/datasetDetail/tabContent/files/fileTab.tsx +++ b/packages/itmat-ui-react/src/components/datasetDetail/tabContent/files/fileTab.tsx @@ -7,7 +7,7 @@ import { Query } from '@apollo/client/react/components'; import { useApolloClient, useMutation, useQuery } from '@apollo/client/react/hooks'; import { useDropzone } from 'react-dropzone'; import { GET_STUDY, UPLOAD_FILE, GET_ORGANISATIONS, GET_USERS, EDIT_STUDY, WHO_AM_I } from '@itmat-broker/itmat-models'; -import { IFile, userTypes, deviceTypes, IStudy } from '@itmat-broker/itmat-types'; +import { IFile, enumUserTypes, deviceTypes, IStudy } from '@itmat-broker/itmat-types'; import { FileList, formatBytes } from '../../../reusable/fileList/fileList'; import LoadSpinner from '../../../reusable/loadSpinner'; import { Subsection, SubsectionWithComment } from '../../../reusable/subsection/subsection'; @@ -332,30 +332,30 @@ export const FileRepositoryTabContent: FunctionComponent<{ studyId: string }> = const studyLevelFiles: IFile[] = []; const subjectLevelFiles: IFile[] = []; for (const file of files) { - if (Object.keys(JSON.parse(file.description)).length !== 0) { + if (file.description && Object.keys(JSON.parse(file.description)).length !== 0) { if (file !== null && file !== undefined && (!searchTerm || (JSON.parse(file.description).participantId).toUpperCase().indexOf(searchTerm) > -1 || sites[JSON.parse(file.description).participantId[0]].toUpperCase().indexOf(searchTerm) > -1 || JSON.parse(file.description).deviceId.toUpperCase().indexOf(searchTerm) > -1 || deviceTypes[JSON.parse(file.description).deviceId.substr(0, 3)].toUpperCase().indexOf(searchTerm) > -1 - || (!userIdNameMapping[file.uploadedBy] || userIdNameMapping[file.uploadedBy].toUpperCase().indexOf(searchTerm) > -1))) { + || (!userIdNameMapping[file.life.createdTime] || userIdNameMapping[file.life.createdTime].toUpperCase().indexOf(searchTerm) > -1))) { subjectLevelFiles.push(file); } } else { studyLevelFiles.push(file); } } - subjectLevelFiles.sort((a, b) => parseInt(a.uploadTime) - parseInt(b.uploadTime)); - studyLevelFiles.sort((a, b) => parseInt(a.uploadTime) - parseInt(b.uploadTime)); + subjectLevelFiles.sort((a, b) => a.life.createdTime - b.life.createdTime); + studyLevelFiles.sort((a, b) => a.life.createdTime - b.life.createdTime); return [subjectLevelFiles, studyLevelFiles]; } const sortedFiles = dataSourceFilter(getStudyData.getStudy.files); const numberOfFiles = sortedFiles[0].length; - const sizeOfFiles = sortedFiles[0].reduce((a, b) => a + (parseInt(b.fileSize || '0') || 0), 0); + const sizeOfFiles = sortedFiles[0].reduce((a, b) => a + (b.fileSize || 0), 0); const participantOfFiles = sortedFiles[0].reduce(function (values, v) { - if (!values.set[JSON.parse(v['description'])['participantId']]) { + if (v.description && !values.set[JSON.parse(v['description'])['participantId']]) { values.set[JSON.parse(v['description'])['participantId']] = 1; values.count++; } @@ -365,8 +365,8 @@ export const FileRepositoryTabContent: FunctionComponent<{ studyId: string }> = const fileSummary: { site: string, total: number }[] = []; const categoryColumns: ColumnProps<{ site: string, total: number }>[] = []; if (sortedFiles[0].length > 0) { - const availableSites: string[] = Array.from(new Set(sortedFiles[0].map(el => JSON.parse(el.description).participantId[0]).sort())); - const availableDeviceTypes: string[] = Array.from(new Set(sortedFiles[0].map(el => JSON.parse(el.description).deviceId.substr(0, 3)).sort())); + const availableSites: string[] = Array.from(new Set(sortedFiles[0].map(el => JSON.parse(el.description ?? '').participantId[0]).sort())); + const availableDeviceTypes: string[] = Array.from(new Set(sortedFiles[0].map(el => JSON.parse(el.description ?? '').deviceId.substr(0, 3)).sort())); categoryColumns.push( { title: 'Site', @@ -395,10 +395,10 @@ export const FileRepositoryTabContent: FunctionComponent<{ studyId: string }> = total: 0 }; for (const deviceType of availableDeviceTypes) { - tmpData[deviceType] = sortedFiles[0].filter(el => (JSON.parse(el.description).participantId[0] === site - && JSON.parse(el.description).deviceId.substr(0, 3) === deviceType)).length; + tmpData[deviceType] = sortedFiles[0].filter(el => (JSON.parse(el.description ?? '').participantId[0] === site + && JSON.parse(el.description ?? '').deviceId.substr(0, 3) === deviceType)).length; } - tmpData.total = sortedFiles[0].filter(el => JSON.parse(el.description).participantId[0] === site).length; + tmpData.total = sortedFiles[0].filter(el => JSON.parse(el.description ?? '').participantId[0] === site).length; fileSummary.push(tmpData); } const tmpData = { @@ -406,7 +406,7 @@ export const FileRepositoryTabContent: FunctionComponent<{ studyId: string }> = total: sortedFiles[0].length }; for (const deviceType of availableDeviceTypes) { - tmpData[deviceType] = sortedFiles[0].filter(el => JSON.parse(el.description).deviceId.substr(0, 3) === deviceType).length; + tmpData[deviceType] = sortedFiles[0].filter(el => JSON.parse(el.description ?? '').deviceId.substr(0, 3) === deviceType).length; } fileSummary.push(tmpData); } @@ -445,7 +445,7 @@ export const FileRepositoryTabContent: FunctionComponent<{ studyId: string }> =
:
diff --git a/packages/itmat-ui-react/src/components/log/logList.tsx b/packages/itmat-ui-react/src/components/log/logList.tsx index c05bb4a6f..c2f052fa2 100644 --- a/packages/itmat-ui-react/src/components/log/logList.tsx +++ b/packages/itmat-ui-react/src/components/log/logList.tsx @@ -1,6 +1,6 @@ import { FunctionComponent, useState } from 'react'; import { GET_LOGS } from '@itmat-broker/itmat-models'; -import { userTypes, LOG_ACTION, LOG_TYPE, LOG_STATUS, USER_AGENT, ILogEntry } from '@itmat-broker/itmat-types'; +import { enumUserTypes, LOG_ACTION, LOG_TYPE, LOG_STATUS, USER_AGENT, ILogEntry } from '@itmat-broker/itmat-types'; import { Query } from '@apollo/client/react/components'; import LoadSpinner from '../reusable/loadSpinner'; import { Table, Input, Button, Checkbox, Descriptions, DatePicker, Modal, Row, Col } from 'antd'; @@ -39,7 +39,7 @@ const LogList: FunctionComponent<{ list: ILogEntry[] }> = ({ list }) => { const [searchTerm, setSearchTerm] = useState(''); const initInputs: { requesterName: string, - requesterType: userTypes[], + requesterType: enumUserTypes[], userAgent: string[], logType: LOG_TYPE[], actionType: LOG_ACTION[], @@ -247,7 +247,7 @@ const LogList: FunctionComponent<{ list: ILogEntry[] }> = ({ list }) => { - + diff --git a/packages/itmat-ui-react/src/components/profile/profile.tsx b/packages/itmat-ui-react/src/components/profile/profile.tsx index 53551269d..248cedf51 100644 --- a/packages/itmat-ui-react/src/components/profile/profile.tsx +++ b/packages/itmat-ui-react/src/components/profile/profile.tsx @@ -140,7 +140,7 @@ export const EditUserForm: FunctionComponent<{ user: (IUserWithoutToken & { acce {(submit, { loading, error }) =>
org.id === user.organisation)?.name }} layout='vertical' onFinish={(variables) => { submit({ variables: formatSubmitObj(variables) }).catch(() => { return; }); }}> diff --git a/packages/itmat-ui-react/src/components/projectDetail/index.tsx b/packages/itmat-ui-react/src/components/projectDetail/index.tsx index 0ede8430e..e705fb23f 100644 --- a/packages/itmat-ui-react/src/components/projectDetail/index.tsx +++ b/packages/itmat-ui-react/src/components/projectDetail/index.tsx @@ -2,7 +2,7 @@ import { FunctionComponent } from 'react'; import { Query } from '@apollo/client/react/components'; import { NavLink, Navigate, Route, Routes, useParams } from 'react-router-dom'; import { GET_PROJECT, WHO_AM_I } from '@itmat-broker/itmat-models'; -import { IJobEntry, IProject, IRoleQL, IUserWithoutToken, userTypes } from '@itmat-broker/itmat-types'; +import { IJobEntry, IProject, IRoleQL, IUserWithoutToken, enumUserTypes } from '@itmat-broker/itmat-types'; import LoadSpinner from '../reusable/loadSpinner'; import css_dataset from '../datasetDetail/projectPage.module.css'; import { AdminTabContent, DashboardTabContent, DataTabContent } from './tabContent'; @@ -19,7 +19,7 @@ export const ProjectDetailPage: FunctionComponent = () => { return ( query={GET_PROJECT} - variables={{ projectId, admin: whoamidata.whoAmI.type === userTypes.ADMIN }} + variables={{ projectId, admin: whoamidata.whoAmI.type === enumUserTypes.ADMIN }} > {({ loading, error, data }) => { if (loading) { return ; } @@ -34,7 +34,7 @@ export const ProjectDetailPage: FunctionComponent = () => { if (loading) return ; if (error) return

{error.toString()}

; if (!sessionData) { return null; } - if (sessionData.whoAmI.type === userTypes.ADMIN) { + if (sessionData.whoAmI.type === enumUserTypes.ADMIN) { return
isActive ? css_dataset.active : undefined}>
DASHBOARD
{/* isActive ? className={({ isActive }) => isActive ? css.active : undefined}>
SAMPLE
*/} diff --git a/packages/itmat-ui-react/src/components/projectDetail/tabContent/analysis/analysisTab.tsx b/packages/itmat-ui-react/src/components/projectDetail/tabContent/analysis/analysisTab.tsx index cc4377368..7504cdf9f 100644 --- a/packages/itmat-ui-react/src/components/projectDetail/tabContent/analysis/analysisTab.tsx +++ b/packages/itmat-ui-react/src/components/projectDetail/tabContent/analysis/analysisTab.tsx @@ -3,7 +3,7 @@ import { statisticsTypes, analysisTemplate, options, dataTypeMapping } from '../ import { get_t_test, get_z_test, mannwhitneyu, findDmField, generateCascader } from '../../../../utils/tools'; import { useQuery, useLazyQuery } from '@apollo/client/react/hooks'; import { GET_STUDY_FIELDS, GET_PROJECT, GET_DATA_RECORDS, GET_ONTOLOGY_TREE } from '@itmat-broker/itmat-models'; -import { IFieldEntry, IProject, enumValueType, IOntologyTree, IOntologyRoute, IQueryString, ICohortSelection, enumCohortSelectionOp } from '@itmat-broker/itmat-types'; +import { IField, IProject, enumDataTypes, IOntologyTree, IOntologyRoute, IQueryString, ICohortSelection, enumCohortSelectionOp } from '@itmat-broker/itmat-types'; import LoadSpinner from '../../../reusable/loadSpinner'; import { SubsectionWithComment } from '../../../reusable/subsection/subsection'; import css from './tabContent.module.css'; @@ -97,7 +97,7 @@ export const AnalysisTabContent: FunctionComponent<{ studyId: string }> = ({ stu
); }; -const FilterSelector: FunctionComponent<{ guideTool, filtersTool: [Filter, Dispatch>], fields: IFieldEntry[], project: IProject, query, ontologyTree: IOntologyTree, fieldPathOptions, dmFields }> = ({ guideTool, filtersTool, fields, project, query, ontologyTree, fieldPathOptions, dmFields }) => { +const FilterSelector: FunctionComponent<{ guideTool, filtersTool: [Filter, Dispatch>], fields: IField[], project: IProject, query, ontologyTree: IOntologyTree, fieldPathOptions, dmFields }> = ({ guideTool, filtersTool, fields, project, query, ontologyTree, fieldPathOptions, dmFields }) => { const [isModalOn, setIsModalOn] = useState(false); const [isTemplateModalOn, setIsTemplateModalOn] = useState(false); const [templateType, setTemplateType] = useState('NA'); @@ -315,8 +315,8 @@ const FilterSelector: FunctionComponent<{ guideTool, filtersTool: [Filter, Dispa
); }; -const ResultsVisualization: FunctionComponent<{ project: IProject, fields: IFieldEntry[], data }> = ({ project, fields, data }) => { +const ResultsVisualization: FunctionComponent<{ project: IProject, fields: IField[], data }> = ({ project, fields, data }) => { const [statisticsType, setStatisticsType] = useState('ttest'); const [signifianceLevel, setSignigicanceLevel] = useState(undefined); if (data.length === 0) { @@ -505,7 +505,7 @@ const ResultsVisualization: FunctionComponent<{ project: IProject, fields: IFiel }, { key: 'Table Name', - value: thisField.tableName || 'NA' + value: thisField.metadata['tableName'] || 'NA' }, { key: 'Data Type', value: dataTypeMapping[thisField.dataType] || 'NA' @@ -544,7 +544,7 @@ const ResultsVisualization: FunctionComponent<{ project: IProject, fields: IFiel key: 'graph', render: (__unused__value, record) => { const fieldDef = fields.filter(el => el.fieldId === record.field)[0]; - if ([enumValueType.DECIMAL, enumValueType.INTEGER].includes(fieldDef.dataType)) { + if ([enumDataTypes.DECIMAL, enumDataTypes.INTEGER].includes(fieldDef.dataType)) { return (
); - } else if ([enumValueType.BOOLEAN, enumValueType.CATEGORICAL].includes(fieldDef.dataType)) { + } else if ([enumDataTypes.BOOLEAN, enumDataTypes.CATEGORICAL].includes(fieldDef.dataType)) { return (
); }; -function variableFilterColumns(guideTool, fields: IFieldEntry[], remove, fieldPathOptions) { +function variableFilterColumns(guideTool, fields: IField[], remove, fieldPathOptions) { return [ { title: 'Field', @@ -807,7 +807,7 @@ function variableFilterColumns(guideTool, fields: IFieldEntry[], remove, fieldPa ]; } -function filterTableColumns(guideTool, fields: IFieldEntry[], dmFields, filtersTool, setIsModalOn, setCurrentGroupIndex) { +function filterTableColumns(guideTool, fields: IField[], dmFields, filtersTool, setIsModalOn, setCurrentGroupIndex) { if (filtersTool[0].groups.length === 0) { return []; } @@ -839,7 +839,7 @@ function filterTableColumns(guideTool, fields: IFieldEntry[], dmFields, filtersT render: (__unused__value, record) => { return
{ - record.race.map(el => {raceField?.possibleValues?.filter(ed => ed.code === el.toString())[0].description}) + record.race.map(el => {raceField?.categoricalOptions?.filter(ed => ed.code === el.toString())[0].description}) }
; } @@ -855,7 +855,7 @@ function filterTableColumns(guideTool, fields: IFieldEntry[], dmFields, filtersT render: (__unused__value, record) => { return
{ - record.genderID.map(el => {genderField?.possibleValues?.filter(ed => ed.code === el.toString())[0].description}) + record.genderID.map(el => {genderField?.categoricalOptions?.filter(ed => ed.code === el.toString())[0].description}) }
; } @@ -1009,7 +1009,7 @@ function formInitialValues(form, filters, index: number) { } // // we sent the union of the filters as the filters in the request body -function combineFilters(fields: IFieldEntry[], filters: Filter, dmFields) { +function combineFilters(fields: IField[], filters: Filter, dmFields) { const queryString: Partial & { format?: string, subjects_requested?: string[] | null, cohort?: ICohortSelection[][] } = {}; queryString.data_requested = Array.from(new Set((filters.groups.map(el => el.filters).flat().map(es => es.field).concat(filters.comparedFields) .concat(dmFields.filter(el => (el !== undefined && el !== null)).map(el => el.fieldId))))); @@ -1063,11 +1063,11 @@ function divideResults(filters, results, fields, dmFields) { siteID: dmFields[3] }; filters.comparedFields.forEach(el => { - const fieldDef: IFieldEntry = fields.filter(ek => ek.fieldId === el)[0]; - if (![enumValueType.DECIMAL, enumValueType.INTEGER, enumValueType.CATEGORICAL, enumValueType.BOOLEAN].includes(fieldDef.dataType)) { + const fieldDef: IField = fields.filter(ek => ek.fieldId === el)[0]; + if (![enumDataTypes.DECIMAL, enumDataTypes.INTEGER, enumDataTypes.CATEGORICAL, enumDataTypes.BOOLEAN].includes(fieldDef.dataType)) { return; } - if (fieldDef.tableName === 'Participants') { + if (fieldDef['tableName'] === 'Participants') { return; } const dataClip: { @@ -1171,7 +1171,7 @@ function divideResults(filters, results, fields, dmFields) { } }); // construct data for visualization - if ([enumValueType.INTEGER, enumValueType.DECIMAL].includes(fieldDef.dataType)) { + if ([enumDataTypes.INTEGER, enumDataTypes.DECIMAL].includes(fieldDef.dataType)) { const dataForGraph = Object.keys(dataClip.data).reduce((acc, curr) => { dataClip.data[curr].forEach(el => { typeof el === 'number' && acc.push({ @@ -1182,10 +1182,10 @@ function divideResults(filters, results, fields, dmFields) { return acc; }, [] as { x: string, y: number }[]); dataClip.dataForGraph = dataForGraph; - } else if ([enumValueType.CATEGORICAL, enumValueType.BOOLEAN].includes(fieldDef.dataType)) { + } else if ([enumDataTypes.CATEGORICAL, enumDataTypes.BOOLEAN].includes(fieldDef.dataType)) { const dataForGraph = Object.keys(dataClip.data).reduce((acc, curr) => { - if (fieldDef.possibleValues) { - fieldDef.possibleValues.forEach(ek => { + if (fieldDef.categoricalOptions) { + fieldDef.categoricalOptions.forEach(ek => { acc.push({ x: curr, y: dataClip.data[curr].filter(es => String(es) === ek.code.toString()).length, diff --git a/packages/itmat-ui-react/src/components/projectDetail/tabContent/data/dataTab.tsx b/packages/itmat-ui-react/src/components/projectDetail/tabContent/data/dataTab.tsx index f2ef10d54..a66d57cba 100644 --- a/packages/itmat-ui-react/src/components/projectDetail/tabContent/data/dataTab.tsx +++ b/packages/itmat-ui-react/src/components/projectDetail/tabContent/data/dataTab.tsx @@ -3,7 +3,7 @@ import { filterFields, generateCascader, findDmField } from '../../../../utils/t import { dataTypeMapping } from '../utils/defaultParameters'; import { useQuery, useLazyQuery } from '@apollo/client/react/hooks'; import { GET_STUDY_FIELDS, GET_PROJECT, GET_DATA_RECORDS, GET_ONTOLOGY_TREE } from '@itmat-broker/itmat-models'; -import { IFieldEntry, IProject, enumValueType, IOntologyTree, IOntologyRoute, ICohortSelection } from '@itmat-broker/itmat-types'; +import { IField, IProject, enumDataTypes, IOntologyTree, IOntologyRoute, ICohortSelection } from '@itmat-broker/itmat-types'; import { Query } from '@apollo/client/react/components'; import LoadSpinner from '../../../reusable/loadSpinner'; import { Subsection, SubsectionWithComment } from '../../../reusable/subsection/subsection'; @@ -123,7 +123,7 @@ export const MetaDataBlock: FunctionComponent<{ project: IProject, numOfOntology
-
{project.dataVersion?.updateDate === undefined ? 'NA' : (new Date(parseFloat(project.dataVersion?.updateDate))).toUTCString()}
+
{project.dataVersion?.life.createdTime === undefined ? 'NA' : (new Date(project.dataVersion?.life.createdTime)).toUTCString()}

: @@ -178,7 +178,7 @@ export const MetaDataBlock: FunctionComponent<{ project: IProject, numOfOntology -
{project.dataVersion?.updateDate === undefined ? 'NA' : (new Date(parseFloat(project.dataVersion?.updateDate))).toDateString()}
+
{project.dataVersion?.life.createdTime === undefined ? 'NA' : (new Date(project.dataVersion?.life.createdTime)).toDateString()}
@@ -191,7 +191,7 @@ interface IDemographicsReport { value: number; } -export const DemographicsBlock: FunctionComponent<{ ontologyTree: IOntologyTree, studyId: string, projectId: string, fields: IFieldEntry[] }> = ({ ontologyTree, studyId, projectId, fields }) => { +export const DemographicsBlock: FunctionComponent<{ ontologyTree: IOntologyTree, studyId: string, projectId: string, fields: IField[] }> = ({ ontologyTree, studyId, projectId, fields }) => { const [width, __unused__height__] = useWindowSize(); // process the data const genderField = findDmField(ontologyTree, fields, 'SEX'); @@ -240,7 +240,7 @@ export const DemographicsBlock: FunctionComponent<{ ontologyTree: IOntologyTree, obj.AGE = []; } else { obj.SEX = (data[genderField.fieldId][genderField.visitRange[0]]?.data || []).reduce((acc, curr) => { - const thisGender = genderField?.possibleValues?.filter(el => el.code === curr)[0].description || ''; + const thisGender = genderField?.categoricalOptions?.filter(el => el.code === curr)[0].description || ''; if (acc.filter(es => es.type === thisGender).length === 0) { acc.push({ type: thisGender, value: 0 }); } @@ -274,7 +274,7 @@ export const DemographicsBlock: FunctionComponent<{ ontologyTree: IOntologyTree, obj.RACE = []; } else { obj.RACE = (data[raceField.fieldId][raceField.visitRange[0]]?.data || []).reduce((acc, curr) => { - const thisRace = raceField?.possibleValues?.filter(el => el.code === curr)[0].description || ''; + const thisRace = raceField?.categoricalOptions?.filter(el => el.code === curr)[0].description || ''; if (acc.filter(es => es.type === thisRace).length === 0) { acc.push({ type: thisRace, value: 0 }); } @@ -429,13 +429,13 @@ interface GroupedData { } } -export const DataDistributionBlock: FunctionComponent<{ ontologyTree: IOntologyTree, fields: IFieldEntry[], project: IProject }> = ({ ontologyTree, fields, project }) => { +export const DataDistributionBlock: FunctionComponent<{ ontologyTree: IOntologyTree, fields: IField[], project: IProject }> = ({ ontologyTree, fields, project }) => { const [selectedPath, setSelectedPath] = useState<(string | number)[]>([]); const [selectedGraphType, setSelectedGraphType] = useState(undefined); const routes: IOntologyRoute[] = ontologyTree.routes?.filter(es => { return JSON.stringify([...es.path, es.name]) === JSON.stringify(selectedPath); }) || []; - const field: IFieldEntry | undefined = fields.filter(el => { + const field: IField | undefined = fields.filter(el => { return el.fieldId.toString() === routes[0]?.field[0]?.replace('$', ''); })[0]; //construct the cascader @@ -502,7 +502,7 @@ export const DataDistributionBlock: FunctionComponent<{ ontologyTree: IOntologyT }} > { - [enumValueType.INTEGER, enumValueType.DECIMAL].includes(fields.filter(el => el.fieldId === field?.fieldId)[0]?.dataType) ? + [enumDataTypes.INTEGER, enumDataTypes.DECIMAL].includes(fields.filter(el => el.fieldId === field?.fieldId)[0]?.dataType) ? <> @@ -588,7 +588,7 @@ export const DataDistributionBlock: FunctionComponent<{ ontologyTree: IOntologyT return ; } let processedData: Array<{ x: string, y: number } | { visit: string, value: string, count: number }> = []; - if ([enumValueType.INTEGER, enumValueType.DECIMAL].includes(fields.filter(el => el.fieldId === fieldIdFromData)[0].dataType)) { + if ([enumDataTypes.INTEGER, enumDataTypes.DECIMAL].includes(fields.filter(el => el.fieldId === fieldIdFromData)[0].dataType)) { processedData = Object.keys(data.getDataRecords.data[fieldIdFromData]).reduce((acc, curr) => { data.getDataRecords.data[fieldIdFromData][curr].data.forEach(el => { if (el === '99999') { @@ -598,7 +598,7 @@ export const DataDistributionBlock: FunctionComponent<{ ontologyTree: IOntologyT }); return acc; }, [] as { x: string, y: number }[]); - } else if ([enumValueType.CATEGORICAL, enumValueType.BOOLEAN].includes(fields.filter(el => el.fieldId === fieldIdFromData)[0].dataType)) { + } else if ([enumDataTypes.CATEGORICAL, enumDataTypes.BOOLEAN].includes(fields.filter(el => el.fieldId === fieldIdFromData)[0].dataType)) { processedData = Object.keys(data.getDataRecords.data[fieldIdFromData]).reduce((acc, curr) => { let count = 0; data.getDataRecords.data[fieldIdFromData][curr].data.forEach(el => { @@ -640,7 +640,7 @@ export const DataDistributionBlock: FunctionComponent<{ ontologyTree: IOntologyT high: sortedY[sortedY.length - 1] }; }); - if ([enumValueType.INTEGER, enumValueType.DECIMAL].includes(fields.filter(el => el.fieldId === fieldIdFromData)[0].dataType)) + if ([enumDataTypes.INTEGER, enumDataTypes.DECIMAL].includes(fields.filter(el => el.fieldId === fieldIdFromData)[0].dataType)) return selectedGraphType === 'violin' ? ); }; -export const DataCompletenessBlock: FunctionComponent<{ studyId: string, projectId: string, ontologyTree: IOntologyTree, fields: IFieldEntry[] }> = ({ studyId, projectId, ontologyTree, fields }) => { +export const DataCompletenessBlock: FunctionComponent<{ studyId: string, projectId: string, ontologyTree: IOntologyTree, fields: IField[] }> = ({ studyId, projectId, ontologyTree, fields }) => { const [selectedPath, setSelectedPath] = useState<(string | number)[]>([]); const requestedFields = ontologyTree.routes?.filter(el => { if (JSON.stringify(el.path) === JSON.stringify(selectedPath)) { @@ -756,7 +756,7 @@ export const DataCompletenessBlock: FunctionComponent<{ studyId: string, project showTitle: false, fields: ['visit', 'field', 'percentage'], formatter: (datum) => { - const field: IFieldEntry | undefined = fields.filter(el => el.fieldId === datum.field)[0]; + const field: IField | undefined = fields.filter(el => el.fieldId === datum.field)[0]; let name; if (field) { name = field.fieldId.concat('-').concat(field.fieldName); diff --git a/packages/itmat-ui-react/src/components/reusable/fieldList/fieldList.tsx b/packages/itmat-ui-react/src/components/reusable/fieldList/fieldList.tsx index e3364959b..591f53339 100644 --- a/packages/itmat-ui-react/src/components/reusable/fieldList/fieldList.tsx +++ b/packages/itmat-ui-react/src/components/reusable/fieldList/fieldList.tsx @@ -1,8 +1,8 @@ import { FunctionComponent } from 'react'; -import { enumValueType, IFieldEntry } from '@itmat-broker/itmat-types'; +import { enumDataTypes, IField } from '@itmat-broker/itmat-types'; import { Table, Tooltip } from 'antd'; -export const FieldListSection: FunctionComponent<{ studyData, onCheck?; checkedList?: string[]; checkable: boolean; fieldList: IFieldEntry[]; verbal?: boolean }> = ({ onCheck, checkedList, checkable, fieldList, verbal }) => { +export const FieldListSection: FunctionComponent<{ studyData, onCheck?; checkedList?: string[]; checkable: boolean; fieldList: IField[]; verbal?: boolean }> = ({ onCheck, checkedList, checkable, fieldList, verbal }) => { const possibleValuesColumns = [ { title: 'Code', @@ -54,10 +54,10 @@ export const FieldListSection: FunctionComponent<{ studyData, onCheck?; checkedL dataIndex: 'dataType', key: 'dataType', render: (__unused__value, record) => { - if (record.dataType === enumValueType.CATEGORICAL) { + if (record.dataType === enumDataTypes.CATEGORICAL) { return }> {record.dataType} diff --git a/packages/itmat-ui-react/src/components/reusable/fileList/fileList.tsx b/packages/itmat-ui-react/src/components/reusable/fileList/fileList.tsx index fcda13f85..dbabbe4d6 100644 --- a/packages/itmat-ui-react/src/components/reusable/fileList/fileList.tsx +++ b/packages/itmat-ui-react/src/components/reusable/fileList/fileList.tsx @@ -2,7 +2,7 @@ import { FunctionComponent, useState } from 'react'; import { useMutation, useQuery } from '@apollo/client/react/hooks'; import { Table, Button, notification, Tooltip } from 'antd'; import { DELETE_FILE, WHO_AM_I, GET_ORGANISATIONS, GET_USERS } from '@itmat-broker/itmat-models'; -import { IFile, userTypes } from '@itmat-broker/itmat-types'; +import { IFile, enumUserTypes } from '@itmat-broker/itmat-types'; import { DeleteOutlined, CloudDownloadOutlined, SwapRightOutlined, NumberOutlined } from '@ant-design/icons'; import { ApolloError } from '@apollo/client/errors'; import dayjs from 'dayjs'; @@ -186,7 +186,7 @@ export const FileList: FunctionComponent<{ files: IFile[], searchTerm: string | width: '10rem', key: 'download' }] - .concat(!loadingWhoAmI && dataWhoAmI?.whoAmI?.type === userTypes.ADMIN ? [ + .concat(!loadingWhoAmI && dataWhoAmI?.whoAmI?.type === enumUserTypes.ADMIN ? [ { render: (__unused__value, record) => ( - {whoamidata.whoAmI.id !== user.id && whoamidata.whoAmI.type === userTypes.ADMIN + {whoamidata.whoAmI.id !== user.id && whoamidata.whoAmI.type === enumUserTypes.ADMIN ? <>