diff --git a/package-lock.json b/package-lock.json
index f97969d42..272911546 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"@apollo/server": "4.0.0",
"@apollo/subgraph": "2.5.2",
+ "@icgc-argo/ego-token-utils": "^8.2.0",
"@overturebio-stack/lectern-client": "1.4.0",
"@types/mongoose-paginate-v2": "^1.3.11",
"adm-zip": "^0.4.16",
@@ -982,6 +983,17 @@
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
+ "node_modules/@icgc-argo/ego-token-utils": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@icgc-argo/ego-token-utils/-/ego-token-utils-8.3.0.tgz",
+ "integrity": "sha512-11Qhuh7LbHsrgZJ6Z4ZxYLY4IINkarzu/WamNMggeDK9oY8R5n5CaimB86sRc1ceHnRpzbMY6S82JovTmAOxIw==",
+ "dependencies": {
+ "jsonwebtoken": "^8.5.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@josephg/resolvable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz",
@@ -9943,6 +9955,14 @@
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"requires": {}
},
+ "@icgc-argo/ego-token-utils": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@icgc-argo/ego-token-utils/-/ego-token-utils-8.3.0.tgz",
+ "integrity": "sha512-11Qhuh7LbHsrgZJ6Z4ZxYLY4IINkarzu/WamNMggeDK9oY8R5n5CaimB86sRc1ceHnRpzbMY6S82JovTmAOxIw==",
+ "requires": {
+ "jsonwebtoken": "^8.5.1"
+ }
+ },
"@josephg/resolvable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@josephg/resolvable/-/resolvable-1.0.1.tgz",
diff --git a/package.json b/package.json
index ba06ef358..78458c403 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
"@apollo/server": "4.0.0",
"@overturebio-stack/lectern-client": "1.4.0",
"@types/mongoose-paginate-v2": "^1.3.11",
+ "@icgc-argo/ego-token-utils": "^8.2.0",
"adm-zip": "^0.4.16",
"async": "^3.0.1",
"bcrypt-nodejs": "^0.0.3",
diff --git a/src/app.ts b/src/app.ts
index c8260d2f1..ae3716d0e 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -37,6 +37,7 @@ import icgcImport from './routes/icgc-import';
import exceptionRouter from './routes/exception';
import responseTime from 'response-time';
import morgan from 'morgan';
+import { EgoJwtData } from '@icgc-argo/ego-token-utils/dist/common';
const L = loggerFor(__filename);
@@ -44,6 +45,15 @@ process.title = 'clinical';
// Create Express server
const app = express();
+
+export type GlobalGqlContext = {
+ isUserRequest: boolean;
+ egoToken: string;
+ Authorization: string;
+ userJwtData: EgoJwtData | undefined;
+ dataLoaders: {};
+};
+
app.set('port', process.env.PORT || 3000);
app.set('graphqlPort', process.env.GRAPHQLPORT || 3001);
app.use(bodyParser.json());
diff --git a/src/dictionary/api.ts b/src/dictionary/api.ts
index cd12cdb2d..d6f780207 100644
--- a/src/dictionary/api.ts
+++ b/src/dictionary/api.ts
@@ -33,7 +33,7 @@ class SchemaController {
async update(req: Request, res: Response) {
const version: string = req.body.version;
const sync: boolean = req.query.sync;
- const initiator = ControllerUtils.getUserFromToken(req);
+ const initiator = ControllerUtils.getUserFromRequest(req);
const migration = await manager.instance().updateSchemaVersion(version, initiator, sync);
return res.status(200).send(migration);
}
@@ -49,7 +49,7 @@ class SchemaController {
@HasFullWriteAccess()
async dryRunUpdate(req: Request, res: Response) {
const version: string = req.body.version;
- const initiator = ControllerUtils.getUserFromToken(req);
+ const initiator = ControllerUtils.getUserFromRequest(req);
const migration = await manager.instance().dryRunSchemaUpgrade(version, initiator);
return res.status(200).send(migration);
}
diff --git a/src/schemas/clinical-resolvers/clearClinicalSubmissionResolver.ts b/src/schemas/clinical-resolvers/clearClinicalSubmissionResolver.ts
new file mode 100644
index 000000000..b05519373
--- /dev/null
+++ b/src/schemas/clinical-resolvers/clearClinicalSubmissionResolver.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+import { GlobalGqlContext } from '../../app';
+import submissionAPI from '../../submission/submission-api';
+import { convertClinicalSubmissionDataToGql } from '../utils';
+
+const clearClinicalSubmissionResolver = {
+ clearClinicalSubmission: async (
+ obj: unknown,
+ args: { programShortName: string; fileType: string; version: string },
+ contextValue: any,
+ ) => {
+ const { programShortName, fileType, version } = args;
+ const response = await submissionAPI.clearFileDataFromActiveSubmission(
+ programShortName,
+ fileType,
+ version,
+ (contextValue).egoToken,
+ );
+ return convertClinicalSubmissionDataToGql(programShortName, {
+ submission: response,
+ });
+ },
+};
+
+export default clearClinicalSubmissionResolver;
diff --git a/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts b/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts
index 1e5671c3c..420b44a17 100644
--- a/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts
+++ b/src/schemas/clinical-resolvers/clinicalSubmissionDataResolver.ts
@@ -21,7 +21,7 @@ import submissionAPI from '../../submission/submission-api';
import get from 'lodash/get';
import { ActiveClinicalSubmission } from '../../submission/submission-entities';
import { DeepReadonly } from 'deep-freeze';
-import { convertClinicalFileErrorToGql, convertClinicalSubmissionEntityToGql } from '../utils';
+import { convertClinicalSubmissionDataToGql } from '../utils';
import { getClinicalEntitiesData } from '../../dictionary/api';
const clinicalSubmissionResolver = {
@@ -35,35 +35,4 @@ const clinicalSubmissionResolver = {
},
};
-const convertClinicalSubmissionDataToGql = (
- programShortName: string,
- data: {
- submission: DeepReadonly | undefined;
- batchErrors?: { message: string; batchNames: string[]; code: string }[];
- },
-) => {
- const submission = get(data, 'submission', {} as Partial);
- const fileErrors = get(data, 'batchErrors', [] as typeof data.batchErrors);
- const clinicalEntities = get(submission, 'clinicalEntities');
- return {
- id: submission?._id || undefined,
- programShortName,
- state: submission?.state || undefined,
- version: submission?.version || undefined,
- updatedBy: submission?.updatedBy || undefined,
- updatedAt: submission?.updatedAt ? submission.updatedAt : undefined,
- clinicalEntities: async () => {
- const clinicalSubmissionTypeList = await getClinicalEntitiesData('false'); // to confirm for true or false
- const filledClinicalEntities = clinicalSubmissionTypeList.map(clinicalType => ({
- clinicalType,
- ...(clinicalEntities ? clinicalEntities[clinicalType.name] : {}),
- }));
- return filledClinicalEntities.map(clinicalEntity =>
- convertClinicalSubmissionEntityToGql(clinicalEntity?.clinicalType.name, clinicalEntity),
- );
- },
- fileErrors: fileErrors?.map(convertClinicalFileErrorToGql),
- };
-};
-
export default clinicalSubmissionResolver;
diff --git a/src/schemas/gqlTypeDefs.ts b/src/schemas/gqlTypeDefs.ts
index 074fddb50..7663f465c 100644
--- a/src/schemas/gqlTypeDefs.ts
+++ b/src/schemas/gqlTypeDefs.ts
@@ -37,6 +37,18 @@ const typeDefs = gql`
clinicalSubmissions(programShortName: String!): ClinicalSubmissionData!
}
+ type Mutation {
+ """
+ Clear Clinical Submission
+ fileType is optional, if it is not provided all fileTypes will be cleared. The values for fileType are the same as the file names from each template (ex. donor, specimen)
+ """
+ clearClinicalSubmission(
+ programShortName: String!
+ version: String!
+ fileType: String
+ ): ClinicalSubmissionData!
+ }
+
scalar DateTime
"""
diff --git a/src/schemas/resolvers.ts b/src/schemas/resolvers.ts
index 4584ff905..e8d89b0f8 100644
--- a/src/schemas/resolvers.ts
+++ b/src/schemas/resolvers.ts
@@ -1,6 +1,7 @@
import clinicalRegistrationResolver from './clinical-resolvers/clinicalRegistrationData';
import clinicalSearchResultResolver from './clinical-resolvers/clinicalSearchResults';
import clinicalSubmissionResolver from './clinical-resolvers/clinicalSubmissionDataResolver';
+import clearClinicalSubmissionResolver from './clinical-resolvers/clearClinicalSubmissionResolver';
const resolvers = {
Query: {
@@ -8,6 +9,9 @@ const resolvers = {
...clinicalSearchResultResolver,
...clinicalSubmissionResolver,
},
+ Mutation: {
+ ...clearClinicalSubmissionResolver,
+ },
};
export default resolvers;
diff --git a/src/schemas/utils.ts b/src/schemas/utils.ts
index 3a1d40bb2..c4016a561 100644
--- a/src/schemas/utils.ts
+++ b/src/schemas/utils.ts
@@ -19,10 +19,12 @@
import get from 'lodash/get';
import {
+ ActiveClinicalSubmission,
SubmissionValidationError,
SubmissionValidationUpdate,
} from '../submission/submission-entities';
import { DeepReadonly } from 'deep-freeze';
+import { getClinicalEntitiesData } from '../dictionary/api';
const ARRAY_DELIMITER_CHAR = '|';
@@ -209,6 +211,37 @@ const convertClinicalSubmissionUpdateToGql = (updateData: UpdateData) => {
};
};
+const convertClinicalSubmissionDataToGql = (
+ programShortName: string,
+ data: {
+ submission: DeepReadonly | undefined;
+ batchErrors?: { message: string; batchNames: string[]; code: string }[];
+ },
+) => {
+ const submission = get(data, 'submission', {} as Partial);
+ const fileErrors = get(data, 'batchErrors', [] as typeof data.batchErrors);
+ const clinicalEntities = get(submission, 'clinicalEntities');
+ return {
+ id: submission?._id || undefined,
+ programShortName,
+ state: submission?.state || undefined,
+ version: submission?.version || undefined,
+ updatedBy: submission?.updatedBy || undefined,
+ updatedAt: submission?.updatedAt ? submission.updatedAt : undefined,
+ clinicalEntities: async () => {
+ const clinicalSubmissionTypeList = await getClinicalEntitiesData('false'); // to confirm for true or false
+ const filledClinicalEntities = clinicalSubmissionTypeList.map(clinicalType => ({
+ clinicalType,
+ ...(clinicalEntities ? clinicalEntities[clinicalType.name] : {}),
+ }));
+ return filledClinicalEntities.map(clinicalEntity =>
+ convertClinicalSubmissionEntityToGql(clinicalEntity?.clinicalType.name, clinicalEntity),
+ );
+ },
+ fileErrors: fileErrors?.map(convertClinicalFileErrorToGql),
+ };
+};
+
export {
convertClinicalRecordToGql,
convertRegistrationErrorToGql,
@@ -216,4 +249,5 @@ export {
convertRegistrationStatsToGql,
RegistrationErrorData,
convertClinicalSubmissionEntityToGql,
+ convertClinicalSubmissionDataToGql,
};
diff --git a/src/server.ts b/src/server.ts
index 36e3e0395..ac0d2c98e 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -16,6 +16,8 @@
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
+import { token } from 'morgan';
+
console.time('boot time');
// Has to import config before any other import uses the configurations
import { AppConfig, RxNormDbConfig, KafkaConfigurations } from './config';
@@ -29,11 +31,16 @@ import { Server } from 'http';
// we import here to allow configs to fully load
import * as bootstrap from './bootstrap';
import app from './app';
+import { GlobalGqlContext } from './app';
import { database, up } from 'migrate-mongo';
-import { ApolloServer } from '@apollo/server';
-import { startStandaloneServer } from '@apollo/server/standalone';
+import { ApolloServer, ContextFunction } from '@apollo/server';
+import {
+ StandaloneServerContextFunctionArgument,
+ startStandaloneServer,
+} from '@apollo/server/standalone';
import schema from './schemas/index';
+import { EgoJwtData } from '@icgc-argo/ego-token-utils/dist/common';
let secrets: any = {};
let server: Server;
@@ -153,10 +160,37 @@ let server: Server;
/**
* Start Graphql server.
*/
- const apolloServer = new ApolloServer({
+
+ const context: ContextFunction<
+ [StandaloneServerContextFunctionArgument],
+ GlobalGqlContext
+ > = async ({ req, res }) => {
+ // Get the user token from the headers.
+ const authHeader = req.headers.authorization;
+ let userJwtData: EgoJwtData | undefined = undefined;
+ try {
+ if (authHeader) {
+ const jwt = authHeader.replace('Bearer ', '');
+ }
+ } catch (err) {
+ userJwtData = undefined;
+ }
+ // Add the user to the context
+ return {
+ isUserRequest: true,
+ egoToken: (authHeader || '').split('Bearer ').join(''),
+ Authorization: `Bearer ${(authHeader || '').replace(/^Bearer[\s]*!/, '')}` || '',
+ userJwtData,
+ dataLoaders: {},
+ };
+ };
+
+ const apolloServer = new ApolloServer({
schema,
});
+
const { url } = await startStandaloneServer(apolloServer, {
+ context,
listen: { port: app.get('graphqlPort') },
});
diff --git a/src/submission/submission-api.ts b/src/submission/submission-api.ts
index 4e2a3e31c..f94845187 100644
--- a/src/submission/submission-api.ts
+++ b/src/submission/submission-api.ts
@@ -35,6 +35,7 @@ import { HasFullWriteAccess, HasProgramWriteAccess } from '../decorators';
import _ from 'lodash';
import { batchErrorMessage } from './submission-error-messages';
import * as fs from 'fs';
+import { GlobalGqlContext } from '../app';
const L = loggerFor(__filename);
const fsPromises = fs.promises;
@@ -62,7 +63,7 @@ class SubmissionController {
return;
}
const programId = req.params.programId;
- const creator = ControllerUtils.getUserFromToken(req);
+ const creator = ControllerUtils.getUserFromRequest(req);
const file = req.file;
let records: ReadonlyArray;
try {
@@ -136,7 +137,7 @@ class SubmissionController {
return;
}
- const user = ControllerUtils.getUserFromToken(req);
+ const user = ControllerUtils.getUserFromRequest(req);
const newClinicalData: NewClinicalEntity[] = [];
const tsvParseErrors: SubmissionBatchError[] = [];
const clinicalFiles = req.files as Express.Multer.File[];
@@ -191,7 +192,7 @@ class SubmissionController {
async validateActiveSubmission(req: Request, res: Response) {
if (await submissionSystemIsDisabled(res)) return;
const { versionId, programId } = req.params;
- const updater = ControllerUtils.getUserFromToken(req);
+ const updater = ControllerUtils.getUserFromRequest(req);
const result = await submission.operations.validateMultipleClinical({
versionId,
programId,
@@ -207,7 +208,7 @@ class SubmissionController {
async clearFileFromActiveSubmission(req: Request, res: Response) {
if (await submissionSystemIsDisabled(res)) return;
const { programId, versionId, fileType } = req.params;
- const updater = ControllerUtils.getUserFromToken(req);
+ const updater = ControllerUtils.getUserFromRequest(req);
L.debug(`Entering clearFileFromActiveSubmission: ${{ programId, versionId, fileType }}`);
const updatedSubmission = await submission.operations.clearSubmissionData({
programId,
@@ -219,11 +220,32 @@ class SubmissionController {
return res.status(200).send(updatedSubmission || {});
}
+ // @HasProgramWriteAccess((programId: string) => programId)
+ async clearFileDataFromActiveSubmission(
+ programId: string,
+ fileType: string,
+ versionId: string,
+ token: string,
+ ) {
+ const submissionSystemDisabled = await persistedConfig.getSubmissionDisabledState();
+ if (submissionSystemDisabled) return;
+ const updater = ControllerUtils.getUserFromToken(token);
+ L.debug(`Entering clearFileDataFromActiveSubmission: ${{ programId, versionId, fileType }}`);
+ const updatedSubmission = await submission.operations.clearSubmissionData({
+ programId,
+ versionId,
+ fileType,
+ updater,
+ });
+ // Handle case where submission was cleared and is now undefined
+ return updatedSubmission;
+ }
+
@HasProgramWriteAccess((req: Request) => req.params.programId)
async commitActiveSubmission(req: Request, res: Response) {
if (await submissionSystemIsDisabled(res)) return;
const { versionId, programId } = req.params;
- const updater = ControllerUtils.getUserFromToken(req);
+ const updater = ControllerUtils.getUserFromRequest(req);
const activeSubmission = await submission2Clinical.commitClinicalSubmission({
versionId,
programId,
@@ -247,7 +269,7 @@ class SubmissionController {
async reopenActiveSubmission(req: Request, res: Response) {
if (await submissionSystemIsDisabled(res)) return;
const { versionId, programId } = req.params;
- const updater = ControllerUtils.getUserFromToken(req);
+ const updater = ControllerUtils.getUserFromRequest(req);
const activeSubmission = await submission.operations.reopenClinicalSubmission({
versionId,
programId,
@@ -299,7 +321,7 @@ class SubmissionController {
const samplesSubmitterIds =
req.query.sampleSubmitterIds && req.query.sampleSubmitterIds.split(',');
const dryRun = req.query.dryRun === 'false' ? false : true;
- const updater = ControllerUtils.getUserFromToken(req);
+ const updater = ControllerUtils.getUserFromRequest(req);
L.info(
`Delete registered samples called, caller ${updater}, ids: ${samplesSubmitterIds}, programId: ${programId}`,
);
diff --git a/src/utils.ts b/src/utils.ts
index 6f0ad85d5..5c018ce83 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -136,7 +136,7 @@ export namespace ControllerUtils {
};
// checks authHeader + decoded jwt and returns the user name
- export const getUserFromToken = (req: Request): string => {
+ export const getUserFromRequest = (req: Request): string => {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new Error("can't get here without auth header");
@@ -147,6 +147,15 @@ export namespace ControllerUtils {
}
return decoded.context.user.firstName + ' ' + decoded.context.user.lastName;
};
+
+ // checks authHeader + decoded jwt and returns the user name
+ export const getUserFromToken = (token: string) => {
+ const decoded = jwt.decode(token) as any;
+ if (!decoded || !decoded.context || !decoded.context.user) {
+ throw new Error('invalid token structure');
+ }
+ return decoded.context.user.firstName + ' ' + decoded.context.user.lastName;
+ };
}
export namespace DonorUtils {