From 56ff564cfaefa80dfb67ec0ebfcd1cebe8b383e2 Mon Sep 17 00:00:00 2001 From: Siyao Wang Date: Mon, 8 Jul 2024 16:27:34 +0100 Subject: [PATCH] refactor: Merge core functions and fix several issues --- .../itmat-cores/config/config.sample.json | 6 +- .../itmat-cores/src/GraphQLCore/fileCore.ts | 245 --- .../itmat-cores/src/GraphQLCore/jobCore.ts | 83 - .../itmat-cores/src/GraphQLCore/logCore.ts | 76 - .../src/GraphQLCore/organisationCore.ts | 76 - .../src/GraphQLCore/permissionCore.ts | 188 -- .../itmat-cores/src/GraphQLCore/pubkeyCore.ts | 208 -- .../itmat-cores/src/GraphQLCore/queryCore.ts | 83 - .../itmat-cores/src/GraphQLCore/studyCore.ts | 1728 ----------------- .../itmat-cores/src/GraphQLCore/userCore.ts | 896 --------- packages/itmat-cores/src/database/database.ts | 11 +- packages/itmat-cores/src/index.ts | 14 +- packages/itmat-cores/src/rest/fileDownload.ts | 25 +- packages/itmat-cores/src/trpcCore/dataCore.ts | 61 +- packages/itmat-cores/src/trpcCore/fileCore.ts | 57 +- packages/itmat-cores/src/trpcCore/jobCore.ts | 22 + packages/itmat-cores/src/trpcCore/logCore.ts | 2 +- .../src/trpcCore/organisationCore.ts | 198 ++ .../src/trpcCore/permissionCore.ts | 18 + .../standardizationCore.ts | 51 +- .../itmat-cores/src/trpcCore/studyCore.ts | 30 +- packages/itmat-cores/src/trpcCore/userCore.ts | 152 +- packages/itmat-cores/src/utils/GraphQL.ts | 207 ++ .../itmat-docker/config/config.sample.json | 4 +- .../itmat-interface/config/config.sample.json | 4 +- .../src/graphql/resolvers/fileResolvers.ts | 95 +- .../src/graphql/resolvers/index.ts | 4 +- .../src/graphql/resolvers/jobResolvers.ts | 10 +- .../src/graphql/resolvers/logResolvers.ts | 22 +- .../resolvers/organisationResolvers.ts | 12 +- .../graphql/resolvers/permissionResolvers.ts | 12 +- .../src/graphql/resolvers/pubkeyResolvers.ts | 17 +- .../src/graphql/resolvers/queryResolvers.ts | 22 - .../resolvers/standardizationResolvers.ts | 6 +- .../src/graphql/resolvers/studyResolvers.ts | 276 ++- .../src/graphql/resolvers/userResolvers.ts | 190 +- .../itmat-interface/src/graphql/typeDefs.ts | 1 - .../src/trpc/organisationProcedure.ts | 73 + .../itmat-interface/src/trpc/tRPCRouter.ts | 4 +- .../itmat-interface/src/trpc/userProcedure.ts | 7 +- .../test/GraphQLTests/file.test.ts | 8 +- .../test/GraphQLTests/job.test.ts | 249 --- .../test/GraphQLTests/study.test.ts | 382 ++-- .../test/GraphQLTests/users.test.ts | 168 +- .../test/trpcTests/data.test.ts | 6 +- .../test/trpcTests/organisation.test.ts | 197 ++ .../test/trpcTests/user.test.ts | 14 +- .../config/config.sample.json | 4 +- .../databaseSetup/collectionsAndIndexes.ts | 14 +- .../src/databaseSetup/seed/config.ts | 50 + packages/itmat-types/src/types/coreErrors.ts | 3 +- packages/itmat-types/src/types/permission.ts | 6 +- packages/itmat-types/src/types/study.ts | 2 +- 53 files changed, 1806 insertions(+), 4493 deletions(-) delete mode 100644 packages/itmat-cores/src/GraphQLCore/fileCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/jobCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/logCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/organisationCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/permissionCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/pubkeyCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/queryCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/studyCore.ts delete mode 100644 packages/itmat-cores/src/GraphQLCore/userCore.ts create mode 100644 packages/itmat-cores/src/trpcCore/jobCore.ts create mode 100644 packages/itmat-cores/src/trpcCore/organisationCore.ts rename packages/itmat-cores/src/{GraphQLCore => trpcCore}/standardizationCore.ts (80%) create mode 100644 packages/itmat-cores/src/utils/GraphQL.ts delete mode 100644 packages/itmat-interface/src/graphql/resolvers/queryResolvers.ts create mode 100644 packages/itmat-interface/src/trpc/organisationProcedure.ts delete mode 100644 packages/itmat-interface/test/GraphQLTests/job.test.ts create mode 100644 packages/itmat-interface/test/trpcTests/organisation.test.ts diff --git a/packages/itmat-cores/config/config.sample.json b/packages/itmat-cores/config/config.sample.json index 57ac731f7..a96b777e4 100644 --- a/packages/itmat-cores/config/config.sample.json +++ b/packages/itmat-cores/config/config.sample.json @@ -18,8 +18,12 @@ "sessions_collection": "SESSIONS_COLLECTION", "pubkeys_collection": "PUBKEY_COLLECTION", "standardizations_collection": "STANDARDIZATION_COLLECTION", - "colddata_collection": "COLDDATA_COLLECTION", + "configs_collection": "CONFIG_COLLECTION", + "ontologies_collection": "ONTOLOGY_COLLECTION", + "docs_collection": "DOC_COLLECTION", "cache_collection": "CACHE_COLLECTION", + "drives_collection": "DRIVE_COLLECTION", + "colddata_collection": "COLDDATA_COLLECTION", "domains_collection": "DOMAIN_COLLECTION" } }, diff --git a/packages/itmat-cores/src/GraphQLCore/fileCore.ts b/packages/itmat-cores/src/GraphQLCore/fileCore.ts deleted file mode 100644 index db55d39c2..000000000 --- a/packages/itmat-cores/src/GraphQLCore/fileCore.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { IData, IFile, IUserWithoutToken, deviceTypes, enumDataAtomicPermissions, enumDataTypes, enumFileCategories, enumFileTypes, enumReservedKeys } 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 { fileSizeLimit } from '../utils/definition'; -import crypto from 'crypto'; -import { makeGenericReponse } from '../utils/responses'; -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 -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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - let targetFieldId: string; - let isStudyLevel = false; - let dataEntry: IData | null = null; - // 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; - targetFieldId = enumReservedKeys.STUDY_LEVEL_DATA; - dataEntry = { - id: uuid(), - studyId: studyId, - fieldId: targetFieldId, - dataVersion: null, - value: '', - properties: {}, - life: { - createdTime: Date.now(), - createdUser: requester.id, - deletedTime: null, - deletedUser: null - }, - metadata: {} - }; - } else { - isStudyLevel = false; - // 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, 'life.deletedTime': null }).sort({ 'life.createdTime': -1 }).limit(1).toArray()).length === 0) { - throw new GraphQLError('File description is invalid', { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); - } - dataEntry = { - id: uuid(), - studyId: studyId, - fieldId: targetFieldId, - dataVersion: null, - value: '', - properties: { - participantId: parsedDescription.participantId, - deviceId: parsedDescription.deviceId, - startDate: parsedDescription.startDate, - endDate: parsedDescription.endDate - }, - life: { - createdTime: Date.now(), - createdUser: requester.id, - deletedTime: null, - deletedUser: null - }, - metadata: {} - }; - // check field permission - if (!this.permissionCore.checkDataPermission(roles, dataEntry, enumDataAtomicPermissions.WRITE)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - // TODO: check data is valid - } - - const file_ = await file; - - return new Promise((resolve, reject) => { - (async () => { - try { - const fileName: string = file_.filename; - let metadata: Record = {}; - if (!isStudyLevel) { - 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 fileParts: string[] = file_.filename.split('.'); - const fileExtension = fileParts.length === 1 ? 'UNKNOWN' : fileParts[fileParts.length - 1].trim().toLowerCase(); - const fileEntry: IFile = { - id: uuid(), - studyId: studyId, - userId: null, - fileName: fileName, - fileSize: readBytes, - description: description, - properties: {}, - uri: fileUri, - 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 - }; - dataEntry.value = fileEntry.id; - await this.db.collections.data_collection.insertOne(dataEntry); - await this.db.collections.files_collection.insertOne(fileEntry); - resolve({ - ...fileEntry, - uploadTime: fileEntry.life.createdTime, - uploadedBy: requester.id - }); - - } 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({ 'life.deletedTime': null, 'id': fileId }); - if (!file || !file.studyId || !file.description) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - const roles = await this.permissionCore.getRolesOfUser(requester, file.studyId); - - if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const data = await this.db.collections.data_collection.findOne({ 'value': fileId, 'life.deletedTime': null }); - if (!data) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - // check if data and file matches - const field = await this.db.collections.field_dictionary_collection.findOne({ 'fieldId': data.fieldId, 'studyId': file.studyId, 'life.deletedTime': null }); - if (field?.dataType !== enumDataTypes.FILE) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - // TODO: check user has permission to delete this file - if (!this.permissionCore.checkDataPermission(roles, data, enumDataAtomicPermissions.DELETE)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - // update data record - await this.db.collections.data_collection.insertOne({ - id: uuid(), - studyId: file.studyId, - fieldId: data.fieldId, - 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/GraphQLCore/jobCore.ts b/packages/itmat-cores/src/GraphQLCore/jobCore.ts deleted file mode 100644 index 8e6d68e56..000000000 --- a/packages/itmat-cores/src/GraphQLCore/jobCore.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { IJobEntry, IJobEntryForQueryCuration, IUserWithoutToken } 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) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check permission */ - const roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - /* check study exists */ - await this.studyCore.findOneStudy_throwErrorIfNotExist(studyId); - - - /* 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, - 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/GraphQLCore/logCore.ts b/packages/itmat-cores/src/GraphQLCore/logCore.ts deleted file mode 100644 index b3c69e810..000000000 --- a/packages/itmat-cores/src/GraphQLCore/logCore.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ILog, IUserWithoutToken, enumEventStatus, enumEventType, enumUserTypes } from '@itmat-broker/itmat-types'; -import { DBType } from '../database/database'; -import { GraphQLError } from 'graphql'; -import { errorCodes } from '../utils/errors'; -import { Filter } from 'mongodb'; - -// the GraphQL log APIs will be removed in further development -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?: enumUserTypes, logType?: enumEventType, actionType?: string, status?: enumEventStatus) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* only admin can access this field */ - if (!(requester.type === enumUserTypes.ADMIN) && !(requester.metadata?.['logPermission'] === true)) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - const queryObj: Filter = {}; - if (requesterName) { queryObj.requester = requesterName; } - if (logType) { queryObj.type = logType; } - if (actionType) { queryObj.event = actionType; } - if (status) { queryObj.status = status; } - - const logData = await this.db.collections.log_collection.find(queryObj, { projection: { _id: 0 } }).limit(1000).sort('life.createdTime', -1).toArray(); - // log information decoration - for (const i in logData) { - logData[i].parameters = await this.logDecorationHelper(logData[i].parameters ?? {}, logData[i].event); - } - return logData.map((log) => { - return { - id: log.id, - requesterName: log.requester, - requesterType: requester.type, - userAgent: 'MOZILLA', - logType: log.type, - actionType: log.event, - actionData: JSON.stringify(log.parameters), - time: log.life.createdTime, - status: log.status, - error: log.errors - }; - }); - return logData; - } - - public async logDecorationHelper(actionData: Record, actionType: string) { - const obj = { ...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 === 'getStudy') { - const studyId = obj['studyId'] ?? ''; - const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); - if (study === null || study === undefined) { - obj['name'] = ''; - } - else { - obj['name'] = study.name; - } - } - return obj; - } - -} diff --git a/packages/itmat-cores/src/GraphQLCore/organisationCore.ts b/packages/itmat-cores/src/GraphQLCore/organisationCore.ts deleted file mode 100644 index 4ec9a9cb9..000000000 --- a/packages/itmat-cores/src/GraphQLCore/organisationCore.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { IOrganisation, IUserWithoutToken, enumUserTypes } 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 | undefined, containOrg: string | null, metadata }): Promise { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - /* check privileges */ - if (requester.type !== enumUserTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - const { name, shortname, metadata } = org; - const entry: IOrganisation = { - id: uuid(), - name, - shortname, - 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 - }, { - 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 !== enumUserTypes.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-cores/src/GraphQLCore/permissionCore.ts b/packages/itmat-cores/src/GraphQLCore/permissionCore.ts deleted file mode 100644 index f15411825..000000000 --- a/packages/itmat-cores/src/GraphQLCore/permissionCore.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { GraphQLError } from 'graphql'; -import { IData, IField, IRole, IUserWithoutToken, enumDataAtomicPermissions, enumUserTypes, permissionString } from '@itmat-broker/itmat-types'; -import { Document, Filter } from 'mongodb'; -import { DBType } from '../database/database'; -import { errorCodes } from '../utils/errors'; -import { CreateFieldInput } from './studyCore'; - -export interface ICombinedPermissions { - subjectIds: string[], - visitIds: string[], - fieldIds: string[] -} - -export interface QueryMatcher { - key: string, - op: string, - parameter: number | string | boolean -} - -export class PermissionCore { - db: DBType; - constructor(db: DBType) { - this.db = db; - } - - public async getGrantedPermissions(requester: IUserWithoutToken | undefined, studyId?: string) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const matchClause: Filter = { users: requester.id }; - if (studyId) - matchClause.studyId = studyId; - 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): Promise { - return await this.db.collections.roles_collection.find({ studyId }).toArray(); - } - - public async userHasTheNeccessaryManagementPermission(type: string, operation: string, user: IUserWithoutToken, studyId: string, projectId?: string) { - if (user === undefined) { - return false; - } - - /* if user is an admin then return true if admin privileges includes needed permissions */ - if (user.type === enumUserTypes.ADMIN) { - return true; - } - const tag = `permissions.manage.${type}`; - 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(); - if (roles.length === 0) { - return false; - } - return true; - } - - // TODO: check data is valid based on field schema - public checkDataValid() { - return false; - } - - public checkDataPermission(roles: IRole[], dataEntry: IData, permission: enumDataAtomicPermissions) { - for (const role of roles) { - for (const dataPermission of role.dataPermissions) { - if ( - dataPermission.fields.some(field => new RegExp(field).test(dataEntry.fieldId)) - && Object.keys(dataPermission.dataProperties).every(property => dataPermission.dataProperties[property].some(prop => new RegExp(prop).test(String(dataEntry.properties[property]))) - && dataPermission.includeUnVersioned - && permissionString[permission].includes(dataPermission.permission) - )) { - return true; - } - } - } - return false; - } - - public checkFieldPermission(roles: IRole[], fieldEntry: Partial | Partial, permission: enumDataAtomicPermissions) { - for (const role of roles) { - for (const dataPermission of role.dataPermissions) { - if ( - dataPermission.fields.some(field => new RegExp(field).test(fieldEntry.fieldId ?? '')) - && permissionString[permission].includes(dataPermission.permission) - ) { - return true; - } - } - } - return false; - } - - public async getRolesOfUser(user: IUserWithoutToken, studyId: string) { - return await this.db.collections.roles_collection.find({ 'studyId': studyId, 'users': user.id, 'life.deletedTime': null }).toArray(); - } - - 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[] = []; - cohort.forEach(function (select) { - const match = { - m_fieldId: select.field - }; - switch (select.op) { - case '=': - // select.value must be an array - match['value'] = { $in: [select.value] }; - break; - case '!=': - // select.value must be an array - match['value'] = { $nin: [select.value] }; - break; - case '<': - // select.value must be a float - match['value'] = { $lt: parseFloat(select.value) }; - break; - case '>': - // select.value must be a float - match['value'] = { $gt: parseFloat(select.value) }; - break; - case 'derived': { - // equation must only have + - * / - const derivedOperation = select.value.split(' '); - if (derivedOperation[0] === '=') { - match['value'] = { $eq: parseFloat(select.value) }; - } - if (derivedOperation[0] === '>') { - match['value'] = { $gt: parseFloat(select.value) }; - } - if (derivedOperation[0] === '<') { - match['value'] = { $lt: parseFloat(select.value) }; - } - break; - } - case 'exists': - // We check if the field exists. This is to be used for checking if a patient - // has an image - match['value'] = { $exists: true }; - break; - case 'count': { - // counts can only be positive. NB: > and < are inclusive e.g. < is <= - const countOperation = select.value.split(' '); - const countfield = select.field + '.count'; - if (countOperation[0] === '=') { - match[countfield] = { $eq: parseInt(countOperation[1], 10) }; - } - if (countOperation[0] === '>') { - match[countfield] = { $gt: parseInt(countOperation[1], 10) }; - } - if (countOperation[0] === '<') { - match[countfield] = { $lt: parseInt(countOperation[1], 10) }; - } - break; - } - default: - break; - } - queries.push(match); - } - ); - return queries; -} diff --git a/packages/itmat-cores/src/GraphQLCore/pubkeyCore.ts b/packages/itmat-cores/src/GraphQLCore/pubkeyCore.ts deleted file mode 100644 index b76b16c5f..000000000 --- a/packages/itmat-cores/src/GraphQLCore/pubkeyCore.ts +++ /dev/null @@ -1,208 +0,0 @@ -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/GraphQLCore/queryCore.ts b/packages/itmat-cores/src/GraphQLCore/queryCore.ts deleted file mode 100644 index 38cf5c9f8..000000000 --- a/packages/itmat-cores/src/GraphQLCore/queryCore.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { IProject, IQueryEntry, IUserWithoutToken } 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 roles = await this.permissionCore.getRolesOfUser(requester, queryEntry.studyId); - if (!roles.length) { - 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { 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/GraphQLCore/studyCore.ts b/packages/itmat-cores/src/GraphQLCore/studyCore.ts deleted file mode 100644 index 405173c19..000000000 --- a/packages/itmat-cores/src/GraphQLCore/studyCore.ts +++ /dev/null @@ -1,1728 +0,0 @@ -import { GraphQLError } from 'graphql'; -import { IFile, IProject, IStudy, studyType, IStudyDataVersion, IDataClip, IRole, IField, deviceTypes, IUserWithoutToken, enumUserTypes, IOntologyTree, IQueryString, IGroupedData, enumFileTypes, enumFileCategories, enumDataTypes, ICategoricalOption, permissionString, enumDataAtomicPermissions, IData, enumStudyRoles } from '@itmat-broker/itmat-types'; -import { v4 as uuid } from 'uuid'; -import { errorCodes } from '../utils/errors'; -import { PermissionCore } from './permissionCore'; -import { validate } from '@ideafast/idgen'; -import type { Filter } 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 } 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: string; - possibleValues?: ICategoricalOption[] - unit?: string - comments?: string - metadata?: Record -} - -export interface EditFieldInput { - fieldId: string; - fieldName: string; - tableName?: string; - dataType: string; - possibleValues?: ICategoricalOption[] - 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (requester.type !== enumUserTypes.ADMIN && !roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - 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, - createdBy: study.life.createdUser, - deleted: study.life.deletedTime - }; - } - - 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 roles = await this.permissionCore.getRolesOfUser(requester, project.studyId); - if (requester.type !== enumUserTypes.ADMIN && !roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - 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, 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const study = await this.findOneStudy_throwErrorIfNotExist(studyId); - - // 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); - if (versionId === null) { - availableDataVersions.push(null); - } - const matchFilter: Filter[] = []; - for (const role of roles) { - for (const dataPermission of role.dataPermissions) { - if (permissionString[enumDataAtomicPermissions.READ].includes(dataPermission.permission)) { - for (const re of dataPermission.fields) { - matchFilter.push({ fieldId: { $regex: re }, dataVersion: { $in: availableDataVersions } }); - } - } - } - } - const fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { $or: matchFilter } - }, { - $sort: { 'life.createdTime': -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - 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) { - 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (requester.type !== enumUserTypes.ADMIN && !roles.length) { 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 (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 getDataRecords(requester: IUserWithoutToken | undefined, queryString: IQueryString, studyId: string, versionId: string | null | undefined) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - - /* user can get study if he has readonly permission */ - const roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (requester.type !== enumUserTypes.ADMIN && !roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const study = await this.findOneStudy_throwErrorIfNotExist(studyId); - - let availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - let fieldRecords: IField[] = []; - let result; - // we obtain the data by different requests - // admin used will not filtered by metadata filters - if (versionId !== undefined) { - if (versionId === null) { - availableDataVersions.push(null); - } else if (versionId === '-1') { - availableDataVersions = availableDataVersions.length !== 0 ? [availableDataVersions[availableDataVersions.length - 1]] : []; - } else { - availableDataVersions = [versionId]; - } - } - if (requester.type === enumUserTypes.ADMIN) { - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: studyId, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { 'life.createdTime': -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(studyId, roles, fieldRecords, availableDataVersions, queryString.data_requested ?? []); - result = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - } else { - const matchFilter: Filter[] = []; - for (const role of roles) { - for (const dataPermission of role.dataPermissions) { - if (permissionString[enumDataAtomicPermissions.READ].includes(dataPermission.permission)) { - for (const re of dataPermission.fields) { - matchFilter.push({ fieldId: { $regex: re }, dataVersion: { $in: availableDataVersions } }); - } - } - } - } - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { $or: matchFilter } - }, { - $sort: { 'life.createdTime': -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - const pipeline = buildPipeline(studyId, roles, fieldRecords, availableDataVersions, queryString.data_requested ?? []); - result = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - } - // post processing the data - // The following code is used for IDEAFAST data type only, use trpc APIs for other datasets - const groupedResult: IGroupedData = {}; - for (let i = 0; i < result.length; i++) { - const { m_subjectId, m_visitId } = result[i].properties; - const { 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][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], 'life.deletedTime': 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 roles = await this.permissionCore.getRolesOfUser(requester, study.id); - - if (!roles.length) { return []; } - - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - availableDataVersions.push(null); - let fieldRecords: IField[] = []; - let dataRecords: IData[] = []; - - const matchFilter: Filter[] = []; - for (const role of roles) { - for (const dataPermission of role.dataPermissions) { - if (permissionString[enumDataAtomicPermissions.READ].includes(dataPermission.permission)) { - for (const re of dataPermission.fields) { - matchFilter.push({ fieldId: { $regex: re }, dataVersion: { $in: availableDataVersions } }); - } - } - } - } - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { $or: matchFilter, dataType: enumDataTypes.FILE } - }, { - $sort: { 'life.createdTime': -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - const pipeline = buildPipeline(study.id, roles, fieldRecords, availableDataVersions); - dataRecords = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - const files = await this.db.collections.files_collection.find({ id: { $in: dataRecords.map(el => String(el.value)) } }).toArray(); - return files.map(el => { - return { - ...el, - uploadTime: el.life.createdTime, - uploadedBy: el.life.createdUser - }; - }); - } - - public async getStudySubjects(requester: IUserWithoutToken | undefined, study: IStudy) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const roles = await this.permissionCore.getRolesOfUser(requester, study.id); - if (!roles.length) { - return [[], []]; - } - return [[], []]; - } - - public async getStudyVisits(requester: IUserWithoutToken | undefined, study: IStudy) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const roles = await this.permissionCore.getRolesOfUser(requester, study.id); - if (!roles.length) { - return [[], []]; - } - return [[], []]; - } - - public async getStudyNumOfRecords(requester: IUserWithoutToken | undefined, study: IStudy) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const roles = await this.permissionCore.getRolesOfUser(requester, study.id); - if (!roles.length) { - return [0, 0]; - } - return [0, 0]; - } - - 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 roles = await this.permissionCore.getRolesOfUser(requester, project.studyId); - if (!roles.length) { - return []; - } - return []; - } - - 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 study = await this.db.collections.studies_collection.findOne({ id: project.studyId }); - if (!study) { - return []; - } - const roles = await this.permissionCore.getRolesOfUser(requester, study.id); - - if (requester.type !== enumUserTypes.ADMIN && !roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - - const availableDataVersions: Array = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); - availableDataVersions.push(null); - let fieldRecords: IField[] = []; - let dataRecords: IData[] = []; - - if (requester.type === enumUserTypes.ADMIN) { - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { studyId: study.id, dataVersion: { $in: availableDataVersions } } - }, { - $sort: { 'life.createdTime': -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - const pipeline = buildPipeline(study.id, roles, fieldRecords, availableDataVersions); - dataRecords = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - } else { - const matchFilter: Filter[] = []; - for (const role of roles) { - for (const dataPermission of role.dataPermissions) { - if (permissionString[enumDataAtomicPermissions.READ].includes(dataPermission.permission)) { - for (const re of dataPermission.fields) { - matchFilter.push({ fieldId: { $regex: re }, dataVersion: { $in: availableDataVersions } }); - } - } - } - } - fieldRecords = await this.db.collections.field_dictionary_collection.aggregate([{ - $match: { $or: matchFilter } - }, { - $sort: { 'life.createdTime': -1 } - }, { - $group: { - _id: '$fieldId', - doc: { $first: '$$ROOT' } - } - }, { - $replaceRoot: { - newRoot: '$doc' - } - }, { - $sort: { fieldId: 1 } - }]).toArray(); - const pipeline = buildPipeline(study.id, roles, fieldRecords, availableDataVersions); - dataRecords = await this.db.collections.data_collection.aggregate(pipeline, { allowDiskUse: true }).toArray(); - } - return await this.db.collections.files_collection.find({ id: { $in: dataRecords.map(el => String(el.value)) } }).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 roles = await this.permissionCore.getRolesOfUser(requester, project.studyId); - if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - summary['subjects'] = []; - summary['visits'] = []; - summary['standardizationTypes'] = []; - return summary; - } - - public async getProjectPatientMapping(requester: IUserWithoutToken | undefined, project: IProject) { - if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); - } - const roles = await this.permissionCore.getRolesOfUser(requester, project.studyId); - if (!roles.length) { - return null; - } - - /* 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) { - /* 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 !== enumUserTypes.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, - currentDataVersion: -1, - dataVersions: [], - description: description, - ontologyTrees: [], - life: { - createdTime: Date.now(), - createdUser: requester.id, - deletedTime: null, - deletedUser: null - }, - metadata: { - type: type - } - }; - await this.db.collections.studies_collection.insertOne(study); - return { - ...study, - type: type - }; - } - - public async validateAndGenerateFieldEntry(fieldEntry: CreateFieldInput, 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 - 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 === 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.`); - } - 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: null, - dataType: fieldEntry.dataType, - possibleValues: fieldEntry.dataType === enumDataTypes.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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { - 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 (!(await this.permissionCore.checkFieldPermission(roles, oneFieldInput, enumDataAtomicPermissions.WRITE))) { - isError = true; - response.push({ successful: false, code: errorCodes.NO_PERMISSION_ERROR, description: 'You do not have permissions to create this field.' }); - continue; - } - 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; - } else { - response.push({ successful: true, description: `Field ${oneFieldInput.fieldId}-${oneFieldInput.fieldName} is created successfully.` }); - } - // // construct the rest of the fields - if (!isError) { - 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; - } - })(), - properties: oneFieldInput.fieldId.startsWith('Device') ? [{ - name: 'participantId', - required: true - }, { - name: 'deviceId', - required: true - }, { - name: 'startDate', - required: true - }, { - name: 'endDate', - required: true - }] : [{ - name: 'm_subjectId', - required: true - }, { - name: 'm_visitId', - required: true - }], - categoricalOptions: oneFieldInput.dataType === 'cat' ? oneFieldInput.possibleValues : undefined, - unit: oneFieldInput.unit, - comments: oneFieldInput.comments, - dataVersion: null, - life: { - createdTime: Date.now(), - createdUser: requester.id, - deletedTime: null, - deletedUser: null - }, - metadata: { - tableName: oneFieldInput.tableName - } - }; - bulk.insert(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 !== 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 }); - 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 = (() => { - 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.metadata['tableName'] = fieldInput.tableName; - } - if (fieldInput.unit) { - searchField.unit = fieldInput.unit; - } - if (fieldInput.possibleValues) { - searchField.categoricalOptions = fieldInput.possibleValues; - } - if (fieldInput.tableName) { - searchField.metadata['tableName'] = fieldInput.tableName; - } - if (fieldInput.comments) { - searchField.comments = fieldInput.comments; - } - - 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: 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; - } - - 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - - if (!(await this.permissionCore.checkFieldPermission(roles, { fieldId: fieldId }, enumDataAtomicPermissions.DELETE))) { - 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].life.deletedTime !== null) { - throw new GraphQLError('Field does not exist.', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - - const fieldEntry: IField = { - id: uuid(), - studyId: studyId, - fieldName: searchField[0].fieldName, - fieldId: searchField[0].fieldId, - dataType: searchField[0].dataType, - categoricalOptions: searchField[0].categoricalOptions, - unit: searchField[0].unit, - comments: searchField[0].comments, - dataVersion: null, - 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 this.fieldTypeConverter([fieldEntry])[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 !== enumUserTypes.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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { - 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: { 'life.createdTime': -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.life.deletedTime === null); - const response = (await this.uploadOneDataClip(requester, studyId, roles, fieldsList, data)); - - 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { - 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('properties.m_subjectId', { studyId: studyId })); - } else { - validSubjects = subjectIds; - } - if (visitIds === undefined || visitIds === null || visitIds.length === 0) { - validVisits = (await this.db.collections.data_collection.distinct('properties.m_visitId', { 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) { - const dataEntry: IData = { - studyId: studyId, - fieldId: fieldId, - value: null, - properties: { - m_subjectId: subjectId, - m_visitId: visitId - }, - dataVersion: null, - life: { - createdTime: Date.now(), - createdUser: requester.id, - deletedTime: Date.now(), - deletedUser: requester.id - }, - id: uuid(), - metadata: {} - }; - if (!(await this.permissionCore.checkDataPermission(roles, dataEntry, enumDataAtomicPermissions.DELETE))) { - response.push({ successful: false, description: `SubjectId-${subjectId}:visitId-${visitId}:fieldId-${fieldId} does not have permission to delete.` }); - continue; - } - bulk.insert(dataEntry); - 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 !== enumUserTypes.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(); - - // 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 - } - }); - // 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, - 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 (newDataVersion === null) { - throw new GraphQLError('No matched or modified records', { extensions: { code: errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY } }); - } - return { - ...newDataVersion, - updateDate: newDataVersion.life.createdTime.toString(), - contentId: uuid() - }; - } - - public async uploadOneDataClip(requester: IUserWithoutToken, studyId: string, roles: IRole[], fieldList: Partial[], data: IDataClip[]): 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; - } - const dataEntry: IData = { - id: uuid(), - studyId: studyId, - fieldId: dataClip.fieldId, - dataVersion: null, - value: null, - properties: { - m_subjectId: dataClip.subjectId, - m_visitId: dataClip.visitId - }, - life: { - createdTime: Date.now(), - createdUser: requester.id, - deletedTime: null, - deletedUser: null - }, - metadata: {} - }; - - if (!(await this.permissionCore.checkDataPermission(roles, dataEntry, enumDataAtomicPermissions.WRITE))) { - 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 enumDataTypes.DECIMAL: {// 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 enumDataTypes.INTEGER: {// 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 enumDataTypes.BOOLEAN: {// 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 enumDataTypes.STRING: { - 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 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; - } - 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 enumDataTypes.JSON: { - parsedValue = dataClip.value; - break; - } - case enumDataTypes.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 enumDataTypes.CATEGORICAL: { - if (!fieldInDb.categoricalOptions) { - error = `Field ${dataClip.fieldId}: Cannot parse as categorical, possible values not defined.`; - break; - } - 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 { - 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}` }); - } - 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: {} - }); - - 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 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) { - 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({ '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 { - 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; - } - 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); - - 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 - 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { 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 roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { 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 */ - const roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { 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: { 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 !== enumUserTypes.ADMIN) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - 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 } }); - } - /* 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, '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 } }); - - /* delete all roles related to the study */ - await this.db.collections.roles_collection.updateMany({ studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester.id } }); - - /* delete all files belong to the study*/ - await this.db.collections.files_collection.updateMany({ studyId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': 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 */ - const roles = await this.permissionCore.getRolesOfUser(requester, project.studyId); - if (!roles.length) { 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.db.collections.roles_collection.updateMany({ projectId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': timestamp, 'life.deletedUser': requester.id } }); - 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 */ - const roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (requester.type !== enumUserTypes.ADMIN && roles.every(el => el.studyRole !== enumStudyRoles.STUDY_MANAGER)) { - 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/GraphQLCore/userCore.ts b/packages/itmat-cores/src/GraphQLCore/userCore.ts deleted file mode 100644 index 6d5d5c353..000000000 --- a/packages/itmat-cores/src/GraphQLCore/userCore.ts +++ /dev/null @@ -1,896 +0,0 @@ -import bcrypt from 'bcrypt'; -import { GraphQLError } from 'graphql'; -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'; -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?: enumUserTypes, - 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({ '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 } }); - } - 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 ? { '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) { - /* 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 - } - }, - 'life.deletedTime': 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 !== 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 === 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({ 'life.deletedTime': 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) => { - 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 }, 'life.deletedTime': 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 !== enumUserTypes.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 !== enumUserTypes.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 !== enumUserTypes.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 ? { '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 */ - 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: 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 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 ? { '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 */ - 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({ 'life.deletedTime': 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 === enumUserTypes.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, - createdAt: filteredResult.life.createdTime, - deleted: filteredResult.life.deletedTime - }); - }); - }); - } - 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, '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, '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 } }); - } - - /* 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: enumUserTypes.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 }, - expiredAt, - resetPasswordRequests: [], - metadata: {}, - life: { - createdTime: createdAt, - createdUser: enumReservedUsers.SYSTEM, - deletedTime: null, - deletedUser: 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 !== enumUserTypes.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, '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( - { - 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 - } - }, - 'life.deletedTime': 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?: 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); - } - if (password && !passwordIsGoodEnough(password)) { - throw new GraphQLError('Password has to be at least 8 character long.'); - } - if (requester.type !== enumUserTypes.ADMIN && requester.id !== id) { - throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); - } - let result; - 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'); - } - } - - const fieldsToUpdate: UpdateFilter = { - type, - firstname, - lastname, - username, - email, - emailNotificationsActivated, - emailNotificationsStatus, - password, - description, - organisation, - expiredAt, - 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 !== 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.'); - } - - 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, 'life.deletedTime': 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, jwtPubkey: string, jwtSeckey: string }): Promise { - const { pubkey, associatedUserId, jwtPubkey, jwtSeckey } = pubkeyobj; - const entry: IPubkey = { - id: uuid(), - pubkey, - associatedUserId, - jwtPubkey, - jwtSeckey, - refreshCounter: 0, - life: { - createdTime: Date.now(), - createdUser: associatedUserId, - deletedTime: null, - deletedUser: null - }, - metadata: {} - }; - - 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 index 10591b4cc..d7fcef1db 100644 --- a/packages/itmat-cores/src/database/database.ts +++ b/packages/itmat-cores/src/database/database.ts @@ -1,4 +1,4 @@ -import type { IField, IFile, IJobEntry, ILog, IOrganisation, IProject, IPubkey, IQueryEntry, IRole, IStudy, IUser, IStandardization, IConfig, IData, IDrive, ICache, IDomain } from '@itmat-broker/itmat-types'; +import type { IField, IFile, IJobEntry, ILog, IOrganisation, IProject, IPubkey, IQueryEntry, IRole, IStudy, IUser, IStandardization, IConfig, IData, IDrive, ICache, IDomain, IOntologyTree, IBase } from '@itmat-broker/itmat-types'; import { Database as DatabaseBase, IDatabaseBaseConfig } from '@itmat-broker/itmat-commons'; import type { Collection } from 'mongodb'; @@ -18,10 +18,12 @@ export interface IDatabaseConfig extends IDatabaseBaseConfig { data_collection: string, standardizations_collection: string, configs_collection: string, + ontologies_collection: string, drives_collection: string, colddata_collection: string, cache_collection: string, - domains_collection: string + domains_collection: string, + doc_collection: string }; } @@ -40,9 +42,12 @@ export interface IDatabaseCollectionConfig { data_collection: Collection, standardizations_collection: Collection, configs_collection: Collection, + ontologies_collection: Collection, drives_collection: Collection, colddata_collection: Collection, cache_collection: Collection, - domains_collection: Collection + domains_collection: Collection, + // TODO: Implemet doc feature + docs_collection: Collection } export type DBType = DatabaseBase; diff --git a/packages/itmat-cores/src/index.ts b/packages/itmat-cores/src/index.ts index 981ea06be..f3f22f41b 100644 --- a/packages/itmat-cores/src/index.ts +++ b/packages/itmat-cores/src/index.ts @@ -1,14 +1,5 @@ // 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'; +export * from './utils/GraphQL'; // TRPCCore export * from './trpcCore/configCore'; export * from './trpcCore/driveCore'; @@ -20,6 +11,9 @@ export * from './trpcCore/transformationCore'; export * from './trpcCore/permissionCore'; export * from './trpcCore/logCore'; export * from './trpcCore/domainCore'; +export * from './trpcCore/organisationCore'; +export * from './trpcCore/jobCore'; +export * from './trpcCore/standardizationCore'; export * from './rest/fileDownload'; export * from './authentication/pubkeyAuthentication'; export * from './log/logPlugin'; diff --git a/packages/itmat-cores/src/rest/fileDownload.ts b/packages/itmat-cores/src/rest/fileDownload.ts index 3cb07be9a..485fb7df8 100644 --- a/packages/itmat-cores/src/rest/fileDownload.ts +++ b/packages/itmat-cores/src/rest/fileDownload.ts @@ -1,21 +1,19 @@ import { Request, Response } from 'express'; -import { IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { CoreError, IUserWithoutToken, enumCoreErrors } 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 '../GraphQLCore/permissionCore'; import { DBType } from '../database/database'; import { ObjectStore } from '@itmat-broker/itmat-commons'; +import { TRPCPermissionCore } from '../trpcCore/permissionCore'; export class FileDownloadController { - private _permissionCore: PermissionCore; + private _permissionCore: TRPCPermissionCore; private _db: DBType; private _objStore: ObjectStore; constructor(db: DBType, objStore: ObjectStore) { this._db = db; - this._permissionCore = new PermissionCore(db); + this._permissionCore = new TRPCPermissionCore(db); this._objStore = objStore; } @@ -33,12 +31,18 @@ export class FileDownloadController { if (decodedPayload !== null && !(typeof decodedPayload === 'string')) { pubkey = decodedPayload['publicKey']; } else { - throw new GraphQLError('JWT verification failed.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'JWT verification failed.' + ); } // verify the JWT jwt.verify(token, pubkey, function (error) { if (error) { - throw new GraphQLError('JWT verification failed.', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT, error } }); + throw new CoreError( + enumCoreErrors.AUTHENTICATION_ERROR, + 'JWT verification failed.' + ); } }); associatedUser = await userRetrieval(this._db, pubkey); @@ -48,14 +52,14 @@ export class FileDownloadController { } try { /* download file */ - const file = await this._db.collections.files_collection.findOne({ id: requestedFile, deleted: null }); + const file = await this._db.collections.files_collection.findOne({ 'id': requestedFile, 'life.deletedTime': null }); if (!file || !file.studyId) { res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); return; } // check target field exists - const roles = await this._permissionCore.getRolesOfUser(associatedUser, file.studyId); + const roles = await this._permissionCore.getRolesOfUser(associatedUser, associatedUser.id, file.studyId); if (!roles.length) { res.status(404).json({ error: 'File not found or you do not have the necessary permission.' }); return; @@ -68,6 +72,7 @@ export class FileDownloadController { stream.pipe(res, { end: true }); return; } catch (e) { + console.log(e); res.status(500).json(e); return; } diff --git a/packages/itmat-cores/src/trpcCore/dataCore.ts b/packages/itmat-cores/src/trpcCore/dataCore.ts index 9e89f7f4e..ed7e2c920 100644 --- a/packages/itmat-cores/src/trpcCore/dataCore.ts +++ b/packages/itmat-cores/src/trpcCore/dataCore.ts @@ -1,4 +1,4 @@ -import { IField, enumDataTypes, ICategoricalOption, IValueVerifier, IGenericResponse, enumConfigType, defaultSettings, IAST, enumConditionOps, enumFileTypes, enumFileCategories, IFieldProperty, IFile, IData, enumASTNodeTypes, IRole, IStudyConfig, enumUserTypes, enumCoreErrors, IUserWithoutToken, CoreError, enumDataAtomicPermissions, enumDataTransformationOperation, enumCacheStatus, enumCacheType, FileUpload } from '@itmat-broker/itmat-types'; +import { IField, enumDataTypes, ICategoricalOption, IValueVerifier, IGenericResponse, enumConfigType, defaultSettings, IAST, enumConditionOps, enumFileTypes, enumFileCategories, IFieldProperty, IFile, IData, enumASTNodeTypes, IRole, IStudyConfig, enumUserTypes, enumCoreErrors, IUserWithoutToken, CoreError, enumDataAtomicPermissions, enumDataTransformationOperation, enumCacheStatus, enumCacheType, FileUpload, enumStudyRoles } from '@itmat-broker/itmat-types'; import { v4 as uuid } from 'uuid'; import { DBType } from '../database/database'; import { TRPCFileCore } from './fileCore'; @@ -113,7 +113,6 @@ export class TRPCDataCore { } else { availableDataVersions = (study.currentDataVersion === -1 ? [] : study.dataVersions.filter((__unused__el, index) => index <= study.currentDataVersion)).map(el => el.id); } - const fields = await this.db.collections.field_dictionary_collection.aggregate([{ $match: { $and: [ @@ -980,7 +979,6 @@ export class TRPCDataCore { 'Study config not found.' ); } - let fieldIds: string[] | undefined = selectedFieldIds; let availableDataVersions: Array = []; if (dataVersion === null) { @@ -998,15 +996,14 @@ export class TRPCDataCore { const fields = await this.db.collections.field_dictionary_collection.find({ studyId: studyId, fieldId: { $in: fieldIds } }).toArray(); fieldIds = fields.filter(el => el.dataType === enumDataTypes.FILE).map(el => el.fieldId); } - - const fileDataRecords = await this.getData( + const fileDataRecords = (await this.getData( requester, studyId, fieldIds, availableDataVersions, undefined, false - ); + ))['raw']; if (!Array.isArray(fileDataRecords)) { return []; } @@ -1082,26 +1079,28 @@ export class TRPCDataCore { } }); } - aggregation.push({ operationName: enumDataTransformationOperation.GROUP, params: { keys: keys, skipUnmatch: false } }); - aggregation.push({ operationName: enumDataTransformationOperation.LEAVEONE, params: { scoreFormula: { type: enumASTNodeTypes.VARIABLE, operator: null, value: 'life.createdTime', parameters: {}, children: null }, isDescend: true } }); - aggregation.push({ - operationName: enumDataTransformationOperation.FILTER, params: { - filters: { - deleted: [{ - formula: { - type: enumASTNodeTypes.VARIABLE, - operation: null, - value: 'life.deletedTime', - parameter: {}, - children: null - }, - condition: enumConditionOps.GENERALISNULL, - value: '', - parameters: {} - }] + if (keys.length > 0) { + aggregation.push({ operationName: enumDataTransformationOperation.GROUP, params: { keys: keys, skipUnmatch: false } }); + aggregation.push({ operationName: enumDataTransformationOperation.LEAVEONE, params: { scoreFormula: { type: enumASTNodeTypes.VARIABLE, operator: null, value: 'life.createdTime', parameters: {}, children: null }, isDescend: true } }); + aggregation.push({ + operationName: enumDataTransformationOperation.FILTER, params: { + filters: { + deleted: [{ + formula: { + type: enumASTNodeTypes.VARIABLE, + operation: null, + value: 'life.deletedTime', + parameter: {}, + children: null + }, + condition: enumConditionOps.GENERALISNULL, + value: '', + parameters: {} + }] + } } - } - }); + }); + } return aggregation; } @@ -1123,6 +1122,14 @@ export class TRPCDataCore { ); } + const roles = await this.permissionCore.getRolesOfUser(requester, requester.id, studyId); + if (roles.length === 0 || roles.every(el => el.studyRole !== enumStudyRoles.STUDY_MANAGER)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + const study = await this.db.collections.studies_collection.findOne({ 'id': studyId, 'life.deletedTime': null }); if (!study) { throw new CoreError( @@ -1142,8 +1149,8 @@ export class TRPCDataCore { life: { createdTime: Date.now(), createdUser: requester.id, - deletedTime: null, - deletedUser: null + deletedTime: Date.now(), + deletedUser: requester.id }, metadata: {} }); diff --git a/packages/itmat-cores/src/trpcCore/fileCore.ts b/packages/itmat-cores/src/trpcCore/fileCore.ts index 3c2ac8f1e..0b9e9e53e 100644 --- a/packages/itmat-cores/src/trpcCore/fileCore.ts +++ b/packages/itmat-cores/src/trpcCore/fileCore.ts @@ -1,9 +1,10 @@ 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 crypto from 'crypto'; import { DBType } from '../database/database'; import { ObjectStore } from '@itmat-broker/itmat-commons'; import { makeGenericReponse } from '../utils'; +import { PassThrough } from 'stream'; /** * This class provides methods to interact with files. @@ -102,23 +103,25 @@ export class TRPCFileCore { const fileUri = uuid(); const hash = crypto.createHash('sha256'); - const fileStream = fileUpload.createReadStream(); + const stream = fileUpload.createReadStream(); + const chunks: Buffer[] = []; return new Promise((resolve, reject) => { let fileSize = 0; - fileStream.on('data', (chunk: BinaryLike) => { + stream.on('data', (chunk: Buffer) => { hash.update(chunk); - fileSize += (chunk as Buffer).length; // Asserting the chunk as Buffer for length property + chunks.push(chunk); + fileSize += chunk.length; }); - fileStream.on('end', () => { - this.handleFileUpload(fileSize, fileSizeLimit, hash, fileUpload, defaultFileBucketId, fileUri, requester, studyId, userId, fileType, fileCategory, description, properties) + stream.on('end', () => { + this.processFileChunks(chunks, hash, fileSize, fileSizeLimit, fileUpload, defaultFileBucketId, fileUri, requester, studyId, userId, fileType, fileCategory, description, properties) .then(fileEntry => resolve(fileEntry)) .catch(error => reject(error)); }); - fileStream.on('error', (err) => { + stream.on('error', (err) => { reject(new CoreError( enumCoreErrors.FILE_STREAM_ERROR, 'Error reading file stream: ' + err.message @@ -127,10 +130,34 @@ export class TRPCFileCore { }); } + private async processFileChunks( + chunks: Buffer[], + hash: crypto.Hash, + fileSize: number, + fileSizeLimit: number, + fileUpload: FileUpload, + defaultFileBucketId: string, + fileUri: string, + requester: IUserWithoutToken, + studyId: string | null, + userId: string | null, + fileType: enumFileTypes, + fileCategory: enumFileCategories, + description?: string, + properties?: Record + ): Promise { + const buffer = Buffer.concat(chunks); + const hashString = hash.digest('hex'); + const stream = new PassThrough(); + stream.end(buffer); + return this.handleFileUpload(fileSize, fileSizeLimit, stream, hashString, fileUpload, defaultFileBucketId, fileUri, requester, studyId, userId, fileType, fileCategory, description, properties); + } + private async handleFileUpload( fileSize: number, fileSizeLimit: number, - hash: ReturnType, + stream: PassThrough, + hashString: string, fileUpload: FileUpload, defaultFileBucketId: string, fileUri: string, @@ -143,20 +170,20 @@ export class TRPCFileCore { properties?: Record ): Promise { if (fileSize > fileSizeLimit) { - throw new Error('File size exceeds the limit.'); + throw new CoreError( + enumCoreErrors.UNQUALIFIED_ERROR, + 'File size exceeds the limit.' + ); } - const hashString = hash.digest('hex'); - const uploadStream = fileUpload.createReadStream(); - - await this.objStore.uploadFile(uploadStream, defaultFileBucketId, fileUri); + await this.objStore.uploadFile(stream, 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. + fileName: fileUpload.filename, + fileSize: fileSize, description: description, uri: fileUri, hash: hashString, diff --git a/packages/itmat-cores/src/trpcCore/jobCore.ts b/packages/itmat-cores/src/trpcCore/jobCore.ts new file mode 100644 index 000000000..893cfe005 --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/jobCore.ts @@ -0,0 +1,22 @@ +import { CoreError, IJobEntry, enumCoreErrors } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +enum JOB_TYPE { + QUERY_EXECUTION = 'QUERY_EXECUTION', + DATA_EXPORT = 'DATA_EXPORT' +} + +export class TRPCJobCore { + db: DBType; + constructor(db: DBType) { + this.db = db; + } + + public async createJob(): Promise { + throw new CoreError( + enumCoreErrors.NOT_IMPLEMENTED, + enumCoreErrors.NOT_IMPLEMENTED + ); + } +} diff --git a/packages/itmat-cores/src/trpcCore/logCore.ts b/packages/itmat-cores/src/trpcCore/logCore.ts index 9bb326cf6..a888a5277 100644 --- a/packages/itmat-cores/src/trpcCore/logCore.ts +++ b/packages/itmat-cores/src/trpcCore/logCore.ts @@ -60,7 +60,7 @@ export class TRPCLogCore { $lte: timeRange[1] }; } - let logs; + let logs: ILog[]; if (indexRange) { logs = await this.db.collections.log_collection.find(filters).skip(indexRange[0]).limit(indexRange[1] - indexRange[0]).toArray(); } else { diff --git a/packages/itmat-cores/src/trpcCore/organisationCore.ts b/packages/itmat-cores/src/trpcCore/organisationCore.ts new file mode 100644 index 000000000..22ded2608 --- /dev/null +++ b/packages/itmat-cores/src/trpcCore/organisationCore.ts @@ -0,0 +1,198 @@ +import { v4 as uuid } from 'uuid'; +import { CoreError, FileUpload, IFile, IOrganisation, IUserWithoutToken, enumCoreErrors, enumFileCategories, enumFileTypes, enumUserTypes } from '@itmat-broker/itmat-types'; +import { DBType } from '../database/database'; +import { TRPCFileCore } from './fileCore'; +import { UpdateFilter } from 'mongodb'; +import { makeGenericReponse } from '../utils'; + +export class TRPCOrganisationCore { + db: DBType; + fileCore: TRPCFileCore; + constructor(db: DBType, fileCore: TRPCFileCore) { + this.db = db; + this.fileCore = fileCore; + } + + /** + * Get organisations. + * + * @param requester - The requester. + * @param organisationId - The organisation id. + * + * @returns - IOrganisation[] + */ + public async getOrganisations(requester: IUserWithoutToken | undefined, organisationId?: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + return organisationId ? await this.db.collections.organisations_collection.find({ 'id': organisationId, 'life.deletedTime': null }).toArray() : await this.db.collections.organisations_collection.find({ 'life.deletedTime': null }).toArray(); + } + + /** + * Creates a new organisation. + * + * @param requester - The requester. + * @param name - The name of the organisation. + * @param shortname - The shortname of the organisation. + * @param profile - The profile of the organisation. + * + * @returns - IOrganisation + */ + public async createOrganisation(requester: IUserWithoutToken | undefined, name: string, shortname?: string, profile?: FileUpload) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + if (requester.type !== enumUserTypes.ADMIN) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + + const organisation = await this.db.collections.organisations_collection.findOne({ 'name': name, 'life.deletedTime': null }); + if (organisation) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Organisation already exists.' + ); + } + + let fileEntry: IFile | undefined = undefined; + 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, null, profile, + enumFileTypes[(profile.filename.split('.').pop() || '').toUpperCase() as keyof typeof enumFileTypes], enumFileCategories.PROFILE_FILE); + } + + const entry: IOrganisation = { + id: uuid(), + name: name, + shortname: shortname, + profile: fileEntry ? fileEntry.id : undefined, + life: { + createdTime: Date.now(), + createdUser: requester.id, + deletedTime: null, + deletedUser: null + }, + metadata: {} + }; + + await this.db.collections.organisations_collection.insertOne(entry); + return entry; + } + + /** + * Edits an organisation. + * + * @param requester - The requester. + * @param organisationId - The organisation id. + * @param name - The name of the organisation. + * @param shortname - The shortname of the organisation. + * @param profile - The profile of the organisation. + * + * @returns - IOrganisation + */ + public async editOrganisation(requester: IUserWithoutToken | undefined, organisationId: string, name?: string, shortname?: string, profile?: FileUpload) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + if (requester.type !== enumUserTypes.ADMIN) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + + const organisation = await this.db.collections.organisations_collection.findOne({ 'id': organisationId, 'life.deletedTime': null }); + if (!organisation) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Organisation not found.' + ); + } + + const updateFilter: UpdateFilter = {}; + if (name) { + const org = await this.db.collections.organisations_collection.findOne({ 'name': name, 'life.deletedTime': null }); + if (org && org.id !== organisationId) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Organisation already exists.' + ); + } + updateFilter['name'] = name; + } + if (shortname) { + updateFilter['shortname'] = shortname; + } + if (profile) { + if (!Object.keys(enumFileTypes).includes((profile?.filename?.split('.').pop() || '').toUpperCase())) { + throw new CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'File type not supported.' + ); + } + const fileEntry = await this.fileCore.uploadFile(requester, null, null, profile, + enumFileTypes[(profile.filename.split('.').pop() || '').toUpperCase() as keyof typeof enumFileTypes], enumFileCategories.PROFILE_FILE); + updateFilter['profile'] = fileEntry.id; + } + + await this.db.collections.organisations_collection.updateOne({ id: organisationId }, { $set: updateFilter }); + + return makeGenericReponse(organisationId, true, undefined, 'Organisation updated successfully.'); + } + + /** + * Deletes an organisation. + * + * @param requester - The requester. + * @param organisationId - The organisation id. + * + * @returns - IOrganisation + */ + public async deleteOrganisation(requester: IUserWithoutToken | undefined, organisationId: string) { + if (!requester) { + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); + } + + if (requester.type !== enumUserTypes.ADMIN) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + + const organisation = await this.db.collections.organisations_collection.findOne({ 'id': organisationId, 'life.deletedTime': null }); + if (!organisation) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Organisation not found.' + ); + } + + await this.db.collections.organisations_collection.updateOne({ id: organisationId }, { $set: { 'life.deletedTime': Date.now(), 'life.deletedUser': requester.id } }); + + return makeGenericReponse(organisationId, true, undefined, 'Organisation deleted successfully.'); + } +} \ No newline at end of file diff --git a/packages/itmat-cores/src/trpcCore/permissionCore.ts b/packages/itmat-cores/src/trpcCore/permissionCore.ts index fcf64a2ab..2b876ed5b 100644 --- a/packages/itmat-cores/src/trpcCore/permissionCore.ts +++ b/packages/itmat-cores/src/trpcCore/permissionCore.ts @@ -9,6 +9,24 @@ export class TRPCPermissionCore { this.db = db; } + /** + * Get the roles of a user. + * + * @param roleId - The id of the role. + * + * @returns - IRole + */ + public async getUsersOfRole(roleId: string) { + const role = await this.db.collections.roles_collection.findOne({ 'id': roleId, 'life.deletedTime': null }); + if (!role) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } + return await this.db.collections.users_collection.find({ id: { $in: role.users } }, { projection: { _id: 0, password: 0, otpSecret: 0 } }).toArray(); + } + /** * Get the roles of a user. * diff --git a/packages/itmat-cores/src/GraphQLCore/standardizationCore.ts b/packages/itmat-cores/src/trpcCore/standardizationCore.ts similarity index 80% rename from packages/itmat-cores/src/GraphQLCore/standardizationCore.ts rename to packages/itmat-cores/src/trpcCore/standardizationCore.ts index 10ed615b0..2695736f8 100644 --- a/packages/itmat-cores/src/GraphQLCore/standardizationCore.ts +++ b/packages/itmat-cores/src/trpcCore/standardizationCore.ts @@ -1,50 +1,49 @@ -import { IProject, IStandardization, IUserWithoutToken } from '@itmat-broker/itmat-types'; +import { CoreError, IStandardization, IUserWithoutToken, enumCoreErrors, enumStudyRoles } 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'; +import { TRPCPermissionCore } from './permissionCore'; +import { TRPCStudyCore } from './studyCore'; /** * TODO: This file is not yet implemented. It is a placeholder for the standardization core. */ -export class StandarizationCore { +export class TRPCStandarizationCore { db: DBType; - permissionCore: PermissionCore; - studyCore: StudyCore; - constructor(db: DBType, objStore: ObjectStore) { + permissionCore: TRPCPermissionCore; + studyCore: TRPCStudyCore; + constructor(db: DBType, objStore: ObjectStore, permissionCore: TRPCPermissionCore, studyCore: TRPCStudyCore) { this.db = db; - this.permissionCore = new PermissionCore(db); - this.studyCore = new StudyCore(db, objStore); + this.permissionCore = permissionCore; + this.studyCore = studyCore; } 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); + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); } - 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; + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Either studyId or projectId should be provided.' + ); } /* check permission */ const roles = await this.permissionCore.getRolesOfUser(requester, studyId); if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } - const study = await this.studyCore.findOneStudy_throwErrorIfNotExist(modifiedStudyId); + const study = (await this.studyCore.getStudies(requester, studyId))[0]; + // 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 (versionId === null) { @@ -75,11 +74,19 @@ export class StandarizationCore { public async createStandardization(requester: IUserWithoutToken | undefined, studyId, standardization) { if (!requester) { - throw new GraphQLError(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + throw new CoreError( + enumCoreErrors.NOT_LOGGED_IN, + enumCoreErrors.NOT_LOGGED_IN + ); } /* check permission */ const roles = await this.permissionCore.getRolesOfUser(requester, studyId); - if (!roles.length) { throw new GraphQLError(errorCodes.NO_PERMISSION_ERROR); } + if (!roles.length || roles.every(el => el.studyRole !== enumStudyRoles.STUDY_MANAGER)) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + enumCoreErrors.NO_PERMISSION_ERROR + ); + } /* check study exists */ const studySearchResult = await this.db.collections.studies_collection.findOne({ id: studyId, deleted: null }); diff --git a/packages/itmat-cores/src/trpcCore/studyCore.ts b/packages/itmat-cores/src/trpcCore/studyCore.ts index 00c0e1f7a..dd2be3dc8 100644 --- a/packages/itmat-cores/src/trpcCore/studyCore.ts +++ b/packages/itmat-cores/src/trpcCore/studyCore.ts @@ -32,20 +32,21 @@ export class TRPCStudyCore { enumCoreErrors.NOT_LOGGED_IN ); } - - if (requester.type === 'ADMIN') { - return studyId ? await this.db.collections.studies_collection.find({ 'id': studyId, 'life.deletedTime': null }).toArray() : + let studies: IStudy[] = []; + if (requester.type === enumUserTypes.ADMIN) { + studies = 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, requester.id)).map(role => role.studyId); - - const query: Filter = { 'life.deletedTime': null }; - if (studyId) { - query.id = studyId; } else { - query.id = { $in: roleStudyIds }; + const roleStudyIds = (await this.permissionCore.getRolesOfUser(requester, requester.id)).map(role => role.studyId); + + const query: Filter = { 'life.deletedTime': null }; + if (studyId) { + query.id = studyId; + } else { + query.id = { $in: roleStudyIds }; + } + studies = await this.db.collections.studies_collection.find(query).toArray(); } - const studies = await this.db.collections.studies_collection.find(query).toArray(); if (studies.length === 0 && studyId) { throw new CoreError( enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, @@ -72,7 +73,7 @@ export class TRPCStudyCore { ); } - if (requester.type !== 'ADMIN') { + if (requester.type !== enumUserTypes.ADMIN) { throw new CoreError( enumCoreErrors.NO_PERMISSION_ERROR, 'Only admin can create a study.' @@ -80,7 +81,10 @@ export class TRPCStudyCore { } const studyId = uuid(); - const existing = await this.db.collections.studies_collection.findOne({ 'name': studyName, 'life.deletedTime': null }); + const existing = await this.db.collections.studies_collection.findOne({ + 'name': { $regex: new RegExp('^' + studyName + '$', 'i') }, + 'life.deletedTime': null + }); if (existing) { throw new CoreError( enumCoreErrors.CLIENT_MALFORMED_INPUT, diff --git a/packages/itmat-cores/src/trpcCore/userCore.ts b/packages/itmat-cores/src/trpcCore/userCore.ts index a0fd2caf5..032bd1fd7 100644 --- a/packages/itmat-cores/src/trpcCore/userCore.ts +++ b/packages/itmat-cores/src/trpcCore/userCore.ts @@ -10,7 +10,8 @@ import * as mfa from '../utils/mfa'; import QRCode from 'qrcode'; import tmp from 'tmp'; import { UpdateFilter } from 'mongodb'; -import { decryptEmail, encryptEmail, makeAESIv, makeAESKeySalt } from '..'; +import * as pubkeycrypto from '../utils/pubkeycrypto'; +import crypto from 'crypto'; export class TRPCUserCore { db: DBType; @@ -367,6 +368,13 @@ export class TRPCUserCore { ); } + if ((password || otpSecret) && requester.id !== userId) { + throw new CoreError( + enumCoreErrors.NO_PERMISSION_ERROR, + 'User can only edit his/her own account.' + ); + } + if (password && !passwordIsGoodEnough(password)) { throw new CoreError( enumCoreErrors.CLIENT_MALFORMED_INPUT, @@ -428,6 +436,22 @@ export class TRPCUserCore { setObj['email'] = email; } + if (firstname) { + setObj['firstname'] = firstname; + } + + if (lastname) { + setObj['lastname'] = lastname; + } + + if (description) { + setObj['description'] = description; + } + + if (emailNotificationsActivated) { + setObj['emailNotificationsActivated'] = emailNotificationsActivated; + } + if (organisation) { const org = await this.db.collections.organisations_collection.findOne({ 'id': organisation, 'life.deletedTime': null }); if (!org) { @@ -540,7 +564,7 @@ export class TRPCUserCore { 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' }); + await this.db.collections.users_collection.findOneAndUpdate({ 'id': userId, 'life.deletedTime': null }, { $set: { 'life.deletedTime': Date.now(), 'life.deletedUser': requester.id, 'password': 'DeletedUserDummyPassword', 'otpSecret': 'DeletedUserDummpOtpSecret' } }, { returnDocument: 'after' }); /* delete all user records in roles related to the study */ await this.db.collections.roles_collection.updateMany({ @@ -586,6 +610,52 @@ export class TRPCUserCore { } return await this.db.collections.pubkeys_collection.find({ 'associatedUserId': userId, 'life.deletedTime': null }).toArray(); } + + /** + * Generate a key pair for a user. + * + * @returns - The key pair. + */ + 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 }; + } + + /** + * Sign a message with a private key. + * + * @param privateKey - The private key. + * @param message - The message to be signed. + * + * @returns - The 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 CoreError( + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Error: private-key incorrect!' + ); + } + + } else { + messageToBeSigned = message; + } + const signature = pubkeycrypto.rsasigner(privateKey, messageToBeSigned); + return { signature: signature }; + } + /** * Register a pubkey to a user. * @@ -885,28 +955,23 @@ export class TRPCUserCore { /** * Ask for a request to extend account expiration time. Send notifications to user and admin. * - * @param userId - The id of the user. + * @param email - The email of the user. * * @return IGenericResponse */ - public async requestExpiryDate(requester: IUserWithoutToken | undefined, userId: string) { - if (!requester) { + public async requestExpiryDate(username?: string, email?: string) { + if ((!username && !email) || (username && email)) { 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.' + enumCoreErrors.CLIENT_MALFORMED_INPUT, + 'Either username or email should be provided.' ); } - const user = await this.db.collections.users_collection.findOne({ 'id': userId, 'life.deletedTime': null }); + + const user = username ? await this.db.collections.users_collection.findOne({ username }) : await this.db.collections.users_collection.findOne({ email }); 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.'); + return makeGenericReponse(email, false, undefined, 'User information is not correct.'); } /* send email to the DMP admin mailing-list */ await this.mailer.sendMail(formatEmailRequestExpiryDatetoAdmin({ @@ -922,7 +987,7 @@ export class TRPCUserCore { username: user.username })); - return makeGenericReponse(userId, true, undefined, 'Request successfully sent.'); + return makeGenericReponse(email, true, undefined, 'Request successfully sent.'); } /** @@ -956,7 +1021,7 @@ export class TRPCUserCore { 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.'); + return makeGenericReponse(undefined, true, undefined, enumCoreErrors.UNQUALIFIED_ERROR); } if (forgotPassword) { @@ -1068,7 +1133,7 @@ export class TRPCUserCore { 'Cannot log in. Please try again later.' ); } - resolve(filteredUser); + resolve(filteredUser as IUserWithoutToken); }); }); } @@ -1182,6 +1247,17 @@ export class TRPCUserCore { tmpobj.removeCallback(); return makeGenericReponse(); } + + /** + * Ping the server. This will refresh the session expire time. + * + * @returns - IGenericResponse + */ + public async recoverSessionExpireTime() { + return makeGenericReponse(); + } + + } function passwordIsGoodEnough(pw: string): boolean { @@ -1302,3 +1378,43 @@ function formatEmailRequestExpiryDateNotification({ config, username, to }: { co ` }); } + +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); + } + }); + }); +} \ No newline at end of file diff --git a/packages/itmat-cores/src/utils/GraphQL.ts b/packages/itmat-cores/src/utils/GraphQL.ts new file mode 100644 index 000000000..95b0464a9 --- /dev/null +++ b/packages/itmat-cores/src/utils/GraphQL.ts @@ -0,0 +1,207 @@ +// This file includes old type defs for DMP V2 + +import { CoreError, ICategoricalOption, IDataClip, IField, enumDataTypes, enumUserTypes } from '@itmat-broker/itmat-types'; +import { GraphQLError } from 'graphql'; + +export interface V2CreateFieldInput { + fieldId: string; + fieldName: string + tableName?: string + dataType: string; + possibleValues?: ICategoricalOption[] + unit?: string + comments?: string + metadata?: Record +} + +export interface V2EditFieldInput { + fieldId: string; + fieldName: string; + tableName?: string; + dataType: string; + possibleValues?: ICategoricalOption[] + unit?: string + comments?: string +} + +export interface V2CreateUserInput { + username: string, + firstname: string, + lastname: string, + email: string, + emailNotificationsActivated?: boolean, + password: string, + description?: string, + organisation: string, + metadata: Record & { logPermission: boolean } +} + +export interface V2EditUserInput { + 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 +} + +export function GraphQLErrorDecroator(error: CoreError) { + throw new GraphQLError( + error.message, + { extensions: { code: error.errorCode } } + ); +} + +export enum enumV2DataTypes { + int = 'int', + dec = 'dec', + str = 'str', + bool = 'bool', + date = 'date', + file = 'file', + json = 'json', + cat = 'cat' +} + +/** + * This function convert the new field type to the old ones for consistency with the GraphQL schema + */ +export function convertV3FieldToV2Field(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 enumV2DataTypes.int; + } else if (field.dataType === enumDataTypes.DECIMAL) { + return enumV2DataTypes.dec; + } else if (field.dataType === enumDataTypes.STRING) { + return enumV2DataTypes.str; + } else if (field.dataType === enumDataTypes.BOOLEAN) { + return enumV2DataTypes.bool; + } else if (field.dataType === enumDataTypes.DATETIME) { + return enumV2DataTypes.date; + } else if (field.dataType === enumDataTypes.FILE) { + return enumV2DataTypes.file; + } else if (field.dataType === enumDataTypes.JSON) { + return enumV2DataTypes.json; + } else if (field.dataType === enumDataTypes.CATEGORICAL) { + return enumV2DataTypes.cat; + } else { + return enumV2DataTypes.str; + } + })(), + dateAdded: field.life.createdTime.toString(), + dateDeleted: field.life.deletedTime ? field.life.deletedTime.toString() : null + }; + }); +} + +export function convertV2CreateFieldInputToV3(studyId: string, fields: V2CreateFieldInput[]) { + return fields.map(field => { + return { + studyId: studyId, + fieldName: field.fieldName, + fieldId: field.fieldId, + description: undefined, + dataType: (() => { + if (field.dataType === enumV2DataTypes.int) { + return enumDataTypes.INTEGER; + } else if (field.dataType === enumV2DataTypes.dec) { + return enumDataTypes.DECIMAL; + } else if (field.dataType === enumV2DataTypes.str) { + return enumDataTypes.STRING; + } else if (field.dataType === enumV2DataTypes.bool) { + return enumDataTypes.BOOLEAN; + } else if (field.dataType === enumV2DataTypes.date) { + return enumDataTypes.DATETIME; + } else if (field.dataType === enumV2DataTypes.file) { + return enumDataTypes.FILE; + } else if (field.dataType === enumV2DataTypes.json) { + return enumDataTypes.JSON; + } else if (field.dataType === enumV2DataTypes.cat) { + return enumDataTypes.CATEGORICAL; + } else { + return enumDataTypes.STRING; + } + })(), + categoricalOptions: field.possibleValues?.map(el => { + return { + id: el.id, + code: el.code, + description: el.description + }; + }), + unit: field.unit, + comments: field.comments + }; + }); +} + +export function convertV2EditFieldInputToV3(studyId: string, fields: V2EditFieldInput[]) { + return fields.map(field => { + return { + studyId: studyId, + fieldName: field.fieldName, + fieldId: field.fieldId, + description: undefined, + dataType: (() => { + if (field.dataType === enumV2DataTypes.int) { + return enumDataTypes.INTEGER; + } else if (field.dataType === enumV2DataTypes.dec) { + return enumDataTypes.DECIMAL; + } else if (field.dataType === enumV2DataTypes.str) { + return enumDataTypes.STRING; + } else if (field.dataType === enumV2DataTypes.bool) { + return enumDataTypes.BOOLEAN; + } else if (field.dataType === enumV2DataTypes.date) { + return enumDataTypes.DATETIME; + } else if (field.dataType === enumV2DataTypes.file) { + return enumDataTypes.FILE; + } else if (field.dataType === enumV2DataTypes.json) { + return enumDataTypes.JSON; + } else if (field.dataType === enumV2DataTypes.cat) { + return enumDataTypes.CATEGORICAL; + } else { + return enumDataTypes.STRING; + } + })(), + categoricalOptions: field.possibleValues?.map(el => { + return { + id: el.id, + code: el.code, + description: el.description + }; + }), + unit: field.unit, + comments: field.comments + }; + }); +} + +export function convertV2DataClipInputToV3(dataclip: IDataClip[]) { + return dataclip.map(el => { + return { + fieldId: el.fieldId, + value: el.value, + properties: { + m_subjectId: el.subjectId, + m_visitId: el.visitId + } + }; + }); +} \ No newline at end of file diff --git a/packages/itmat-docker/config/config.sample.json b/packages/itmat-docker/config/config.sample.json index 423b12b0a..b0c5e91b1 100644 --- a/packages/itmat-docker/config/config.sample.json +++ b/packages/itmat-docker/config/config.sample.json @@ -19,9 +19,11 @@ "pubkeys_collection": "PUBKEY_COLLECTION", "standardizations_collection": "STANDARDIZATION_COLLECTION", "configs_collection": "CONFIG_COLLECTION", + "ontologies_collection": "ONTOLOGY_COLLECTION", + "docs_collection": "DOC_COLLECTION", + "cache_collection": "CACHE_COLLECTION", "drives_collection": "DRIVE_COLLECTION", "colddata_collection": "COLDDATA_COLLECTION", - "cache_collection": "CACHE_COLLECTION", "domains_collection": "DOMAIN_COLLECTION" } }, diff --git a/packages/itmat-interface/config/config.sample.json b/packages/itmat-interface/config/config.sample.json index 65ad1bce9..a96b777e4 100644 --- a/packages/itmat-interface/config/config.sample.json +++ b/packages/itmat-interface/config/config.sample.json @@ -19,9 +19,11 @@ "pubkeys_collection": "PUBKEY_COLLECTION", "standardizations_collection": "STANDARDIZATION_COLLECTION", "configs_collection": "CONFIG_COLLECTION", + "ontologies_collection": "ONTOLOGY_COLLECTION", + "docs_collection": "DOC_COLLECTION", + "cache_collection": "CACHE_COLLECTION", "drives_collection": "DRIVE_COLLECTION", "colddata_collection": "COLDDATA_COLLECTION", - "cache_collection": "CACHE_COLLECTION", "domains_collection": "DOMAIN_COLLECTION" } }, diff --git a/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts index 62d22769a..996516176 100644 --- a/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/fileResolvers.ts @@ -1,21 +1,104 @@ import { FileUpload } from 'graphql-upload-minimal'; import { DMPResolversMap } from './context'; import { db } from '../../database/database'; -import { FileCore } from '@itmat-broker/itmat-cores'; +import { TRPCDataCore, TRPCDataTransformationCore, TRPCFileCore, TRPCPermissionCore, errorCodes } from '@itmat-broker/itmat-cores'; import { objStore } from '../../objStore/objStore'; +import { CoreError, deviceTypes, enumCoreErrors, enumReservedKeys } from '@itmat-broker/itmat-types'; +import { TRPCUtilsCore } from 'packages/itmat-cores/src/trpcCore/utilsCore'; +import { GraphQLError } from 'graphql'; -const fileCore = Object.freeze(new FileCore(db, objStore)); - +// const fileCore = Object.freeze(new FileCore(db, objStore)); +const fileCore = new TRPCFileCore(db, objStore); +const utilsCore = new TRPCUtilsCore(); +const dataCore = new TRPCDataCore(db, fileCore, new TRPCPermissionCore(db), utilsCore, new TRPCDataTransformationCore(utilsCore)); 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) => { - return await fileCore.uploadFile(context.req.user, args.studyId, args.file, args.description, args.hash, args.fileLength); + // The pre-processing is for IDEA-FAST device data only + // For new studies, use the new API + let targetFieldId: string | undefined = undefined; + let parsedDescription: Record | undefined = undefined; + + try { + parsedDescription = JSON.parse(args.description); + if (!parsedDescription) { + throw new GraphQLError( + 'File description is invalid.', + { extensions: { code: enumCoreErrors.CLIENT_MALFORMED_INPUT } } + ); + } + if (parsedDescription['fieldId']) { + targetFieldId = parsedDescription['fieldId'].toString(); + } else { + // individual device data + if (parsedDescription['deviceId']) { + const device = parsedDescription['deviceId']?.slice(0, 3); + targetFieldId = `Device_${deviceTypes[device].replace(/ /g, '_')}`; + } else { + // study level data + targetFieldId = enumReservedKeys.STUDY_LEVEL_DATA; + } + } + } catch { + throw new GraphQLError( + 'File description is invalid.', + { extensions: { code: enumCoreErrors.CLIENT_MALFORMED_INPUT } } + ); + } + if (!targetFieldId) { + throw new GraphQLError( + 'Field Id not found.', + { extensions: { code: enumCoreErrors.CLIENT_MALFORMED_INPUT } } + ); + } else { + try { + const res = await dataCore.uploadFileData(context.req.user, args.studyId, await args.file, targetFieldId, args.description); + const fileEntry = await db.collections.files_collection.findOne({ id: res.id }); + if (args.fileLength) { + if (args.fileLength.toString() !== fileEntry?.fileSize.toString()) { + throw new GraphQLError( + 'File size mismatch.', + { extensions: { code: enumCoreErrors.CLIENT_MALFORMED_INPUT } } + ); + } + } + if (args.hash) { + if (args.hash !== fileEntry?.hash) { + throw new GraphQLError( + 'File hash not match.', + { extensions: { code: enumCoreErrors.CLIENT_MALFORMED_INPUT } } + ); + } + } + return { + ...fileEntry, + description: JSON.stringify(fileEntry?.properties), + uploadTime: fileEntry?.life.createdTime, + uploadedBy: fileEntry?.life.createdUser, + metadata: { + ...fileEntry?.properties + } + }; + } catch (e) { + throw new GraphQLError((e as CoreError).message, { extensions: { code: errorCodes.CLIENT_MALFORMED_INPUT } }); + } + } }, + /** + * Deprecated API. + * + */ deleteFile: async (_parent, args: { fileId: string }, context) => { - return await fileCore.deleteFile(context.req.user, args.fileId); + const data = await db.collections.data_collection.findOne({ value: args.fileId }); + if (!data) { + throw new CoreError( + enumCoreErrors.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY, + 'Data entry not found.' + ); + } + return await dataCore.deleteData(context.req.user, data.studyId, data.fieldId, data.properties); } }, Subscription: {} diff --git a/packages/itmat-interface/src/graphql/resolvers/index.ts b/packages/itmat-interface/src/graphql/resolvers/index.ts index 64100ce84..790d39f5c 100644 --- a/packages/itmat-interface/src/graphql/resolvers/index.ts +++ b/packages/itmat-interface/src/graphql/resolvers/index.ts @@ -1,7 +1,6 @@ import { fileResolvers } from './fileResolvers'; import { jobResolvers } from './jobResolvers'; import { permissionResolvers } from './permissionResolvers'; -import { queryResolvers } from './queryResolvers'; import { studyResolvers } from './studyResolvers'; import { userResolvers } from './userResolvers'; import { organisationResolvers } from './organisationResolvers'; @@ -16,7 +15,6 @@ import { errorCodes } from '@itmat-broker/itmat-cores'; const modules = [ studyResolvers, userResolvers, - queryResolvers, permissionResolvers, jobResolvers, fileResolvers, @@ -28,7 +26,7 @@ const modules = [ const bounceNotLoggedInDecorator = (funcName: string, reducerFunction: DMPResolver): DMPResolver => { return (parent, args, context, info) => { - const uncheckedFunctionWhitelist = ['login', 'rsaSigner', 'keyPairGenwSignature', 'issueAccessToken', 'getOrganisations', 'requestUsernameOrResetPassword', 'resetPassword', 'createUser', 'validateResetPassword']; + const uncheckedFunctionWhitelist = ['login', 'rsaSigner', 'keyPairGenwSignature', 'issueAccessToken', 'getOrganisations', 'requestUsernameOrResetPassword', 'resetPassword', 'createUser', 'validateResetPassword', 'whoAmI']; const requester = context.req.user; if (!requester && !uncheckedFunctionWhitelist.includes(funcName)) { diff --git a/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts index 4bc17f8ef..490ec2527 100644 --- a/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/jobResolvers.ts @@ -2,17 +2,15 @@ import { withFilter } from 'graphql-subscriptions'; 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'; +import { TRPCJobCore, subscriptionEvents } from '@itmat-broker/itmat-cores'; -const jobCore = Object.freeze(new JobCore(db, objStore)); +// TODO: Implement the jobResolvers +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const jobCore = new TRPCJobCore(db); export const jobResolvers: DMPResolversMap = { Query: {}, Mutation: { - createQueryCurationJob: async (_parent, { queryId, studyId }: { queryId: string[], studyId: string }, context) => { - return await jobCore.createQueryCurationJob(context.req.user, queryId, studyId); - } }, Subscription: { subscribeToJobStatusChange: { diff --git a/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts index a0a0acd5c..28cc65eba 100644 --- a/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/logResolvers.ts @@ -1,16 +1,30 @@ import { enumEventStatus, enumEventType, enumUserTypes } from '@itmat-broker/itmat-types'; import { DMPResolversMap } from './context'; import { db } from '../../database/database'; -import { LogCore } from '@itmat-broker/itmat-cores'; +import { TRPCLogCore } from '@itmat-broker/itmat-cores'; -const logCore = Object.freeze(new LogCore(db)); +const logCore = new TRPCLogCore(db); export const logResolvers: DMPResolversMap = { Query: { // keep this api temporarily for testing purpose // should be removed in further development - getLogs: async (_parent, args: { requesterName: string, requesterType: enumUserTypes, logType: enumEventType, actionType: string, status: enumEventStatus }, context) => { - return await logCore.getLogs(context.req.user, args.requesterName, args.requesterType, args.logType, args.actionType, args.status); + getLogs: async (_parent, args: { requesterName: string, requesterType: enumUserTypes, logType?: enumEventType, actionType?: string, status?: enumEventStatus }, context) => { + const response = await logCore.getLogs(context.req.user, args.requesterName, args.logType ? [args.logType] : undefined, undefined, args.actionType ? [args.actionType] : undefined, args.status ? [args.status] : undefined); + return response.map(el => { + return { + ...el, + requesterName: el.requester, + requesterType: undefined, + userAgent: 'N/A', + logType: el.type, + actionType: el.event, + actionData: el.parameters, + time: el.life.createdTime, + status: el.status, + error: el.errors + }; + }); } } }; diff --git a/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts index 9bfa5bbf9..4c8d9914c 100644 --- a/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/organisationResolvers.ts @@ -1,19 +1,19 @@ import { DMPResolversMap } from './context'; import { db } from '../../database/database'; -import { OrganisationCore } from '@itmat-broker/itmat-cores'; +import { TRPCFileCore, TRPCOrganisationCore } from '@itmat-broker/itmat-cores'; +import { objStore } from '../../objStore/objStore'; -const organisationCore = Object.freeze(new OrganisationCore(db)); +const organisationCore = new TRPCOrganisationCore(db, new TRPCFileCore(db, objStore)); export const organisationResolvers: DMPResolversMap = { Query: { getOrganisations: async (_parent, args: { organisationId?: string }) => { - // everyone is allowed to see all organisations in the app. - return await organisationCore.getOrganisations(args.organisationId); + return await organisationCore.getOrganisations(undefined, 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 }); + createOrganisation: async (parent, { name, shortname }: { name: string, shortname: string, containOrg: string, metadata: unknown }, context) => { + return await organisationCore.createOrganisation(context.req.user, name, shortname); }, deleteOrganisation: async (parent, { id }: { id: string }, context) => { return await organisationCore.deleteOrganisation(context.req.user, id); diff --git a/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts index bc1fe451e..8be3a9572 100644 --- a/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/permissionResolvers.ts @@ -1,19 +1,23 @@ import { IRole } from '@itmat-broker/itmat-types'; import { DMPResolversMap } from './context'; import { db } from '../../database/database'; -import { PermissionCore } from '@itmat-broker/itmat-cores'; +import { TRPCPermissionCore } from '@itmat-broker/itmat-cores'; -const permissionCore = Object.freeze(new PermissionCore(db)); +const permissionCore = new TRPCPermissionCore(db); export const permissionResolvers: DMPResolversMap = { Query: { getGrantedPermissions: async (_parent, { studyId }: { studyId?: string }, context) => { - return await permissionCore.getGrantedPermissions(context.req.user, studyId); + return { + studies: studyId ? await permissionCore.getRolesOfStudy(context.req.user, studyId) : + await permissionCore.getRolesOfUser(context.req.user, context.req.user?.id ?? ''), + projects: [] + }; } }, StudyOrProjectUserRole: { users: async (role: IRole) => { - return permissionCore.getUsersOfRole(role); + return await permissionCore.getUsersOfRole(role.id); } }, Mutation: { diff --git a/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts index 67179fda5..252d1ca92 100644 --- a/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/pubkeyResolvers.ts @@ -1,33 +1,34 @@ import { DMPResolversMap } from './context'; import { db } from '../../database/database'; -import { PubkeyCore } from '@itmat-broker/itmat-cores'; +import { TRPCUserCore } from '@itmat-broker/itmat-cores'; import config from '../../utils/configManager'; import { mailer } from '../../emailer/emailer'; +import { objStore } from '../../objStore/objStore'; -const pubkeyCore = Object.freeze(new PubkeyCore(db, mailer, config)); +const userCore = new TRPCUserCore(db, mailer, config, objStore); export const pubkeyResolvers: DMPResolversMap = { Query: { - getPubkeys: async (_parent, args: { pubkeyId?: string, associatedUserId?: string }) => { - return pubkeyCore.getPubkeys(args.pubkeyId, args.associatedUserId); + getPubkeys: async (_parent, args: { pubkeyId?: string, associatedUserId?: string }, context) => { + return await userCore.getUserKeys(context.req.user, args.associatedUserId ?? ''); } }, Mutation: { keyPairGenwSignature: async () => { - return await pubkeyCore.keyPairGenwSignature(); + return await userCore.keyPairGenwSignature(); }, rsaSigner: async (_parent, { privateKey, message }: { privateKey: string, message: string }) => { - return pubkeyCore.rsaSigner(privateKey, message); + return userCore.rsaSigner(privateKey, message); }, issueAccessToken: async (_parent, { pubkey, signature }: { pubkey: string, signature: string }) => { - return pubkeyCore.issueAccessToken(pubkey, signature); + return userCore.issueAccessToken(pubkey, signature); }, registerPubkey: async (_parent, { pubkey, signature, associatedUserId }: { pubkey: string, signature: string, associatedUserId: string }, context) => { - return await pubkeyCore.registerPubkey(context.req.user, pubkey, signature, associatedUserId); + return await userCore.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 deleted file mode 100644 index e234b04e9..000000000 --- a/packages/itmat-interface/src/graphql/resolvers/queryResolvers.ts +++ /dev/null @@ -1,22 +0,0 @@ -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) => { - return await queryCore.getQueryByIdparent(context.req.user, args.queryId); - }, - 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 } }) => { - 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 3d71e53b1..dd7565697 100644 --- a/packages/itmat-interface/src/graphql/resolvers/standardizationResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/standardizationResolvers.ts @@ -1,9 +1,11 @@ import { DMPResolversMap } from './context'; import { db } from '../../database/database'; -import { StandarizationCore } from '@itmat-broker/itmat-cores'; import { objStore } from '../../objStore/objStore'; +import { TRPCFileCore, TRPCPermissionCore, TRPCStandarizationCore, TRPCStudyCore } from '@itmat-broker/itmat-cores'; -const standardizationCore = Object.freeze(new StandarizationCore(db, objStore)); +const permissionCore = new TRPCPermissionCore(db); +const studyCore = new TRPCStudyCore(db, objStore, permissionCore, new TRPCFileCore(db, objStore)); +const standardizationCore = new TRPCStandarizationCore(db, objStore, permissionCore, studyCore); export const standardizationResolvers: DMPResolversMap = { Query: { diff --git a/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts index 3c428aa76..6471be355 100644 --- a/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/studyResolvers.ts @@ -1,129 +1,279 @@ import { - IProject, IStudy, studyType, IDataClip, - IOntologyTree + IQueryString, + IGenericResponse, + CoreError, + IGroupedData } from '@itmat-broker/itmat-types'; -import { CreateFieldInput, EditFieldInput, StudyCore } from '@itmat-broker/itmat-cores'; +import { GraphQLErrorDecroator, TRPCDataCore, TRPCDataTransformationCore, TRPCFileCore, TRPCPermissionCore, TRPCStudyCore, V2CreateFieldInput, V2EditFieldInput, convertV2CreateFieldInputToV3, convertV2DataClipInputToV3, convertV2EditFieldInputToV3, convertV3FieldToV2Field } from '@itmat-broker/itmat-cores'; import { DMPResolversMap } from './context'; import { db } from '../../database/database'; import { objStore } from '../../objStore/objStore'; +import { TRPCUtilsCore } from 'packages/itmat-cores/src/trpcCore/utilsCore'; -const studyCore = Object.freeze(new StudyCore(db, objStore)); +const fileCore = new TRPCFileCore(db, objStore); +const permissionCore = new TRPCPermissionCore(db); +const utilsCore = new TRPCUtilsCore(); +const studyCore = new TRPCStudyCore(db, objStore, permissionCore, fileCore); +const dataCore = new TRPCDataCore(db, fileCore, permissionCore, utilsCore, new TRPCDataTransformationCore(utilsCore)); export const studyResolvers: DMPResolversMap = { Query: { getStudy: async (_parent, args: { studyId: string }, context) => { - return studyCore.getStudy(context.req.user, args.studyId); + try { + const study = (await studyCore.getStudies(context.req.user, args.studyId))[0]; + return { + ...study, + createdBy: study.life.createdUser, + deleted: study.life.deletedTime, + dataVersions: study.dataVersions.map(el => { + return { + ...el, + updateDate: el.life.createdTime.toString(), + contentId: el.id + }; + }) + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - getProject: async (_parent, args: { projectId: string }, context) => { - return await studyCore.getProject(context.req.user, args.projectId); + getProject: async () => { + // TO BE REMOVED + return null; }, getStudyFields: async (_parent, { studyId, versionId }: { studyId: string, versionId?: string | null }, context) => { - return await studyCore.getStudyFields(context.req.user, studyId, versionId); + try { + // V3 and V2 parse version id as different logic; for compatibility, we need to convert the version id to V3 version id + let dataVersion: string | Array | null | undefined = versionId; + if (versionId === null) { + const study = (await studyCore.getStudies(context.req.user, studyId))[0]; + dataVersion = study.dataVersions.map(el => el.id); + dataVersion.push(null); + } + const fields = await dataCore.getStudyFields(context.req.user, studyId, dataVersion); + return convertV3FieldToV2Field(fields).sort((a, b) => a.fieldId.localeCompare(b.fieldId)); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - getOntologyTree: async (_parent, { studyId, treeName, versionId }: { studyId: string, treeName?: string, versionId?: string }, context) => { - return await studyCore.getOntologyTree(context.req.user, studyId, treeName, versionId); + getOntologyTree: async () => { + // TO BE REIMPLEMENTED + return null; }, - getDataRecords: async (_parent, { studyId, queryString, versionId }: { queryString, studyId: string, versionId: string | null | undefined }, context) => { - return await studyCore.getDataRecords(context.req.user, queryString, studyId, versionId); + getDataRecords: async (_parent, { studyId, queryString, versionId }: { queryString: IQueryString, studyId: string, versionId: string | null | undefined }, context) => { + try { + const result = (await dataCore.getData(context.req.user, studyId, queryString.data_requested, versionId))['raw']; + const groupedResult: IGroupedData = {}; + for (let i = 0; i < result.length; i++) { + const { m_subjectId, m_visitId } = result[i].properties; + const m_fieldId = result[i].fieldId; + const value = result[i].value; + 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; + } + return { + data: groupedResult + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } } }, Study: { - projects: async (study: IStudy) => { - return await studyCore.getStudyProjects(study); + projects: async () => { + // TO BE REMOVED + return []; }, - jobs: async (study: IStudy) => { - return await studyCore.getStudyJobs(study); + jobs: async () => { + // TO BE REIMPLEMENTED + return []; }, - roles: async (study: IStudy) => { - return await studyCore.getStudyRoles(study); + roles: async (study: IStudy, _args: never, context) => { + try { + return await permissionCore.getRolesOfStudy(context.req.user, study.id); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, files: async (study: IStudy, _args: never, context) => { - return await studyCore.getStudyFiles(context.req.user, study); + try { + const files = await dataCore.getStudyFiles(context.req.user, study.id); + return files.map(el => { + return { + ...el, + fileSize: el.fileSize.toString(), + uploadTime: el.life.createdTime.toString(), + uploadedBy: el.life.createdUser + }; + }); + } catch (e) { + return []; + } }, - subjects: async (study: IStudy, _args: never, context) => { - return await studyCore.getStudySubjects(context.req.user, study); + subjects: async () => { + // TO BE REMOVED + return [[], []]; }, - visits: async (study: IStudy, _args: never, context) => { - return await studyCore.getStudyVisits(context.req.user, study); + visits: async () => { + // TO BE REMOVED + return [[], []]; }, - numOfRecords: async (study: IStudy, _args: never, context) => { - return await studyCore.getStudyNumOfRecords(context.req.user, study); + numOfRecords: async () => { + // TO_BE_REMOVED + return [0, 0]; }, currentDataVersion: async (study: IStudy) => { - return studyCore.getStudyCurrentDataVersion(study); + return study.currentDataVersion; } }, + // TO_BE_REMOVED Project: { - fields: async (project: Omit, _args: never, context) => { - return await studyCore.getProjectFields(context.req.user, project); + fields: async () => { + return []; }, - jobs: async (project: Omit) => { - return await studyCore.getProjectJobs(project); + jobs: async () => { + return []; }, - files: async (project: Omit, _args: never, context) => { - return await studyCore.getProjectFiles(context.req.user, project); + files: async () => { + return []; }, - dataVersion: async (project: IProject) => { - return await studyCore.getProjectDataVersion(project); + dataVersion: async () => { + return null; }, - summary: async (project: IProject, _args: never, context) => { - return await studyCore.getProjectSummary(context.req.user, project); + summary: async () => { + return {}; }, - patientMapping: async (project: IProject, _args: never, context) => { - return await studyCore.getProjectPatientMapping(context.req.user, project); + patientMapping: async () => { + return {}; }, - roles: async (project: IProject) => { - return await studyCore.getProjectRoles(project); + roles: async () => { + return []; }, iCanEdit: async () => { // TO_DO return true; } }, Mutation: { - createStudy: async (_parent, { name, description, type }: { name: string, description: string, type: studyType }, context) => { - return await studyCore.createNewStudy(context.req.user, name, description, type); + createStudy: async (_parent, { name, description }: { name: string, description: string, type?: studyType }, context) => { + try { + const res = await studyCore.createStudy(context.req.user, name, description); + return { + ...res, + type: studyType.SENSOR + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, editStudy: async (_parent, { studyId, description }: { studyId: string, description: string }, context) => { - return await studyCore.editStudy(context.req.user, studyId, description); + try { + return await studyCore.editStudy(context.req.user, studyId, undefined, description); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - createNewField: async (_parent, { studyId, fieldInput }: { studyId: string, fieldInput: CreateFieldInput[] }, context) => { - return await studyCore.createNewField(context.req.user, studyId, fieldInput); + createNewField: async (_parent, { studyId, fieldInput }: { studyId: string, fieldInput: V2CreateFieldInput[] }, context) => { + try { + const responses: IGenericResponse[] = []; + const converted = convertV2CreateFieldInputToV3(studyId, fieldInput); + for (let i = 0; i < converted.length; i++) { + try { + const res = await dataCore.createField(context.req.user, converted[i]); + responses.push({ id: undefined, successful: true, description: `Field ${res.fieldId}-${res.fieldName} is created successfully.` }); + } catch (e) { + responses.push({ id: undefined, successful: false, description: (e as CoreError).message }); + } + } + return responses; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - editField: async (_parent, { studyId, fieldInput }: { studyId: string, fieldInput: EditFieldInput }, context) => { - return await studyCore.editField(context.req.user, studyId, fieldInput); + editField: async (_parent, { studyId, fieldInput }: { studyId: string, fieldInput: V2EditFieldInput }, context) => { + try { + return await dataCore.editField(context.req.user, convertV2EditFieldInputToV3(studyId, [fieldInput])[0]); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, deleteField: async (_parent, { studyId, fieldId }: { studyId: string, fieldId: string }, context) => { - return await studyCore.deleteField(context.req.user, studyId, fieldId); + try { + await dataCore.deleteField(context.req.user, studyId, fieldId); + const fields = await db.collections.field_dictionary_collection.find({ 'studyId': studyId, 'fieldId': fieldId, 'life.deletedTime': { $exists: true } }).toArray(); + const lastField = fields[fields.length - 1]; + return convertV3FieldToV2Field([lastField])[0]; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, 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) => { - return await studyCore.deleteDataRecords(context.req.user, studyId, subjectIds, visitIds, fieldIds); + try { + return await dataCore.uploadData(context.req.user, studyId, convertV2DataClipInputToV3(data)); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, createNewDataVersion: async (_parent, { studyId, dataVersion, tag }: { studyId: string, dataVersion: string, tag: string }, context) => { - return await studyCore.createNewDataVersion(context.req.user, studyId, dataVersion, tag); + try { + const version = await studyCore.createDataVersion(context.req.user, studyId, tag, dataVersion); + return { + ...version, + updateDate: version.life.createdTime.toString(), + contentId: version.id + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - createOntologyTree: async (_parent, { studyId, ontologyTree }: { studyId: string, ontologyTree: Pick }, context) => { - return await studyCore.createOntologyTree(context.req.user, studyId, ontologyTree); + createOntologyTree: async () => { + // TO BE REIMPLEMENTED + return null; }, - deleteOntologyTree: async (_parent, { studyId, treeName }: { studyId: string, treeName: string }, context) => { - return await studyCore.deleteOntologyTree(context.req.user, studyId, treeName); + deleteOntologyTree: async () => { + // TO BE REIMPLEMENTED + return null; }, - createProject: async (_parent, { studyId, projectName }: { studyId: string, projectName: string }, context) => { - return await studyCore.createProjectForStudy(context.req.user, studyId, projectName); + createProject: async () => { + // TO BE REMOVED + return null; }, - deleteProject: async (_parent, { projectId }: { projectId: string }, context) => { - return await studyCore.deleteProject(context.req.user, projectId); + deleteProject: async () => { + // TO BE REMOVED + return null; }, deleteStudy: async (_parent, { studyId }: { studyId: string }, context) => { - return await studyCore.deleteStudy(context.req.user, studyId); + try { + return await studyCore.deleteStudy(context.req.user, studyId); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, setDataversionAsCurrent: async (_parent, { studyId, dataVersionId }: { studyId: string, dataVersionId: string }, context) => { - return await studyCore.setDataversionAsCurrent(context.req.user, studyId, dataVersionId); + try { + await studyCore.setDataversionAsCurrent(context.req.user, studyId, dataVersionId); + const study = (await studyCore.getStudies(context.req.user, studyId))[0]; + return { + id: studyId, + currentDataVersion: study.currentDataVersion, + dataVersions: study.dataVersions.map(el => { + return { + ...el, + updateDate: el.life.createdTime.toString(), + contentId: el.id + }; + }) + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } } }, Subscription: {} diff --git a/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts b/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts index 22613e68d..7f39d33f7 100644 --- a/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts +++ b/packages/itmat-interface/src/graphql/resolvers/userResolvers.ts @@ -1,69 +1,199 @@ -import { IUserWithoutToken } from '@itmat-broker/itmat-types'; -import { CreateUserInput, EditUserInput, UserCore } from '@itmat-broker/itmat-cores'; +import { CoreError, IUserWithoutToken, enumUserTypes } from '@itmat-broker/itmat-types'; +import { GraphQLErrorDecroator, TRPCPermissionCore, TRPCUserCore, V2CreateUserInput, V2EditUserInput, errorCodes, makeGenericReponse } 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 { objStore } from '../../objStore/objStore'; +import { GraphQLError } from 'graphql'; -const userCore = Object.freeze(new UserCore(db, mailer, config)); +const userCore = new TRPCUserCore(db, mailer, config, objStore); +const permissionCore = new TRPCPermissionCore(db); export const userResolvers: DMPResolversMap = { Query: { whoAmI: (_parent, _args, context) => { - return { - ...context.req.user, - createdAt: context.req.user?.life.createdTime, - deleted: context.req.user?.life.deletedTime - }; + try { + if (context.req.user) { + return { + ...context.req.user, + createdAt: context.req.user?.life.createdTime, + deleted: context.req.user?.life.deletedTime + }; + } else { + throw new GraphQLError(errorCodes.NOT_LOGGED_IN); + } + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - getUsers: async (_parent, args: { userId?: string }) => { - return await userCore.getUsers(args.userId); + getUsers: async (_parent, args: { userId?: string }, context) => { + try { + const users = args.userId ? [await userCore.getUser(context.req.user, args.userId)] : await userCore.getUsers(context.req.user); + const decroatedUsers = users.map(user => { + if (!user) return null; + + return { + id: user.id, + username: user.username, + type: user.type, + firstname: user.firstname, + lastname: user.lastname, + email: user.email, + emailNotificationsActivated: user.emailNotificationsActivated, + emailNotificationsStatus: user.emailNotificationsStatus, + organisation: user.organisation, + createdAt: user.life.createdTime, + expiredAt: user.expiredAt, + description: user.description, + metadata: user.metadata + }; + }).filter(user => user !== null); + return decroatedUsers; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, validateResetPassword: async (_parent, args: { token: string, encryptedEmail: string }) => { - return await userCore.validateResetPassword(args.token, args.encryptedEmail); + try { + return await userCore.validateResetPassword(args.token, args.encryptedEmail); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, recoverSessionExpireTime: async () => { - return await userCore.recoverSessionExpireTime(); + try { + return await userCore.recoverSessionExpireTime(); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } } }, User: { - access: async (user: IUserWithoutToken, _args, context) => { - return await userCore.getUserAccess(context.req.user, user); + access: async (user: IUserWithoutToken) => { + try { + return await { + id: `user_access_obj_user_id_${user?.id}`, + projects: [], + studies: await db.collections.studies_collection.find({ + id: { + $in: + (await permissionCore.getRolesOfUser(user, user.id)).map(el => el.studyId) + } + }).toArray() + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - username: async (user: IUserWithoutToken, _args, context) => { - return await userCore.getUserUsername(context.req.user, user); + username: async (user: IUserWithoutToken) => { + return await user.username; }, - description: async (user: IUserWithoutToken, _args, context) => { - return await userCore.getUserDescription(context.req.user, user); + description: async (user: IUserWithoutToken) => { + return await user.description; }, - email: async (user: IUserWithoutToken, _args, context) => { - return await userCore.getUserEmail(context.req.user, user); + email: async (user: IUserWithoutToken) => { + return await user.email; } }, Mutation: { requestExpiryDate: async (_parent: Record, { username, email }: { username?: string, email?: string }) => { - return await userCore.requestExpiryDate(username, email); + try { + return await userCore.requestExpiryDate(username, email); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, 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); + try { + return await userCore.requestUsernameOrResetPassword(forgotUsername, forgotPassword, context.req.headers.origin, email, username); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, 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); + try { + const response = await userCore.login(context.req, args.username, args.password, args.totp, args.requestexpirydate); + if (response) { + return { + ...response, + createdAt: (response as IUserWithoutToken).life.createdTime + }; + } else { + return response; + } + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, logout: async (_parent: Record, _args: unknown, context) => { - return await userCore.logout(context.req); + try { + return await userCore.logout(context.req.user, context.req); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - createUser: async (_parent, args: { user: CreateUserInput }) => { - return await userCore.createUser(args.user); + createUser: async (_parent, args: { user: V2CreateUserInput }, context) => { + try { + const user = await userCore.createUser( + context.req.user, + args.user.username, + args.user.email, + args.user.firstname, + args.user.lastname, + args.user.organisation, + enumUserTypes.STANDARD, + args.user.emailNotificationsActivated ?? false, + args.user.password, + undefined, + args.user.description + ); + return makeGenericReponse(user.id, true, undefined, 'User created successfully.'); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, deleteUser: async (_parent, args: { userId: string }, context) => { - return await userCore.deleteUser(context.req.user, args.userId); + try { + return await userCore.deleteUser(context.req.user, args.userId); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, resetPassword: async (_parent, { encryptedEmail, token, newPassword }: { encryptedEmail: string, token: string, newPassword: string }) => { - return await userCore.resetPassword(encryptedEmail, token, newPassword); + try { + return await userCore.resetPassword(encryptedEmail, token, newPassword); + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } }, - editUser: async (_parent, args: { user: EditUserInput }, context) => { - return await userCore.editUser(context.req.user, args.user); + editUser: async (_parent, args: { user: V2EditUserInput }, context) => { + try { + const user = await userCore.editUser( + context.req.user, + args.user.id, + args.user.username, + args.user.email, + args.user.firstname, + args.user.lastname, + args.user.organisation, + args.user.type, + args.user.emailNotificationsActivated, + args.user.password, + undefined, + undefined, + args.user.description, + args.user.expiredAt + ); + return { + ...user, + createdAt: user?.life?.createdTime, + deleted: user?.life?.deletedTime, + metadata: {} + }; + } catch (e) { + return GraphQLErrorDecroator(e as CoreError); + } } }, Subscription: {} diff --git a/packages/itmat-interface/src/graphql/typeDefs.ts b/packages/itmat-interface/src/graphql/typeDefs.ts index 7a33a8b3b..c03b6e248 100644 --- a/packages/itmat-interface/src/graphql/typeDefs.ts +++ b/packages/itmat-interface/src/graphql/typeDefs.ts @@ -522,7 +522,6 @@ type Mutation { createQuery(query: QueryObjInput!): QueryEntry # CURATION - createQueryCurationJob(queryId: [String], studyId: String, projectId: String): Job setDataversionAsCurrent(studyId: String!, dataVersionId: String!): Study } diff --git a/packages/itmat-interface/src/trpc/organisationProcedure.ts b/packages/itmat-interface/src/trpc/organisationProcedure.ts new file mode 100644 index 000000000..149b70538 --- /dev/null +++ b/packages/itmat-interface/src/trpc/organisationProcedure.ts @@ -0,0 +1,73 @@ +import { FileUploadSchema } from '@itmat-broker/itmat-types'; +import { z } from 'zod'; +import { baseProcedure, router } from './trpc'; +import { TRPCFileCore, TRPCOrganisationCore } from '@itmat-broker/itmat-cores'; +import { db } from '../database/database'; +import { objStore } from '../objStore/objStore'; + +const organisationCore = new TRPCOrganisationCore(db, new TRPCFileCore(db, objStore)); + +export const organisationRouter = router({ + /** + * Get organisations. + * + * @param organisationId - The organisation id. + * + * @returns - IOrganisation[] + */ + getOrganisations: baseProcedure.input(z.object({ + organisationId: z.optional(z.string()) + })).query(async (opts) => { + return organisationCore.getOrganisations(opts.ctx.user, opts.input.organisationId); + }), + /** + * Create a new organisation. + * + * @param name - The name of the organisation. + * @param shortname - The shortname of the organisation. + * @param files - The files of the organisation. + * + * @returns - IOrganisation + */ + createOrganisation: baseProcedure.input(z.object({ + name: z.string(), + shortname: z.string(), + files: z.optional(z.object({ + profile: z.optional(z.array(FileUploadSchema)) + })) + })).mutation(async (opts) => { + return organisationCore.createOrganisation(opts.ctx.user, opts.input.name, opts.input.shortname, opts.input.files?.profile?.[0]); + }), + /** + * Edit an organisation. + * + * @param organisationId - The organisation id. + * @param name - The name of the organisation. + * @param shortname - The shortname of the organisation. + * @param files - The files of the organisation. + * + * @returns - IOrganisation + */ + editOrganisation: baseProcedure.input(z.object({ + organisationId: z.string(), + name: z.string(), + shortname: z.string(), + files: z.optional(z.object({ + profile: z.optional(z.array(FileUploadSchema)) + })) + })).mutation(async (opts) => { + return organisationCore.editOrganisation(opts.ctx.user, opts.input.organisationId, opts.input.name, opts.input.shortname, opts.input.files?.profile?.[0]); + }), + /** + * Delete an organisation. + * + * @param organisationId - The organisation id. + * + * @returns - IOrganisation + */ + deleteOrganisation: baseProcedure.input(z.object({ + organisationId: z.string() + })).mutation(async (opts) => { + return organisationCore.deleteOrganisation(opts.ctx.user, opts.input.organisationId); + }) +}); \ 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 18a13ea0d..dc2a8f621 100644 --- a/packages/itmat-interface/src/trpc/tRPCRouter.ts +++ b/packages/itmat-interface/src/trpc/tRPCRouter.ts @@ -3,6 +3,7 @@ import { dataRouter } from './dataProcedure'; import { domainRouter } from './domainProcedure'; import { driveRouter } from './driveProcedure'; import { logRouter } from './logProcedure'; +import { organisationRouter } from './organisationProcedure'; import { roleRouter } from './roleProcedure'; import { studyRouter } from './studyProcedure'; import { router } from './trpc'; @@ -16,7 +17,8 @@ export const tRPCRouter = router({ role: roleRouter, config: configRouter, log: logRouter, - domain: domainRouter + domain: domainRouter, + organisation: organisationRouter }); export type APPTRPCRouter = typeof tRPCRouter; \ No newline at end of file diff --git a/packages/itmat-interface/src/trpc/userProcedure.ts b/packages/itmat-interface/src/trpc/userProcedure.ts index b83ecd6a6..b4ab40e0d 100644 --- a/packages/itmat-interface/src/trpc/userProcedure.ts +++ b/packages/itmat-interface/src/trpc/userProcedure.ts @@ -66,14 +66,15 @@ export const userRouter = router({ /** * Ask for a request to extend account expiration time. Send notifications to user and admin. * - * @param userId - The id of the user. + * @param email - The email of the user. * * @return IGenericResponse - The object of IGenericResponse */ requestExpiryDate: baseProcedure.input(z.object({ - userId: z.string() + username: z.optional(z.string()), + email: z.optional(z.string()) })).mutation(async (opts) => { - return await userCore.requestExpiryDate(opts.ctx.user, opts.input.userId); + return await userCore.requestExpiryDate(opts.input.username, opts.input.email); }), /** * Request for resetting password. diff --git a/packages/itmat-interface/test/GraphQLTests/file.test.ts b/packages/itmat-interface/test/GraphQLTests/file.test.ts index d1a7acf07..1be6244ab 100644 --- a/packages/itmat-interface/test/GraphQLTests/file.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/file.test.ts @@ -348,7 +348,7 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('File hash not match'); + expect(res.body.errors[0].message).toBe('File hash not match.'); expect(res.body.data.uploadFile).toEqual(null); }); @@ -369,7 +369,7 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('File size mismatch'); + expect(res.body.errors[0].message).toBe('File size mismatch.'); expect(res.body.data.uploadFile).toEqual(null); }); }); @@ -745,7 +745,7 @@ if (global.hasMinio) { }); 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.errors[0].message).toBe('Data entry not found.'); expect(res.body.data.deleteFile).toBe(null); }); @@ -756,7 +756,7 @@ if (global.hasMinio) { }); 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.errors[0].message).toBe('Data entry not found.'); expect(res.body.data.deleteFile).toBe(null); }); }); diff --git a/packages/itmat-interface/test/GraphQLTests/job.test.ts b/packages/itmat-interface/test/GraphQLTests/job.test.ts deleted file mode 100644 index c4ce0166b..000000000 --- a/packages/itmat-interface/test/GraphQLTests/job.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck - -import request from 'supertest'; -import { print } from 'graphql'; -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 '@itmat-broker/itmat-cores'; -import { Db, MongoClient } from 'mongodb'; -import { IJobEntry, IUser, IRole, IStudy, IQueryEntry } from '@itmat-broker/itmat-types'; -import { CREATE_QUERY_CURATION_JOB } from '@itmat-broker/itmat-models'; -import { MongoMemoryServer } from 'mongodb-memory-server'; -import { setupDatabase } from '@itmat-broker/itmat-setup'; -import config from '../../config/config.sample.json'; -import { Express } from 'express'; - -let app: Express; -let mongodb: MongoMemoryServer; -let admin: request.SuperTest; -let user: request.SuperTest; -let mongoConnection: MongoClient; -let mongoClient: Db; - -afterAll(async () => { - await db.closeConnection(); - await mongoConnection?.close(); - await mongodb.stop(); -}); - -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.database.mongo_url = connectionString; - config.database.database = dbName; - await db.connect(config.database, MongoClient); - 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); -}); - -describe('JOB API', () => { - let createdStudy: { id: any; name?: string; createdBy?: string; lastModified?: number; deleted?: null; currentDataVersion?: number; dataVersions?: never[]; }; - let createdQuery: { id: any; requester?: string; queryString?: { date_requested: string; }; studyId?: string; projectId?: string; status?: string; error?: null; cancelled?: boolean; data_requested?: never[]; cohort?: never[][]; new_fields?: never[]; queryResult?: never[]; }; - let authorisedUser: request.SuperTest; - let authorisedUserProfile: IUser; - // beforeAll(async () => { - // }); - - describe('CREATE QUERY CURATION API', () => { - beforeEach(async () => { - /* setup: create a study to upload file to */ - const studyname = uuid(); - createdStudy = { - id: `new_study_id_${studyname}`, - name: studyname, - createdBy: 'admin', - lastModified: 200000002, - deleted: null, - currentDataVersion: -1, - dataVersions: [] - }; - await mongoClient.collection(config.database.collections.studies_collection).insertOne(createdStudy); - - /* setup: created query entry in the database */ - const queryId = uuid(); - createdQuery = { - id: `new_query_id_${queryId}`, - requester: 'admin', - queryString: { date_requested: 'test_query_string' }, - studyId: createdStudy.id, - status: 'QUEUED', - error: null, - cancelled: false, - data_requested: [], - cohort: [[]], - new_fields: [], - queryResult: [] - }; - await mongoClient.collection(config.database.collections.queries_collection).insertOne(createdQuery); - - /* setup: creating a privileged user */ - const username = uuid(); - authorisedUserProfile = { - username, - type: '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}`, - 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: IRole = { - id: roleId, - studyId: createdStudy.id, - name: `${roleId}_rolename`, - dataPermissions: [{ - fields: ['^.*$'], - dataProperties: {}, - includeUnVersioned: true, - permission: 7 - }], - studyRole: 'STUDY_MANAGER', - users: [authorisedUserProfile.id], - life: { - createdTime: 1591134065000, - createdUserId: '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); - }); - - test('Create a query curation job (authorised user)', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(CREATE_QUERY_CURATION_JOB), - variables: { - queryId: createdQuery.id, - studyId: createdStudy.id - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const job = await mongoClient.collection(config.database.collections.jobs_collection).findOne({ - 'data.queryId': createdQuery.id - }); - expect(res.body.data.createQueryCurationJob).toEqual({ - id: job.id, - studyId: createdStudy.id, - projectId: null, - jobType: 'QUERY_EXECUTION', - requester: authorisedUserProfile.id, - requestTime: job.requestTime, - receivedFiles: [], - status: 'QUEUED', - error: null, - cancelled: false, - cancelledTime: null, - data: { - queryId: [createdQuery.id], - studyId: createdStudy.id - } - }); - }); - - test('Create a query curation job (user with no privilege)', async () => { - const res = await user.post('/graphql').send({ - query: print(CREATE_QUERY_CURATION_JOB), - variables: { - queryId: createdQuery.id, - studyId: createdStudy.id - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - const job = await mongoClient.collection(config.database.collections.jobs_collection).findOne({ - 'data.queryId': createdQuery.id - }); - expect(job).toBe(null); - }); - - test('Create a query curation job (admin)', async () => { - const res = await admin.post('/graphql').send({ - query: print(CREATE_QUERY_CURATION_JOB), - variables: { - queryId: createdQuery.id, - studyId: createdStudy.id - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - const job = await mongoClient.collection(config.database.collections.jobs_collection).findOne({ - 'data.queryId': createdQuery.id - }); - expect(job).toBe(null); - }); - - test('Create a query curation job with a non-existent study id (admin)', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(CREATE_QUERY_CURATION_JOB), - variables: { - queryId: createdQuery.id, - studyId: 'fake_study_id' - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - const job = await mongoClient.collection(config.database.collections.jobs_collection).findOne({ - 'data.queryId': createdQuery.id - }); - expect(job).toBe(null); - }); - - test('Create a query curation job with a non-existent query id (admin)', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(CREATE_QUERY_CURATION_JOB), - variables: { - queryId: 'fake_query_id', - studyId: createdStudy.id - } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('Query does not exist.'); - const job = await mongoClient.collection(config.database.collections.jobs_collection).findOne({ - 'data.queryId': createdQuery.id - }); - expect(job).toBe(null); - }); - }); -}); diff --git a/packages/itmat-interface/test/GraphQLTests/study.test.ts b/packages/itmat-interface/test/GraphQLTests/study.test.ts index 1dc6eddf1..76565ba72 100644 --- a/packages/itmat-interface/test/GraphQLTests/study.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/study.test.ts @@ -23,7 +23,6 @@ import { SET_DATAVERSION_AS_CURRENT, EDIT_STUDY, UPLOAD_DATA_IN_ARRAY, - DELETE_DATA_RECORDS, GET_DATA_RECORDS, CREATE_NEW_DATA_VERSION, CREATE_NEW_FIELD, @@ -41,10 +40,11 @@ import { IRole, IData, enumFileTypes, - enumFileCategories + enumFileCategories, + enumConfigType, + enumReservedUsers } from '@itmat-broker/itmat-types'; import { Express } from 'express'; -import path from 'path'; import { objStore } from '../../src/objStore/objStore'; if (global.hasMinio) { @@ -105,6 +105,10 @@ if (global.hasMinio) { }); describe('MANIPULATING STUDIES EXISTENCE', () => { + afterEach(async () => { + await db.collections.studies_collection.deleteMany({}); + }); + test('Create study (admin)', async () => { const studyName = uuid(); const res = await admin.post('/graphql').send({ @@ -137,10 +141,7 @@ if (global.hasMinio) { access: { id: `user_access_obj_user_id_${adminId}`, projects: [], - studies: [{ - id: createdStudy.id, - name: studyName - }] + studies: [] }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, @@ -207,7 +208,7 @@ if (global.hasMinio) { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(`Study "${studyName}" already exists (duplicates are case-insensitive).`); + expect(res.body.errors[0].message).toBe('Study name already used.'); expect(res.body.data.createStudy).toBe(null); /* should be only one study in database */ @@ -241,7 +242,7 @@ if (global.hasMinio) { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(`Study "${studyName.toUpperCase()}" already exists (duplicates are case-insensitive).`); + expect(res.body.errors[0].message).toBe('Study name already used.'); expect(res.body.data.createStudy).toBe(null); /* should be only one study in database */ @@ -261,7 +262,7 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.data.createStudy).toBe(null); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); + expect(res.body.errors[0].message).toBe('Only admin can create a study.'); const createdStudy = await mongoClient.collection(config.database.collections.studies_collection).findOne({ name: studyName }); expect(createdStudy).toBe(null); @@ -291,7 +292,7 @@ if (global.hasMinio) { expect(editStudy.status).toBe(200); expect(editStudy.body.data.editStudy).toBe(null); expect(editStudy.body.errors).toHaveLength(1); - expect(editStudy.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); + expect(editStudy.body.errors[0].message).toBe('Only admin or study manager can edit a study.'); /* cleanup: delete study */ await mongoClient.collection(config.database.collections.studies_collection).findOneAndUpdate({ 'name': studyName, 'life.deletedTime': null }, { $set: { 'life.deletedUser': 'admin', 'life.deletedTime': new Date().valueOf() } }); @@ -330,10 +331,7 @@ if (global.hasMinio) { access: { id: `user_access_obj_user_id_${adminId}`, projects: [], - studies: [{ - id: newStudy.id, - name: studyName - }] + studies: [] }, emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, @@ -447,7 +445,7 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.data.deleteStudy).toBe(null); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); + expect(res.body.errors[0].message).toBe('Only admin can delete a study.'); /* confirms that the created study is still alive */ const createdStudy = await mongoClient.collection(config.database.collections.studies_collection).findOne({ name: studyName }); @@ -470,8 +468,14 @@ if (global.hasMinio) { id: 'mockDataVersionId2', contentId: 'mockContentId2', version: '0.0.2', - updateDate: '5000000', - tag: 'hey' + tag: 'hey', + life: { + createdTime: 5000001, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; beforeAll(async () => { @@ -498,9 +502,15 @@ if (global.hasMinio) { { mockDataVersion = { id: 'mockDataVersionId', - contentId: 'mockContentId', version: '0.0.1', - updateDate: '5000000' + tag: null, + life: { + createdTime: 5000000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; const mockData: IData[] = [ { @@ -675,7 +685,7 @@ if (global.hasMinio) { }], dataVersion: 'mockDataVersionId', life: { - createdTime: 20000, + createdTime: 30000, createdUser: 'admin', deletedTime: null, deletedUser: null @@ -726,45 +736,35 @@ if (global.hasMinio) { metadata: {} } ]; - await db.collections.studies_collection.updateOne({ id: createdStudy.id }, { - $push: { dataVersions: mockDataVersion }, - $inc: { currentDataVersion: 1 }, - $set: { - ontologyTrees: [{ - id: 'testOntology_id', - name: 'testOntology', - routes: [ - { - path: [ - 'MO', - 'MOCK' - ], - name: 'mockfield1', - field: [ - '$31' - ], - visitRange: [], - id: '036b7772-f239-4fef-b7f8-c3db883f51e3' - }, - { - path: [ - 'MO', - 'MOCK' - ], - name: 'mockfield2', - field: [ - '$32' - ], - visitRange: [], - id: 'f577023f-de54-446a-9bbe-1c346823e6bf' - } - ] - }] - } - }); await db.collections.data_collection.insertMany(mockData); await db.collections.field_dictionary_collection.insertMany(mockFields); await db.collections.files_collection.insertMany(mockFiles); + await db.collections.studies_collection.findOneAndUpdate({ id: createdStudy.id }, { $set: { currentDataVersion: 0, dataVersions: [mockDataVersion] } }); + + await db.collections.configs_collection.insertOne({ + type: enumConfigType.STUDYCONFIG, + key: createdStudy.id, + properties: { + 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: [] + } + }); } /* setup: creating a privileged user */ @@ -826,6 +826,7 @@ if (global.hasMinio) { await db.collections.files_collection.deleteMany({}); await db.collections.roles_collection.deleteMany({}); await db.collections.projects_collection.deleteMany({}); + await db.collections.configs_collection.deleteMany({}); }); test('Get a non-existent study (admin)', async () => { @@ -835,7 +836,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.getStudy).toBe(null); }); @@ -876,11 +877,10 @@ if (global.hasMinio) { currentDataVersion: 0, dataVersions: [{ id: 'mockDataVersionId', - contentId: 'mockContentId', version: '0.0.1', - // fileSize: '10000', - updateDate: '5000000', - tag: null + tag: null, + contentId: 'mockDataVersionId', + updateDate: '5000000' }] }); }); @@ -956,11 +956,10 @@ if (global.hasMinio) { currentDataVersion: 0, dataVersions: [{ id: 'mockDataVersionId', - contentId: 'mockContentId', version: '0.0.1', - // fileSize: '10000', - updateDate: '5000000', - tag: null + tag: null, + contentId: 'mockDataVersionId', + updateDate: '5000000' }] }); }); @@ -1027,7 +1026,7 @@ if (global.hasMinio) { possibleValues: [], unit: null, comments: null, - dateAdded: '20000', + dateAdded: '30000', dateDeleted: null } ]); @@ -1115,14 +1114,7 @@ if (global.hasMinio) { expect(res.body.errors).toBeUndefined(); const study = await db.collections.studies_collection.findOne({ id: createdStudy.id }, { projection: { dataVersions: 1 } }); expect(study).toBeDefined(); - expect(res.body.data.setDataversionAsCurrent).toEqual({ - id: createdStudy.id, - currentDataVersion: 0, - dataVersions: [ - { ...mockDataVersion, tag: null }, - { ...newMockDataVersion } - ] - }); + expect(res.body.data.setDataversionAsCurrent.currentDataVersion).toBe(0); /* cleanup: reverse setting dataversion */ await mongoClient.collection(config.database.collections.studies_collection) @@ -1216,8 +1208,8 @@ if (global.hasMinio) { expect(res.body.data.createNewField[0]).toEqual({ id: null, successful: false, - code: 'CLIENT_MALFORMED_INPUT', - description: 'Field 8.2-newField8: ["FieldId should contain letters, numbers and _ only."]' + code: null, + description: 'FieldId should contain letters, numbers and _ only.' }); }); @@ -1253,8 +1245,19 @@ if (global.hasMinio) { } }); 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.createNewField).toHaveLength(2); + expect(res.body.data.createNewField[0]).toEqual({ + id: null, + successful: false, + code: null, + description: errorCodes.NO_PERMISSION_ERROR + }); + expect(res.body.data.createNewField[1]).toEqual({ + id: null, + successful: false, + code: null, + description: errorCodes.NO_PERMISSION_ERROR + }); }); test('Create New fields (admin, should fail)', async () => { @@ -1289,8 +1292,9 @@ if (global.hasMinio) { } }); 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.createNewField).toHaveLength(2); + expect(res.body.data.createNewField[0].description).toBe(errorCodes.NO_PERMISSION_ERROR); + expect(res.body.data.createNewField[1].description).toBe(errorCodes.NO_PERMISSION_ERROR); }); test('Delete an unversioned field (authorised user)', async () => { @@ -1566,9 +1570,15 @@ if (global.hasMinio) { /* 5. Insert field for data uploading later */ mockDataVersion = { id: 'mockDataVersionId', - contentId: 'mockContentId', version: '0.0.1', - updateDate: '5000000' + tag: null, + life: { + createdTime: 5000000, + createdUserId: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; mockFields = [ { @@ -1684,7 +1694,30 @@ if (global.hasMinio) { }] } }); - + await db.collections.configs_collection.insertOne({ + type: enumConfigType.STUDYCONFIG, + key: createdStudy.id, + properties: { + 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: [] + } + }); }); afterAll(async () => { @@ -1737,13 +1770,6 @@ if (global.hasMinio) { value: 'wrong', subjectId: 'I7N3G6G', visitId: '2' - }, - // illegal subject id - { - fieldId: '31', - value: '10', - subjectId: 'I777770', - visitId: '1' } ]; const res = await authorisedUser.post('/graphql').send({ @@ -1753,12 +1779,11 @@ if (global.hasMinio) { expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); expect(res.body.data.uploadDataInArray).toEqual([ - { code: null, description: 'I7N3G6G-1-31', id: null, successful: true }, - { code: null, description: 'I7N3G6G-1-32', id: null, successful: true }, - { code: null, description: 'GR6R4AR-1-31', id: null, successful: true }, - { code: 'CLIENT_ACTION_ON_NON_EXISTENT_ENTRY', description: 'Field 34: Field Not found', id: null, successful: false }, - { code: 'CLIENT_MALFORMED_INPUT', description: 'Field 31: Cannot parse as integer.', id: null, successful: false }, - { code: 'CLIENT_MALFORMED_INPUT', description: 'Subject ID I777770 is illegal.', id: null, successful: false } + { code: null, description: 'Field 31 value 10 successfully uploaded.', id: '0', successful: true }, + { code: null, description: 'Field 32 value FAKE1 successfully uploaded.', id: '1', successful: true }, + { code: null, description: 'Field 31 value 11 successfully uploaded.', id: '2', successful: true }, + { code: 'CLIENT_ACTION_ON_NON_EXISTENT_ENTRY', description: 'Field 34: Field not found', id: '3', successful: false }, + { code: 'CLIENT_MALFORMED_INPUT', description: 'Field 31: Cannot parse as integer.', id: '4', successful: false } ]); const dataInDb = await db.collections.data_collection.find({ deleted: null }).toArray(); @@ -1785,40 +1810,6 @@ if (global.hasMinio) { expect(res.body.errors[0].message).toBe('Study does not exist.'); }); - test('Upload a file with the data API', async () => { - const res = 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: 'txt' - } - } - ] - } - })) - .field('map', JSON.stringify({ 1: ['variables.data.0.file'] })) - .attach('1', path.join(__dirname, '../filesForTests/I7N3G6G-MMM7N3G6G-20200704-20200721.txt')); - 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, '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 () => { const res = await authorisedUser.post('/graphql').send({ query: print(UPLOAD_DATA_IN_ARRAY), @@ -1914,135 +1905,6 @@ if (global.hasMinio) { expect(fieldsInDb).toHaveLength(4); }); - test('Delete data records: (unauthorised user) should fail', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { studyId: createdStudy.id, data: multipleRecords } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - - const deleteRes = await user.post('/graphql').send({ - query: print(DELETE_DATA_RECORDS), - variables: { studyId: createdStudy.id, subjectId: 'I7N3G6G' } - }); - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.errors).toHaveLength(1); - expect(deleteRes.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - }); - - test('Delete data records: subjectId (user with study privilege)', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { studyId: createdStudy.id, data: multipleRecords } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - const deleteRes = await authorisedUser.post('/graphql').send({ - query: print(DELETE_DATA_RECORDS), - variables: { studyId: createdStudy.id, subjectIds: ['I7N3G6G'] } - }); - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.errors).toBeUndefined(); - expect(deleteRes.body.data.deleteDataRecords).toEqual([ - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-31 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-32 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-33 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-31 is deleted.' }, - { 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.' } - ]); - }); - - test('Delete data records: visitId (admin)', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { studyId: createdStudy.id, data: multipleRecords } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - - const deleteRes = await admin.post('/graphql').send({ - query: print(DELETE_DATA_RECORDS), - variables: { studyId: createdStudy.id, visitIds: ['2'] } - }); - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.errors).toHaveLength(1); - expect(deleteRes.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); - }); - - test('Delete data records: studyId (authorised user)', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { studyId: createdStudy.id, data: multipleRecords } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.uploadDataInArray).toEqual([ - { code: null, description: 'I7N3G6G-1-31', id: null, successful: true }, - { code: null, description: 'I7N3G6G-1-32', id: null, successful: true }, - { code: null, description: 'I7N3G6G-2-31', id: null, successful: true }, - { code: null, description: 'I7N3G6G-2-32', id: null, successful: true }, - { code: null, description: 'GR6R4AR-2-31', id: null, successful: true }, - { code: null, description: 'GR6R4AR-2-32', id: null, successful: true } - ]); - const deleteRes = await authorisedUser.post('/graphql').send({ - query: print(DELETE_DATA_RECORDS), - variables: { studyId: createdStudy.id } - }); - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.errors).toBeUndefined(); - expect(deleteRes.body.data.deleteDataRecords).toEqual([ - { successful: true, id: null, code: null, description: 'SubjectId-GR6R4AR:visitId-1:fieldId-31 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-GR6R4AR:visitId-1:fieldId-32 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-GR6R4AR:visitId-1:fieldId-33 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-GR6R4AR:visitId-2:fieldId-31 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-GR6R4AR:visitId-2:fieldId-32 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-GR6R4AR:visitId-2:fieldId-33 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-31 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-32 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-33 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-31 is deleted.' }, - { 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.' } - ]); - const dataInDb = await db.collections.data_collection.find({}).sort({ 'life.createdTime': -1 }).toArray(); - expect(dataInDb).toHaveLength(18); // 2 visits * 2 subjects * 2 fields * 2 (delete or not) + 6 (original records) = 22 records - }); - - test('Delete data records: records not exist', async () => { - const res = await authorisedUser.post('/graphql').send({ - query: print(UPLOAD_DATA_IN_ARRAY), - variables: { studyId: createdStudy.id, data: multipleRecords } - }); - expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.uploadDataInArray).toEqual([ - { code: null, description: 'I7N3G6G-1-31', id: null, successful: true }, - { code: null, description: 'I7N3G6G-1-32', id: null, successful: true }, - { code: null, description: 'I7N3G6G-2-31', id: null, successful: true }, - { code: null, description: 'I7N3G6G-2-32', id: null, successful: true }, - { code: null, description: 'GR6R4AR-2-31', id: null, successful: true }, - { code: null, description: 'GR6R4AR-2-32', id: null, successful: true } - ]); - const deleteRes = await authorisedUser.post('/graphql').send({ - query: print(DELETE_DATA_RECORDS), - variables: { studyId: createdStudy.id, subjectIds: ['I7N3G6G'], visitIds: ['1', '2'] } - }); - expect(deleteRes.status).toBe(200); - expect(deleteRes.body.errors).toBeUndefined(); - expect(deleteRes.body.data.deleteDataRecords).toEqual([ - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-31 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-32 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-1:fieldId-33 is deleted.' }, - { successful: true, id: null, code: null, description: 'SubjectId-I7N3G6G:visitId-2:fieldId-31 is deleted.' }, - { 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.' } - ]); - const dataInDb = await db.collections.data_collection.find({}).sort({ 'life.createdTime': -1 }).toArray(); - expect(dataInDb).toHaveLength(12); // 8 deleted records and 6 original records - }); - test('Get data records (user with study privilege)', async () => { await authorisedUser.post('/graphql').send({ query: print(UPLOAD_DATA_IN_ARRAY), diff --git a/packages/itmat-interface/test/GraphQLTests/users.test.ts b/packages/itmat-interface/test/GraphQLTests/users.test.ts index da5f1a1f8..168f35912 100644 --- a/packages/itmat-interface/test/GraphQLTests/users.test.ts +++ b/packages/itmat-interface/test/GraphQLTests/users.test.ts @@ -25,6 +25,7 @@ import { } from '@itmat-broker/itmat-models'; import { IResetPasswordRequest, IUser, enumUserTypes } from '@itmat-broker/itmat-types'; import type { Express } from 'express'; +import { seedOrganisations } from 'packages/itmat-setup/src/databaseSetup/seed/organisations'; let app: Express; let mongodb: MongoMemoryServer; @@ -121,7 +122,7 @@ describe('USERS API', () => { username: 'Idontexist' } }); - expect(res.status).toBe(200); // even though user doesnt exist. This should pass so people dont know the registered users + expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); expect(res.body.data.requestUsernameOrResetPassword).toEqual({ successful: true }); }, 6050); @@ -156,7 +157,7 @@ describe('USERS API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.CLIENT_MALFORMED_INPUT); + expect(res.body.errors[0].message).toBe('Inputs are invalid.'); expect(res.body.data.requestUsernameOrResetPassword).toBe(null); }); @@ -172,7 +173,7 @@ describe('USERS API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.CLIENT_MALFORMED_INPUT); + expect(res.body.errors[0].message).toBe('Inputs are invalid.'); }); test('Request reset password and username but provide username (should fail)', async () => { @@ -189,7 +190,7 @@ describe('USERS API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.CLIENT_MALFORMED_INPUT); + expect(res.body.errors[0].message).toBe('Inputs are invalid.'); }); test('Request reset password with existing user providing email', async () => { @@ -305,7 +306,7 @@ describe('USERS API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('Token is not valid.'); + expect(res.body.errors[0].message).toBe('Token is invalid.'); expect(res.body.data.resetPassword).toBe(null); /* cleanup */ @@ -344,7 +345,7 @@ describe('USERS API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe(errorCodes.CLIENT_MALFORMED_INPUT); + expect(res.body.errors[0].message).toBe('Token is invalid.'); expect(res.body.data.resetPassword).toBe(null); /* cleanup */ @@ -381,7 +382,7 @@ describe('USERS API', () => { }); 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('User does not exist.'); expect(res.body.data.resetPassword).toBe(null); /* cleanup */ @@ -426,7 +427,7 @@ describe('USERS API', () => { }); 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('User does not exist.'); expect(res.body.data.resetPassword).toBe(null); /* cleanup */ @@ -573,7 +574,7 @@ describe('USERS API', () => { }); expect(resAgain.status).toBe(200); expect(resAgain.body.errors).toHaveLength(1); - expect(resAgain.body.errors[0].message).toBe(errorCodes.CLIENT_ACTION_ON_NON_EXISTENT_ENTRY); + expect(resAgain.body.errors[0].message).toBe('User does not exist.'); expect(resAgain.body.data.resetPassword).toEqual(null); /* cleanup */ @@ -877,13 +878,10 @@ describe('USERS API', () => { ]); }); - test('Get all users list with detail (no access info) (user) (should fail)', async () => { - const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { fetchDetailsAdminOnly: true, fetchAccessPrivileges: false } }); - expect(res.status).toBe(200); //graphql returns 200 for application layer errors - expect(res.body.errors).toHaveLength(3); - expect(res.body.errors[0].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.data.getUsers).toEqual([ // user still has permission to his own data - null, + test('Get user list with detail (no access info) (user) (should fail)', async () => { + const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { userId: userId, fetchDetailsAdminOnly: true, fetchAccessPrivileges: false } }); + expect(res.status).toBe(200); + expect(res.body.data.getUsers).toEqual([ { username: 'standardUser', type: enumUserTypes.STANDARD, @@ -902,16 +900,10 @@ describe('USERS API', () => { ]); }); - test('Get all users list with detail (w/ access info) (user) (should fail)', async () => { - const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { fetchDetailsAdminOnly: true, fetchAccessPrivileges: true } }); - expect(res.status).toBe(200); // graphql returns 200 for application layer errors - expect(res.body.errors).toHaveLength(4); - expect(res.body.errors[0].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.errors[1].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.errors[2].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.errors[3].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.data.getUsers).toEqual([ // user still has permission to his own data - null, + test('Get user list with detail (w/ access info) (user) (should fail)', async () => { + const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { userId, userId, fetchDetailsAdminOnly: true, fetchAccessPrivileges: true } }); + expect(res.status).toBe(200); + expect(res.body.data.getUsers).toEqual([ { username: 'standardUser', type: enumUserTypes.STANDARD, @@ -958,17 +950,10 @@ describe('USERS API', () => { }); test('Get all users without details (user)', async () => { - const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { fetchDetailsAdminOnly: false, fetchAccessPrivileges: false } }); + const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { userId: userId, fetchDetailsAdminOnly: false, fetchAccessPrivileges: false } }); expect(res.status).toBe(200); expect(res.body.error).toBeUndefined(); expect(res.body.data.getUsers).toEqual([ - { - type: enumUserTypes.ADMIN, - firstname: 'Fadmin', - lastname: 'Ladmin', - organisation: 'organisation_system', - id: adminId - }, { type: enumUserTypes.STANDARD, firstname: 'Tai Man', @@ -1010,29 +995,15 @@ describe('USERS API', () => { test('Get a specific non-self user with details (user) (should fail)', async () => { const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { userId: adminId, fetchDetailsAdminOnly: true, fetchAccessPrivileges: true } }); expect(res.status).toBe(200); - expect(res.body.errors).toHaveLength(4); - expect(res.body.errors[0].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.errors[1].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.errors[2].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.errors[3].message).toBe('NO_PERMISSION_ERROR'); - expect(res.body.data.getUsers).toEqual([ - null - ]); + expect(res.body.errors).toHaveLength(1); + expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); }); test('Get a specific non-self user without details (user) (should fail)', async () => { const res = await user.post('/graphql').send({ query: print(GET_USERS), variables: { userId: adminId, fetchDetailsAdminOnly: false, fetchAccessPrivileges: false } }); expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.getUsers).toEqual([ - { - type: enumUserTypes.ADMIN, - firstname: 'Fadmin', - lastname: 'Ladmin', - organisation: 'organisation_system', - id: adminId - } - ]); + expect(res.body.errors).toHaveLength(1); + expect(res.body.errors[0].message).toBe(errorCodes.NO_PERMISSION_ERROR); }); test('Get a specific self user with details (user)', async () => { @@ -1085,6 +1056,9 @@ describe('USERS API', () => { }); describe('APP USER MUTATION API', () => { + afterEach(async () => { + await db.collections.users_collection.deleteMany({ username: { $nin: ['admin', 'standardUser'] } }); + }); test('log in with incorrect totp (user)', async () => { await admin.post('/graphql').send({ @@ -1095,13 +1069,12 @@ describe('USERS API', () => { firstname: 'FUser Testing', lastname: 'LUser Testing', description: 'I am fake!', - organisation: 'DSI-ICL', + organisation: seedOrganisations[0].id, emailNotificationsActivated: false, email: 'user0email@email.io', type: enumUserTypes.STANDARD } }); - /* getting the created user from mongo */ const createdUser = (await mongoClient .collection(config.database.collections.users_collection) @@ -1129,13 +1102,12 @@ describe('USERS API', () => { firstname: 'FUser Testing', lastname: 'LUser Testing', description: 'I am fake!', - organisation: 'DSI-ICL', + organisation: seedOrganisations[0].id, emailNotificationsActivated: false, email: 'fake@email.io', type: enumUserTypes.STANDARD } }); - expect(res.status).toBe(200); expect(res.body.errors).toBeUndefined(); expect(res.body.data.createUser).toStrictEqual( @@ -1154,7 +1126,7 @@ describe('USERS API', () => { firstname: 'FUser Testing2', lastname: 'LUser Testing2', description: 'I am fake!', - organisation: 'DSI-ICL', + organisation: seedOrganisations[0].id, emailNotificationsActivated: false, email: 'fak@e@semail.io', type: enumUserTypes.STANDARD @@ -1175,7 +1147,7 @@ describe('USERS API', () => { firstname: 'FUser Testing', lastname: 'LUser Testing', description: 'I am fake!', - organisation: 'DSI-ICL', + organisation: seedOrganisations[0].id, emailNotificationsActivated: false, email: 'fake@email.io', type: enumUserTypes.STANDARD @@ -1218,7 +1190,7 @@ describe('USERS API', () => { firstname: 'FUser Testing', lastname: 'LUser Testing', description: 'I am fake!', - organisation: 'DSI-ICL', + organisation: seedOrganisations[0].id, emailNotificationsActivated: false, email: 'fake@email.io', type: enumUserTypes.STANDARD @@ -1226,29 +1198,35 @@ describe('USERS API', () => { }); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('User already exists.'); + expect(res.body.errors[0].message).toBe('Username or email already exists.'); expect(res.body.data.createUser).toBe(null); }); test('edit user password (admin) (should fail)', async () => { - /* setup: getting the id of the created user from mongo */ const newUser: IUser = { username: 'new_user_333333', - type: enumUserTypes.STANDARD, + type: 'STANDARD', firstname: 'FChan Ming Ming', lastname: 'LChan Ming Ming', password: 'fakepassword', otpSecret: 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA', + organisation: 'organisation_system', email: 'new3333@example.com', resetPasswordRequests: [], description: 'I am a new user 33333.', emailNotificationsActivated: true, - organisation: 'organisation_system', - deleted: null, + emailNotificationsStatus: { expiringNotification: false }, id: 'fakeid2', - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; + await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); /* assertion */ @@ -1267,34 +1245,37 @@ describe('USERS API', () => { expect(result.password).toBe('fakepassword'); 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.errors[0].message).toBe('User can only edit his/her own account.'); expect(res.body.data.editUser).toEqual(null); - }); test('edit user without password (admin)', async () => { /* setup: getting the id of the created user from mongo */ const newUser: IUser = { - username: 'new_user_3', - type: enumUserTypes.STANDARD, - firstname: 'FChan Ming Man', - lastname: 'LChan Ming Man', + username: 'new_user_333333', + type: 'STANDARD', + firstname: 'FChan Ming Ming', + lastname: 'LChan Ming Ming', password: 'fakepassword', otpSecret: 'H6BNKKO27DPLCATGEJAZNWQV4LWOTMRA', - email: 'new3@example.com', + organisation: 'organisation_system', + email: 'new3333@example.com', resetPasswordRequests: [], - description: 'I am a new user 3.', + description: 'I am a new user 33333.', emailNotificationsActivated: true, emailNotificationsStatus: { expiringNotification: false }, - organisation: 'organisation_system', - deleted: null, id: 'fakeid2222', - createdAt: 1591134065000, - expiredAt: 1991134065000 + expiredAt: 1991134065000, + life: { + createdTime: 1591134065000, + createdUser: 'admin', + deletedTime: null, + deletedUser: null + }, + metadata: {} }; await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); - /* assertion */ const res = await admin.post('/graphql').send( { @@ -1307,7 +1288,7 @@ describe('USERS API', () => { lastname: 'LMan', email: 'hey@uk.io', description: 'DSI director', - organisation: 'DSI-ICL' + organisation: seedOrganisations[0].id } } ); @@ -1319,10 +1300,10 @@ describe('USERS API', () => { expect(res.body.data.editUser).toEqual( { username: 'fakeusername', - type: enumUserTypes.ADMIN, + type: enumUserTypes.STANDARD, firstname: 'FMan', lastname: 'LMan', - organisation: 'DSI-ICL', + organisation: seedOrganisations[0].id, email: 'hey@uk.io', description: 'DSI director', id: 'fakeid2222', @@ -1500,7 +1481,7 @@ describe('USERS API', () => { ); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('User not updated: Non-admin users are only authorised to change their password, email or email notification.'); + expect(res.body.errors[0].message).toBe('Standard user can not change their type, expiration time and organisation. Please contact admins for help.'); expect(res.body.data.editUser).toEqual(null); }); @@ -1546,7 +1527,7 @@ describe('USERS API', () => { ); expect(res.status).toBe(200); expect(res.body.errors).toHaveLength(1); - expect(res.body.errors[0].message).toBe('User not updated: Email is not the right format.'); + expect(res.body.errors[0].message).toBe('Email is not the right format.'); expect(res.body.data.editUser).toBe(null); }); @@ -1590,7 +1571,7 @@ describe('USERS API', () => { ); 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.errors[0].message).toBe('User can only edit his/her own account.'); expect(res.body.data.editUser).toEqual(null); }); @@ -1656,8 +1637,8 @@ describe('USERS API', () => { query: print(GET_USERS), variables: { userId: newUser.id, fetchDetailsAdminOnly: false, fetchAccessPrivileges: false } }); - - expect(getUserResAfter.body.data.getUsers).toEqual([]); + expect(getUserResAfter.body.errors).toHaveLength(1); + expect(getUserResAfter.body.errors[0].message).toBe('User does not exist.'); }); test('delete user that has been deleted (admin)', async () => { @@ -1715,11 +1696,8 @@ describe('USERS API', () => { } ); expect(res.status).toBe(200); - expect(res.body.errors).toBeUndefined(); - expect(res.body.data.deleteUser).toEqual({ - successful: true, - id: 'I never existed' - }); + expect(res.body.errors).toHaveLength(1); + expect(res.body.errors[0].message).toBe('User does not exist.'); }); test('delete user (user)', async () => { @@ -1751,7 +1729,7 @@ describe('USERS API', () => { await mongoClient.collection(config.database.collections.users_collection).insertOne(newUser); /* assertion */ - const getUserRes = await user.post('/graphql').send({ + const getUserRes = await admin.post('/graphql').send({ query: print(GET_USERS), variables: { userId: newUser.id, fetchDetailsAdminOnly: false, fetchAccessPrivileges: false } }); @@ -1775,10 +1753,10 @@ describe('USERS API', () => { ); 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.errors[0].message).toBe('User can only delete his/her own account.'); expect(res.body.data.deleteUser).toEqual(null); - const getUserResAfter = await user.post('/graphql').send({ + const getUserResAfter = await admin.post('/graphql').send({ query: print(GET_USERS), variables: { userId: newUser.id, fetchDetailsAdminOnly: false, fetchAccessPrivileges: false } }); diff --git a/packages/itmat-interface/test/trpcTests/data.test.ts b/packages/itmat-interface/test/trpcTests/data.test.ts index 9cb23b95a..8b7d22f47 100644 --- a/packages/itmat-interface/test/trpcTests/data.test.ts +++ b/packages/itmat-interface/test/trpcTests/data.test.ts @@ -186,7 +186,7 @@ if (global.hasMinio) { permission: parseInt('111', 2), includeUnVersioned: true }], - studyRole: enumStudyRoles.STUDY_USER, + studyRole: enumStudyRoles.STUDY_MANAGER, users: [authorisedUserProfile.id], groups: [], life: { @@ -1236,7 +1236,7 @@ if (global.hasMinio) { const data = await db.collections.data_collection.find({ studyId: study.id }).toArray(); expect(data).toHaveLength(2); expect(data[0].life.deletedTime).toBeNull(); - expect(data[1].life.deletedTime).toBeNull(); + expect(data[1].life.deletedTime).not.toBeNull(); }); test('Delete a data clip (study does not exist)', async () => { await authorisedUser.post('/trpc/data.createStudyField') @@ -1261,7 +1261,7 @@ if (global.hasMinio) { fieldId: '1' }); expect(response2.status).toBe(400); - expect(response2.body.error.message).toBe('Study does not exist.'); + expect(response2.body.error.message).toBe(enumCoreErrors.NO_PERMISSION_ERROR); }); test('Delete a data clip (delete twice)', async () => { await authorisedUser.post('/trpc/data.createStudyField') diff --git a/packages/itmat-interface/test/trpcTests/organisation.test.ts b/packages/itmat-interface/test/trpcTests/organisation.test.ts new file mode 100644 index 000000000..f9664ff04 --- /dev/null +++ b/packages/itmat-interface/test/trpcTests/organisation.test.ts @@ -0,0 +1,197 @@ +/** + * @with Minio + */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { v4 as uuid } from 'uuid'; +import request from 'supertest'; +import { connectAdmin, connectUser } from './_loginHelper'; +import { db } from '../../src/database/database'; +import { Router } from '../../src/server/router'; +import { Db, MongoClient } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import config from '../../config/config.sample.json'; +import { objStore } from '../../src/objStore/objStore'; +import { enumCoreErrors } from '@itmat-broker/itmat-types'; +import { Express } from 'express'; +import { setupDatabase } from '@itmat-broker/itmat-setup'; +import { encodeQueryParams } from './helper'; +import path from 'path'; +import { seedOrganisations } from 'packages/itmat-setup/src/databaseSetup/seed/organisations'; + +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; + + 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); + + /* Mock Date for testing */ + jest.spyOn(Date, 'now').mockImplementation(() => 1591134065000); + }); + + afterEach(async () => { + await db.collections.organisations_collection.deleteMany({}); + await db.collections.organisations_collection.insertMany(seedOrganisations); + await db.collections.files_collection.deleteMany({}); + }); + + describe('tRPC organisation APIs', () => { + test('Get organisations (admin)', async () => { + const paramteres = { + }; + const response = await admin.get('/trpc/organisation.getOrganisations?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response.status).toBe(200); + expect(response.body.result.data).toHaveLength(2); + }); + test('Get organisations with organisationId (admin)', async () => { + const paramteres = { + organisationId: 'organisation_system' + }; + const response = await admin.get('/trpc/organisation.getOrganisations?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response.status).toBe(200); + expect(response.body.result.data).toHaveLength(1); + expect(response.body.result.data[0].id).toBe('organisation_system'); + }); + test('Get organisations (user)', async () => { + const paramteres = { + }; + const response = await user.get('/trpc/organisation.getOrganisations?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response.status).toBe(200); + expect(response.body.result.data).toHaveLength(2); + }); + test('Get organisations with organisationId (user)', async () => { + const paramteres = { + organisationId: 'organisation_system' + }; + const response = await user.get('/trpc/organisation.getOrganisations?input=' + encodeQueryParams(paramteres)) + .query({}); + expect(response.status).toBe(200); + expect(response.body.result.data).toHaveLength(1); + expect(response.body.result.data[0].id).toBe('organisation_system'); + }); + test('Create organisation (admin)', async () => { + const response = await admin.post('/trpc/organisation.createOrganisation') + .field('name', 'test') + .field('shortname', 'tst'); + expect(response.status).toBe(200); + expect(response.body.result.data.name).toBe('test'); + expect(response.body.result.data.shortname).toBe('tst'); + const orgs = await db.collections.organisations_collection.find({}).toArray(); + expect(orgs).toHaveLength(3); + }); + test('Create organisation (user)', async () => { + const response = await user.post('/trpc/organisation.createOrganisation') + .field('name', 'test') + .field('shortname', 'tst'); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(enumCoreErrors.NO_PERMISSION_ERROR); + }); + test('Create organisation (admin) with files', async () => { + const filePath = path.join(__dirname, '../filesForTests/dsi.jpeg'); + const response = await admin.post('/trpc/organisation.createOrganisation') + .attach('profile', filePath) + .field('name', 'test') + .field('shortname', 'tst'); + expect(response.status).toBe(200); + expect(response.body.result.data.name).toBe('test'); + expect(response.body.result.data.shortname).toBe('tst'); + expect(response.body.result.data.profile).toBeDefined(); + const orgs = await db.collections.organisations_collection.find({}).toArray(); + expect(orgs).toHaveLength(3); + const files = await db.collections.files_collection.find({}).toArray(); + expect(files).toHaveLength(1); + expect(files[0].id).toBe(response.body.result.data.profile); + }); + test('Create organisation (admin) duplicate name', async () => { + const response = await admin.post('/trpc/organisation.createOrganisation') + .field('name', 'System') + .field('shortname', 'tst'); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Organisation already exists.'); + }); + test('Edit organisation (admin)', async () => { + const response = await admin.post('/trpc/organisation.editOrganisation') + .field('organisationId', 'organisation_system') + .field('name', 'test') + .field('shortname', 'tst'); + expect(response.status).toBe(200); + expect(response.body.result.data.successful).toBe(true); + const org = await db.collections.organisations_collection.findOne({ id: 'organisation_system' }); + expect(org.name).toBe('test'); + }); + test('Edit organisation (user)', async () => { + const response = await user.post('/trpc/organisation.editOrganisation') + .field('organisationId', 'organisation_system') + .field('name', 'test') + .field('shortname', 'tst'); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(enumCoreErrors.NO_PERMISSION_ERROR); + }); + test('Edit organisation (admin) duplicate name', async () => { + const response = await admin.post('/trpc/organisation.editOrganisation') + .field('organisationId', 'organisation_system') + .field('name', 'user') + .field('shortname', 'tst'); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('Organisation already exists.'); + }); + test('Delete organisation (admin)', async () => { + const response = await admin.post('/trpc/organisation.deleteOrganisation') + .field('organisationId', 'organisation_system'); + expect(response.status).toBe(200); + expect(response.body.result.data.successful).toBe(true); + const org = await db.collections.organisations_collection.findOne({ id: 'organisation_system' }); + expect(org.life.deletedTime).toBeDefined(); + }); + test('Delete organisation (user)', async () => { + const response = await user.post('/trpc/organisation.deleteOrganisation') + .field('organisationId', 'organisation_system'); + expect(response.status).toBe(400); + expect(response.body.error.message).toBe(enumCoreErrors.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-interface/test/trpcTests/user.test.ts b/packages/itmat-interface/test/trpcTests/user.test.ts index 7039034bd..be6c10749 100644 --- a/packages/itmat-interface/test/trpcTests/user.test.ts +++ b/packages/itmat-interface/test/trpcTests/user.test.ts @@ -230,7 +230,6 @@ if (global.hasMinio) { firstname: 'edit_firstname', lastname: 'edit_lastname', email: 'edit_email@test.com', - password: 'edit_password', description: 'edit_description', type: enumUserTypes.MANAGER }); @@ -348,7 +347,6 @@ if (global.hasMinio) { firstname: 'edit_firstname', lastname: 'edit_lastname', email: 'edit_email@test.com', - password: 'edit_password', description: 'edit_description', organisation: 'random' }); @@ -356,17 +354,12 @@ if (global.hasMinio) { expect(response.body.error.message).toBe('Organisation does not exist.'); }); test('Edit a user (old password)', async () => { - await admin.post('/trpc/user.editUser') + 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' + password: 'edit_password' }); - const response = await admin.post('/trpc/user.editUser') + const response = await user.post('/trpc/user.editUser') .send({ userId: userProfile.id, password: 'edit_password' @@ -382,7 +375,6 @@ if (global.hasMinio) { firstname: 'edit_firstname', lastname: 'edit_lastname', email: 'edit_email@test.com', - password: 'edit_password', description: 'edit_description', expiredAt: Date.now() - 1000 }); diff --git a/packages/itmat-job-executor/config/config.sample.json b/packages/itmat-job-executor/config/config.sample.json index d20269599..fba58f2ef 100644 --- a/packages/itmat-job-executor/config/config.sample.json +++ b/packages/itmat-job-executor/config/config.sample.json @@ -18,9 +18,11 @@ "pubkeys_collection": "PUBKEY_COLLECTION", "standardizations_collection": "STANDARDIZATION_COLLECTION", "configs_collection": "CONFIG_COLLECTION", + "ontologies_collection": "ONTOLOGY_COLLECTION", + "docs_collection": "DOC_COLLECTION", + "cache_collection": "CACHE_COLLECTION", "drives_collection": "DRIVE_COLLECTION", "colddata_collection": "COLDDATA_COLLECTION", - "cache_collection": "CACHE_COLLECTION", "domains_collection": "DOMAIN_COLLECTION" } }, diff --git a/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts b/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts index 35e3e9cd6..284bfc070 100644 --- a/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts +++ b/packages/itmat-setup/src/databaseSetup/collectionsAndIndexes.ts @@ -104,6 +104,12 @@ const collections = { { key: { type: 1, key: 1 }, unique: true } ] }, + ontologies_collection: { + name: 'ONTOLOGY_COLLECTION', + indexes: [ + { key: { id: 1 }, unique: true } + ] + }, drives_collection: { name: 'DRIVE_COLLECTION', indexes: [ @@ -130,14 +136,20 @@ const collections = { { key: { id: 1 }, unique: true }, { key: { domainPath: 1 }, unique: true } ] + }, + docs_collection: { + name: 'DOC_COLLECTION', + indexes: [ + { key: { id: 1 }, unique: true } + ] } }; export async function setupDatabase(mongostr: string, databaseName: string): Promise { + console.log('Setting up database'); const conn = await mongo.MongoClient.connect(mongostr); const db = conn.db(databaseName); const existingCollections = (await db.listCollections({}).toArray()).map((el) => el.name); - /* creating collections and indexes */ for (const each of Object.keys(collections) as Array) { if (existingCollections.includes(collections[each].name)) { diff --git a/packages/itmat-setup/src/databaseSetup/seed/config.ts b/packages/itmat-setup/src/databaseSetup/seed/config.ts index 3b0aac8ca..380c93709 100644 --- a/packages/itmat-setup/src/databaseSetup/seed/config.ts +++ b/packages/itmat-setup/src/databaseSetup/seed/config.ts @@ -1,4 +1,54 @@ export const seedConfigs = [{ + id: 'root_admin_user_config_protected', + type: 'USERCONFIG', + key: 'replaced_at_runtime2', + properties: { + id: 'root_admin_user_config', + life: { + createdTime: Date.now(), + createdUser: '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 + } +}, { + id: 'root_standard_user_config_protected', + type: 'USERCONFIG', + key: 'replaced_at_runtime1', + properties: { + id: 'root_standard_user_config', + life: { + createdTime: Date.now(), + createdUser: '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 + } +}, { id: 'root_system_config_protected', type: 'SYSTEMCONFIG', key: null, diff --git a/packages/itmat-types/src/types/coreErrors.ts b/packages/itmat-types/src/types/coreErrors.ts index b08c552ce..eda8c7de9 100644 --- a/packages/itmat-types/src/types/coreErrors.ts +++ b/packages/itmat-types/src/types/coreErrors.ts @@ -9,7 +9,8 @@ export enum enumCoreErrors { NO_PERMISSION_ERROR = 'NO_PERMISSION_ERROR', FILE_STREAM_ERROR = 'FILE_STREAM_ERROR', OBJ_STORE_ERROR = 'OBJ_STORE_ERROR', - UNQUALIFIED_ERROR = 'UNQUALIFIED_ERROR' + UNQUALIFIED_ERROR = 'UNQUALIFIED_ERROR', + NOT_IMPLEMENTED = 'NOT_IMPLEMENTED' } export enum enumRequestErrorCodes { diff --git a/packages/itmat-types/src/types/permission.ts b/packages/itmat-types/src/types/permission.ts index 6812d5099..cef6b1587 100644 --- a/packages/itmat-types/src/types/permission.ts +++ b/packages/itmat-types/src/types/permission.ts @@ -15,9 +15,9 @@ export interface IDocumentLevelPermission { // study manager in addition can manage the study data and study itself, e.g., editing study metadata // study user only have access to data filtered by dataPermissions export enum enumStudyRoles { - STUDY_MANAGER = 'STUDY MANAGER', - STUDY_ASSISTANT = 'STUDY ASSISTANT', - STUDY_USER = 'STUDY USER' + STUDY_MANAGER = 'STUDY_MANAGER', + STUDY_ASSISTANT = 'STUDY_ASSISTANT', + STUDY_USER = 'STUDY_USER' } export interface IDataPermission { diff --git a/packages/itmat-types/src/types/study.ts b/packages/itmat-types/src/types/study.ts index c5654d40c..f0e4f5e49 100644 --- a/packages/itmat-types/src/types/study.ts +++ b/packages/itmat-types/src/types/study.ts @@ -41,7 +41,7 @@ export interface IProject { export interface IDataClip { fieldId: string; - value?: string; + value: string; subjectId: string; visitId: string; file?: FileUpload;