From 94624677b034f42dd64085a29d272ca6e7ccf879 Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Wed, 22 Apr 2026 15:19:03 -0700 Subject: [PATCH 01/25] - DEVSU-2310 - Add migrations to add new legends table and rework pathway analysis legend column - Add new table to store pathway analysis legends - Rework association column between pathway analysis and legends - Add unit tests for new legends endpoints - Update swagger documentation for new legends endpoints - Update mockReportData json with test legend image --- app/models/index.js | 17 ++ .../genomic/summary/pathwayAnalysis.js | 14 +- app/models/reports/legend.js | 80 ++++++ app/routes/report/images.js | 44 +++ app/routes/report/legend/index.js | 148 ++++++++++ app/routes/swagger/swagger.json | 229 +++++++++++++++ ...21000000-DEVSU-2310-create-legend-table.js | 55 ++++ ...-2310-update-pathway-analysis-legend-fk.js | 48 ++++ test/routes/report/legend.test.js | 265 ++++++++++++++++++ .../report/summary/pathwayAnalysis.test.js | 24 +- test/testData/mockReportData.json | 11 +- 11 files changed, 917 insertions(+), 18 deletions(-) create mode 100644 app/models/reports/legend.js create mode 100644 app/routes/report/legend/index.js create mode 100644 migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js create mode 100644 migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js create mode 100644 test/routes/report/legend.test.js diff --git a/app/models/index.js b/app/models/index.js index 1acc09fb6..c9cc32cb1 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -133,6 +133,23 @@ summary.probeResults = require('./reports/probeResults')(sequelize, Sq); summary.therapeuticTargets = require('./reports/genomic/summary/therapeuticTargets')(sequelize, Sq); summary.microbial = require('./reports/genomic/summary/microbial')(sequelize, Sq); +// Pathway Analysis Legends +const pathwayAnalysisLegends = require('./reports/legend')(sequelize, Sq); + +pathwayAnalysisLegends.belongsTo(analysisReports, { + as: 'report', foreignKey: 'reportId', targetKey: 'id', onDelete: 'CASCADE', constraints: true, +}); +analysisReports.hasMany(pathwayAnalysisLegends, { + as: 'legends', foreignKey: 'reportId', onDelete: 'CASCADE', constraints: true, +}); + +summary.pathwayAnalysis.belongsTo(pathwayAnalysisLegends, { + as: 'legend', foreignKey: 'legendId', targetKey: 'id', onDelete: 'SET NULL', constraints: true, +}); +pathwayAnalysisLegends.hasMany(summary.pathwayAnalysis, { + as: 'pathwayAnalyses', foreignKey: 'legendId', onDelete: 'SET NULL', constraints: true, +}); + analysisReports.belongsTo(user, { as: 'createdBy', foreignKey: 'createdBy_id', targetKey: 'id', onDelete: 'SET NULL', controlled: true, }); diff --git a/app/models/reports/genomic/summary/pathwayAnalysis.js b/app/models/reports/genomic/summary/pathwayAnalysis.js index d646941c0..52f6a5826 100644 --- a/app/models/reports/genomic/summary/pathwayAnalysis.js +++ b/app/models/reports/genomic/summary/pathwayAnalysis.js @@ -12,6 +12,15 @@ module.exports = (sequelize, Sq) => { key: 'id', }, }, + legendId: { + name: 'legendId', + field: 'legend_id', + type: Sq.INTEGER, + references: { + model: 'pathway_analysis_legends', + key: 'id', + }, + }, pathway: { type: Sq.TEXT, allowNull: true, @@ -20,11 +29,6 @@ module.exports = (sequelize, Sq) => { schema: {format: 'svg', type: 'string'}, }, }, - legend: { - type: Sq.ENUM(['v1', 'v2', 'v3', 'custom']), - allowNull: false, - defaultValue: 'v3', - }, }, { ...DEFAULT_REPORT_OPTIONS, tableName: 'reports_summary_pathway_analysis', diff --git a/app/models/reports/legend.js b/app/models/reports/legend.js new file mode 100644 index 000000000..2ed6035fa --- /dev/null +++ b/app/models/reports/legend.js @@ -0,0 +1,80 @@ +const {DEFAULT_COLUMNS, DEFAULT_REPORT_OPTIONS} = require('../base'); + +module.exports = (sequelize, Sq) => { + const legend = sequelize.define( + 'legend', + { + ...DEFAULT_COLUMNS, + reportId: { + name: 'reportId', + field: 'report_id', + type: Sq.INTEGER, + references: { + model: 'reports', + key: 'id', + }, + }, + format: { + type: Sq.ENUM('PNG', 'JPG'), + defaultValue: 'PNG', + }, + filename: { + type: Sq.TEXT, + allowNull: false, + }, + version: { + type: Sq.TEXT, + allowNull: false, + }, + data: { + type: Sq.TEXT, + allowNull: false, + }, + title: { + type: Sq.TEXT, + }, + caption: { + type: Sq.TEXT, + }, + height: { + type: Sq.INTEGER, + }, + width: { + type: Sq.INTEGER, + }, + }, + { + ...DEFAULT_REPORT_OPTIONS, + tableName: 'pathway_analysis_legends', + scopes: { + public: { + attributes: { + exclude: ['id', 'reportId', 'deletedAt', 'updatedBy'], + }, + }, + versionlist: { + attributes: { + exclude: ['id', 'deletedAt', 'updatedBy', 'data'], + }, + }, + }, + }, + ); + + // set instance methods + legend.prototype.view = function (scope) { + if (scope === 'public') { + const { + id, reportId, deletedAt, updatedBy, ...publicView + } = this.dataValues; + return publicView; + } + if (scope === 'versionlist') { + const {id, deletedAt, updatedBy, exclue, ...versionlistView} = this.dataValues; + return versionlistView; + } + return this; + }; + + return legend; +}; diff --git a/app/routes/report/images.js b/app/routes/report/images.js index 370475133..38b56edc9 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -47,6 +47,50 @@ const uploadReportImage = async (reportId, key, image, options = {}) => { } }; +/** + * Resize, reformat and upload a legend image to the pathway_analysis_legends table + * + * @param {Number} reportId - The primary key for the report this legend belongs to (to create FK relationship) + * @param {string} version - The legend version identifier (e.g. 'v1', 'v2', 'v3') + * @param {Buffer|string} image - Buffer containing image data or the absolute path to the image file + * @param {object} options - An object containing additional image upload options + * + * @property {string} options.filename - An optional filename for the image + * @property {string} options.caption - An optional caption for the image + * @property {string} options.title - An optional title for the image + * @property {string} options.category - An optional category for the image + * @property {object} options.transaction - An optional transaction to run the create under + * + * @returns {Promise} - Returns the created legend db entry + * @throws {Promise} - Something goes wrong with image processing and saving entry + */ +const uploadLegendImage = async (reportId, version, image, options = {}) => { + logger.verbose(`Loading legend (${version}) image`); + + const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT}; + + try { + const imageData = await processImage(image, config.size, config.format); + + return db.models.legend.create({ + reportId, + format: config.format, + filename: options.filename, + version, + data: imageData, + caption: options.caption, + title: options.title, + width: config.width, + height: config.height, + category: options.category, + }, {transaction: options.transaction}); + } catch (error) { + logger.error(`Error processing legend image ${options.filename} ${error}`); + throw new Error(`Error processing legend image ${options.filename} ${error}`); + } +}; + module.exports = { uploadReportImage, + uploadLegendImage, }; diff --git a/app/routes/report/legend/index.js b/app/routes/report/legend/index.js new file mode 100644 index 000000000..079c62785 --- /dev/null +++ b/app/routes/report/legend/index.js @@ -0,0 +1,148 @@ +const HTTP_STATUS = require('http-status-codes'); +const express = require('express'); + +const db = require('../../../models'); +const logger = require('../../../log'); +const {uploadLegendImage} = require('../images'); + +const router = express.Router({mergeParams: true}); + +// Middleware for legend image +router.param('legend', async (req, res, next, imgIdent) => { + let result; + try { + result = await db.models.legend.findOne({ + where: {ident: imgIdent, reportId: req.report.id}, + }); + } catch (error) { + logger.error(`Unable to lookup legend image error: ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to lookup legend image'}}); + } + + if (!result) { + const message = `Unable to find legend image ${imgIdent} for report ${req.report.ident}`; + logger.error(message); + return res.status(HTTP_STATUS.NOT_FOUND).json({error: {message}}); + } + + // Add legend data to request + req.legend = result; + return next(); +}); + +// Routes for operating on specific legends +// !!Should not add update routes for legend images (PUT, PATCH, etc.) +// because updates will create duplicate image entries!! +router.route('/:legend([A-z0-9-]{36})') + .get((req, res) => { + return res.json(req.legend.view('public')); + }) + .delete(async (req, res) => { + // Whether to hard or soft delete legend + const force = (typeof req.query.force === 'boolean') ? req.query.force : false; + + // Delete legend image + try { + await req.legend.destroy({force}); + return res.status(HTTP_STATUS.NO_CONTENT).send(); + } catch (error) { + logger.error(`Error while deleting legend image ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while deleting legend image'}}); + } + }); + +// Route for adding a legend image +router.route('/') + .post(async (req, res) => { + // Check that image files were uploaded + if (!req.files || Object.keys(req.files).length === 0) { + logger.error('No attached images to upload'); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); + } + + const versions = []; + + for (let [version, value] of Object.entries(req.files)) { + version = version.trim(); + + // Check if version is a duplicate + if (versions.includes(version) || Array.isArray(value)) { + logger.error(`Duplicate versions are not allowed. Duplicate version: ${version}`); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Duplicate versions are not allowed. Duplicate version: ${version}`}}); + } + + versions.push(version); + } + + try { + const results = await Promise.all(Object.entries(req.files).map(async ([version, image]) => { + // Remove trailing space from version + version = version.trim(); + + try { + // Set options (value or undefined) + const options = { + filename: image.name.trim(), + title: req.body[`${version}_title`], + caption: req.body[`${version}_caption`], + }; + + // Load image + await uploadLegendImage(req.report.id, version, image.data, options); + // Return that this image was uploaded successfully + return {version, upload: 'successful'}; + } catch (error) { + return {version, upload: 'failed', error}; + } + })); + return res.status(HTTP_STATUS.MULTI_STATUS).json(results); + } catch (error) { + logger.error(`Error while uploading images ${error}`); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error}`}}); + } + }); + +// Route for getting a legend image +router.route('/retrieve/:version') + .get(async (req, res) => { + const versions = (req.params.version.includes(',')) ? req.params.version.split(',') : [req.params.version]; + + try { + const results = await db.models.imageData.scope('public').findAll({ + where: { + reportId: req.report.id, + version: versions, + }, + order: [['version', 'ASC']], + }); + + return res.json(results); + } catch (error) { + logger.error(`Error while getting report images with version: ${req.params.version} ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ + error: {message: 'Error while getting report images by version'}, + }); + } + }); + +// Route for getting a list of legend versions for the report +router.route('/versionlist') + .get(async (req, res) => { + try { + const results = await db.models.legend.scope('versionlist').findAll({ + where: { + reportId: req.report.id, + }, + order: [['version', 'ASC']], + }); + + return res.json(results); + } catch (error) { + logger.error(`Error while getting report legend versionlist: ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ + error: {message: 'Error while getting report legend versionlist'}, + }); + } + }); + +module.exports = router; diff --git a/app/routes/swagger/swagger.json b/app/routes/swagger/swagger.json index e9fad861f..406af819a 100644 --- a/app/routes/swagger/swagger.json +++ b/app/routes/swagger/swagger.json @@ -4595,6 +4595,225 @@ } } }, + "/reports/{report}/legend": { + "post": { + "summary": "Add Legend Images", + "description": "Upload new legend images to report", + "parameters": [ + { + "$ref": "#/components/parameters/report" + } + ], + "requestBody": { + "description": "Legend images to upload (allows multiple images with different versions)", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "{version}": { + "type": "string", + "format": "binary", + "description": "legend image to upload" + }, + "{version}_title": { + "type": "string", + "description": "title of legend image specified by version" + }, + "{version}_caption": { + "type": "string", + "description": "caption of legend image specified by version" + } + } + } + } + } + }, + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "207": { + "description": "Returns an array of objects indicating which legend images were uploaded successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "upload": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Malformed request syntax or no files attached" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Report not found" + } + } + } + }, + "/reports/{report}/legend/{legend}": { + "get": { + "summary": "Get Legend Image", + "description": "Retrieve a specific legend image", + "parameters": [ + { + "$ref": "#/components/parameters/report" + }, + { + "$ref": "#/components/parameters/legend" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the details of the specified legend image" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Report or legend not found" + } + } + }, + "delete": { + "summary": "Delete Legend Image", + "description": "Removes the specified legend image", + "parameters": [ + { + "$ref": "#/components/parameters/report" + }, + { + "$ref": "#/components/parameters/legend" + }, + { + "name": "force", + "in": "query", + "required": false, + "description": "Whether to do a hard delete or not", + "schema": { + "type": "boolean" + } + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "204": { + "description": "No content" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Report or legend not found" + } + } + } + }, + "/reports/{report}/legend/retrieve/{version}": { + "get": { + "summary": "Get Legend Images by Version", + "description": "Retrieve legend image data for specified versions", + "parameters": [ + { + "$ref": "#/components/parameters/report" + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Comma separated list of legend versions (e.g. v1,v2)", + "schema": { + "type": "string" + } + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns an array of legend images that match the version" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Report not found" + } + } + } + }, + "/reports/{report}/legend/versionlist": { + "get": { + "summary": "Get Legend Version List", + "description": "Retrieve a list of all legend versions for a report", + "parameters": [ + { + "$ref": "#/components/parameters/report" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns an array of legend entries without image data" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Report not found" + } + } + } + }, "/reports/{report}/kb-matches": { "get": { "summary": "Get KB Matches", @@ -8837,6 +9056,16 @@ "format": "UUIDv4" } }, + "legend": { + "name": "legend", + "in": "path", + "required": true, + "description": "legend image ident", + "schema": { + "type": "string", + "format": "UUIDv4" + } + }, "alteration": { "name": "alteration", "in": "path", diff --git a/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js new file mode 100644 index 000000000..187b9274b --- /dev/null +++ b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js @@ -0,0 +1,55 @@ +const TABLE = 'pathway_analysis_legends'; +const {DEFAULT_COLUMNS} = require('../../app/models/base'); + +module.exports = { + up: (queryInterface, Sq) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable(TABLE, { + ...DEFAULT_COLUMNS, + reportId: { + name: 'reportId', + field: 'report_id', + type: Sq.INTEGER, + references: { + model: 'reports', + key: 'id', + }, + }, + format: { + type: Sq.ENUM('PNG', 'JPG'), + defaultValue: 'PNG', + }, + filename: { + type: Sq.TEXT, + allowNull: false, + }, + version: { + type: Sq.TEXT, + allowNull: false, + }, + data: { + type: Sq.TEXT, + allowNull: false, + }, + title: { + type: Sq.TEXT, + }, + caption: { + type: Sq.TEXT, + }, + height: { + type: Sq.INTEGER, + }, + width: { + type: Sq.INTEGER, + }, + }, {transaction}); + }); + }, + + down: (queryInterface) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable(TABLE, {transaction}); + }); + }, +}; diff --git a/migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js b/migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js new file mode 100644 index 000000000..e2abb7582 --- /dev/null +++ b/migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js @@ -0,0 +1,48 @@ +const TABLE = 'reports_summary_pathway_analysis'; + +module.exports = { + up: (queryInterface, Sq) => { + return queryInterface.sequelize.transaction(async (transaction) => { + // Add the new legend_id column first + await queryInterface.addColumn(TABLE, 'legend_id', { + type: Sq.INTEGER, + references: { + model: 'pathway_analysis_legends', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + allowNull: true, + }, {transaction}); + + // Datafix: map existing legend values to legend_id using legends table + await queryInterface.sequelize.query( + `UPDATE "${TABLE}" SET legend_id = l.id FROM pathway_analysis_legends l WHERE "${TABLE}".legend = l.version;`, + {transaction}, + ); + + // Remove the existing ENUM column + await queryInterface.removeColumn(TABLE, 'legend', {transaction}); + + // Drop the ENUM type created by Sequelize + await queryInterface.sequelize.query( + 'DROP TYPE IF EXISTS "enum_reports_summary_pathway_analysis_legend";', + {transaction}, + ); + }); + }, + + down: (queryInterface, Sq) => { + return queryInterface.sequelize.transaction(async (transaction) => { + // Remove the foreign key column + await queryInterface.removeColumn(TABLE, 'legend_id', {transaction}); + + // Re-add the original ENUM column + await queryInterface.addColumn(TABLE, 'legend', { + type: Sq.ENUM(['v1', 'v2', 'v3', 'custom']), + allowNull: false, + defaultValue: 'v3', + }, {transaction}); + }); + }, +}; diff --git a/test/routes/report/legend.test.js b/test/routes/report/legend.test.js new file mode 100644 index 000000000..9ea712842 --- /dev/null +++ b/test/routes/report/legend.test.js @@ -0,0 +1,265 @@ +const HTTP_STATUS = require('http-status-codes'); +const supertest = require('supertest'); +const getPort = require('get-port'); +const db = require('../../../app/models'); + +const CONFIG = require('../../../app/config'); +const {listen} = require('../../../app'); + +CONFIG.set('env', 'test'); +const {username, password} = CONFIG.get('testing'); + +let server; +let request; + +const legendProperties = [ + 'ident', 'createdAt', 'updatedAt', 'format', 'filename', + 'version', 'data', 'title', 'caption', +]; + +const checkLegend = (legendObject) => { + legendProperties.forEach((field) => { + expect(legendObject).toHaveProperty(field); + }); + expect(legendObject).toEqual(expect.not.objectContaining({ + id: expect.any(Number), + reportId: expect.any(Number), + deletedAt: expect.any(String), + })); +}; + +const checkLegends = (legends) => { + legends.forEach((legend) => { + checkLegend(legend); + }); +}; + +// Start API +beforeAll(async () => { + const port = await getPort({port: CONFIG.get('web:port')}); + server = await listen(port); + request = supertest(server); +}); + +describe('/reports/{REPORTID}/legend', () => { + let report; + let fakeLegendData; + + beforeAll(async () => { + // Get genomic template + const template = await db.models.template.findOne({where: {name: 'genomic'}}); + // Create test report + report = await db.models.report.create({ + templateId: template.id, + patientId: 'PATIENT1234', + }); + + fakeLegendData = { + reportId: report.id, + filename: 'TestFile.png', + version: 'v1', + data: 'TestFileData', + }; + }); + + describe('GET', () => { + test('/{legend} - 200 Success', async () => { + const legend = await db.models.legend.create(fakeLegendData); + + const res = await request + .get(`/api/reports/${report.ident}/legend/${legend.ident}`) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + // Check that all fields are present and that data is correct + checkLegend(res.body); + + // Remove reportId before checking + const {reportId, ...legendData} = fakeLegendData; + expect(res.body).toEqual(expect.objectContaining(legendData)); + + await db.models.legend.destroy({where: {id: legend.id}, force: true}); + }); + + test('/retrieve/:version - 200 Success', async () => { + const version = 'v1'; + const testLegend = await db.models.legend.create({...fakeLegendData, version}); + + const res = await request + .get(`/api/reports/${report.ident}/legend/retrieve/${version}`) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + checkLegends(res.body); + expect(res.body).toEqual(expect.arrayContaining([ + expect.objectContaining({version: expect.stringContaining(version)}), + ])); + + await db.models.legend.destroy({where: {ident: testLegend.ident}, force: true}); + }); + + test('/{legend} - 404 Not Found', async () => { + await request + .get(`/api/reports/${report.ident}/legend/00000000-0000-0000-0000-000000000000`) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.NOT_FOUND); + }); + + test('/versionlist - 200 Success', async () => { + const legend1 = await db.models.legend.create({...fakeLegendData, version: 'v1'}); + const legend2 = await db.models.legend.create({...fakeLegendData, version: 'v2'}); + const legend3 = await db.models.legend.create({...fakeLegendData, version: 'v3'}); + + const res = await request + .get(`/api/reports/${report.ident}/legend/versionlist`) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + const versionlist = res.body.map((elem) => {return elem.version;}); + const expectedVersions = ['v1', 'v2', 'v3']; + expect(versionlist.every((elem) => {return expectedVersions.includes(elem);})).toBe(true); + + await db.models.legend.destroy({where: {ident: legend1.ident}, force: true}); + await db.models.legend.destroy({where: {ident: legend2.ident}, force: true}); + await db.models.legend.destroy({where: {ident: legend3.ident}, force: true}); + }); + }); + + describe('POST', () => { + test('POST / - 207 Multi-Status successful', async () => { + const res = await request + .post(`/api/reports/${report.ident}/legend`) + .attach('v1', 'test/testData/images/golden.jpg') + .auth(username, password) + .expect(HTTP_STATUS.MULTI_STATUS); + + // Check returned values match successful upload + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + const [result] = res.body; + + expect(result.version).toBe('v1'); + expect(result.upload).toBe('successful'); + expect(result.error).toBe(undefined); + }); + + test('POST / - (With title and caption) 207 Multi-Status successful', async () => { + const res = await request + .post(`/api/reports/${report.ident}/legend`) + .attach('v2', 'test/testData/images/golden.jpg') + .field('v2_title', 'Test title') + .field('v2_caption', 'Test caption') + .auth(username, password) + .expect(HTTP_STATUS.MULTI_STATUS); + + // Check returned values match successful upload + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + const [result] = res.body; + + expect(result.version).toBe('v2'); + expect(result.upload).toBe('successful'); + expect(result.error).toBe(undefined); + + // Test that title and caption were added to db + const legendData = await db.models.legend.findOne({ + where: { + reportId: report.id, + version: 'v2', + }, + }); + + expect(legendData).toEqual(expect.objectContaining({ + format: 'PNG', + filename: 'golden.jpg', + version: 'v2', + title: 'Test title', + caption: 'Test caption', + })); + }); + + test('POST / - 400 Bad Request duplicate version', async () => { + const res = await request + .post(`/api/reports/${report.ident}/legend`) + .attach('v1', 'test/testData/images/golden.jpg') + .attach('v1 ', 'test/testData/images/golden.jpg') + .auth(username, password) + .expect(HTTP_STATUS.BAD_REQUEST); + + // Check duplicate version error + expect(res.body.error).toEqual(expect.objectContaining({ + message: 'Duplicate versions are not allowed. Duplicate version: v1', + })); + }); + + test('POST / - 400 Bad Request no files', async () => { + const res = await request + .post(`/api/reports/${report.ident}/legend`) + .auth(username, password) + .expect(HTTP_STATUS.BAD_REQUEST); + + expect(res.body.error).toEqual(expect.objectContaining({ + message: 'No attached images to upload', + })); + }); + }); + + describe('DELETE', () => { + let legend; + + beforeEach(async () => { + // Create legend + legend = await db.models.legend.create(fakeLegendData); + }); + + test('/{legend} - 204 No Content - Soft delete', async () => { + await request + .delete(`/api/reports/${report.ident}/legend/${legend.ident}`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + // Check that legend was soft deleted + const deletedLegend = await db.models.legend.findOne({ + where: {id: legend.id}, + paranoid: false, + }); + + // Expect record to still exist, but deletedAt now has a date + expect(deletedLegend).toEqual(expect.objectContaining(fakeLegendData)); + expect(deletedLegend.deletedAt).not.toBeNull(); + }); + + test('/{legend} - 204 No Content - Hard delete', async () => { + await request + .delete(`/api/reports/${report.ident}/legend/${legend.ident}?force=true`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + // Check that legend was hard deleted + const deletedLegend = await db.models.legend.findOne({ + where: {id: legend.id}, + paranoid: false, + }); + + // Expect nothing to be returned + expect(deletedLegend).toBeNull(); + }); + }); + + afterAll(async () => { + // Delete newly created report and all of it's components + // indirectly by force deleting the report + return db.models.report.destroy({where: {ident: report.ident}, force: true}); + }); +}); + +afterAll(async () => { + global.gc && global.gc(); + await server.close(); +}); diff --git a/test/routes/report/summary/pathwayAnalysis.test.js b/test/routes/report/summary/pathwayAnalysis.test.js index 837a1c69d..ecefa20d6 100644 --- a/test/routes/report/summary/pathwayAnalysis.test.js +++ b/test/routes/report/summary/pathwayAnalysis.test.js @@ -12,7 +12,7 @@ const {username, password} = CONFIG.get('testing'); let server; let request; -const pathwayProperties = ['ident', 'createdAt', 'updatedAt', 'pathway', 'legend']; +const pathwayProperties = ['ident', 'createdAt', 'updatedAt', 'pathway', 'legendId']; const checkPathwayAnalysis = (pathwayObject) => { pathwayProperties.forEach((element) => { @@ -89,21 +89,21 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'v2') + .field('legendId', 2) .expect(HTTP_STATUS.OK); checkPathwayAnalysis(res.body); expect(res.body.pathway).not.toBeNull(); - expect(res.body.legend).toBe('v2'); + expect(res.body.legendId).toBe(2); }); - test('/ - 400 Bad request - Invalid legend', async () => { + test('/ - 400 Bad request - Invalid legend fk', async () => { await request .put(`/api/reports/${report.ident}/summary/pathway-analysis`) .auth(username, password) .type('json') - .send({legend: 'Not valid legend'}) + .send({legendId: 'Not valid legend id'}) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -113,7 +113,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/golden.jpg') - .field('legend', 'v1') + .field('legendId', 1) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -173,25 +173,25 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'custom') + .field('legendId', 2) .expect(HTTP_STATUS.CREATED); checkPathwayAnalysis(res.body); expect(res.body.pathway).not.toBeNull(); - expect(res.body.legend).toBe('custom'); + expect(res.body.legendId).toBe(2); // Remove pathway analysis await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); }); - test('/ - 400 Bad request - Invalid legend', async () => { + test('/ - 400 Bad request - Invalid legend id', async () => { await request .post(`/api/reports/${report.ident}/summary/pathway-analysis`) .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'Not valid legend') + .field('legendId', 'Not valid legend id') .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -201,7 +201,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/golden.jpg') - .field('legend', 'v1') + .field('legendId', 1) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -216,7 +216,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'v2') + .field('legendId', 2) .expect(HTTP_STATUS.CONFLICT); // Remove pathway analysis diff --git a/test/testData/mockReportData.json b/test/testData/mockReportData.json index 69a237c0b..750c83fc3 100644 --- a/test/testData/mockReportData.json +++ b/test/testData/mockReportData.json @@ -124,7 +124,7 @@ ], "pathwayAnalysis": { "pathway": "\nimage/svg+xmlGENOMIC CASE NOTES\nPATHWAY VISUALIZATION\nPancreas Cancer \nTissue Comparator: average\nDisease Comparator: PAAD\n\n none\n NOTCH1\n\n Node NOTCH1\n 99\npercentile=99;foldchange=2.35;copychange=0HES4\n\n Node HES4\n 100\npercentile=100;foldchange=25.21;copychange=-1+4\nNOTCH2\n\n Node NOTCH2\n 100\npercentile=100;foldchange=2.84;copychange=4NOTCH\nGUCA2A\n\n Node GUCA2A\n 99\npercentile=99;foldchange=30.23;copychange=-1SMAD2\n6\nSMAD4\nPDAC related genes\nRRM2\n\n Node RRM2\n percentile=18;foldchange=2.63;copychange=0RRM1\n\n Node RRM1\n percentile=54;foldchange=1.02;copychange=0CD274\n\n Node CD274\n percentile=46;foldchange=-3.03;copychange=0CTLA4\n\n Node CTLA4\n percentile=20;foldchange=-1.48;copychange=0PDCD1\n\n Node PDCD1\n percentile=27;foldchange=-1.18;copychange=0Immune Therapy\n\n none\n \n none\n MTOR\n\n Node MTOR\n percentile=36;foldchange=-1.47;copychange=-1PTEN\n\n Node PTEN\n 2\npercentile=2;foldchange=-2.1;copychange=0PIK3CA\n\n Node PIK3CA\n percentile=17;foldchange=-2.03;copychange=0PI3K/MTOR\nTP53\n\n Node TP53\n 92\npercentile=92;foldchange=1.47;copychange=0FANCD2\n\n Node FANCD2\n mutations=p.E803K;percentile=67;foldchange=1.33;copychange=0RAD50\n\n Node RAD50\n percentile=53;foldchange=-1.31;copychange=-1FAM175A\n\n Node FAM175A\n percentile=17;foldchange=-2.02;copychange=0BRCA1\n\n Node BRCA1\n 95\npercentile=95;foldchange=1.28;copychange=0BRCA2\n\n Node BRCA2\n percentile=89;foldchange=1.16;copychange=0Homologous recombination\nERBB4\n\n Node ERBB4\n percentile=31;foldchange=-2.6;copychange=0IGF2\n\n Node IGF2\n 97\npercentile=97;foldchange=1.56;copychange=0+2\nERBB2\n\n Node ERBB2\n percentile=45;foldchange=6.08;copychange=2EGFR\n\n Node EGFR\n percentile=90;foldchange=-1.06;copychange=0FGFR2\n\n Node FGFR2\n 100\npercentile=100;foldchange=3.61;copychange=0FGFR4\n\n Node FGFR4\n 98\npercentile=98;foldchange=13.39;copychange=-1FGFR3\n\n Node FGFR3\n 99\npercentile=99;foldchange=14.12;copychange=0ERBB3\n\n Node ERBB3\n 93\npercentile=93;foldchange=9.73;copychange=0RTKs\nNRG1\n\n Node NRG1\n 100\nATP1B1\nstructural-variants=translocation(ATP1B1,NRG1) and translocation(NRG1,ATP1B1);percentile=100;foldchange=39.25;copychange=-1\n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_MAP kinase signalling pathways in cancer_17496922\n MAP2K2\n\n Node MAP2K2\n percentile=73;foldchange=1.8;copychange=0BRAF\n\n Node BRAF\n 97\npercentile=97;foldchange=-1.25;copychange=0KRAS\n\n Node KRAS\n 6\npercentile=6;foldchange=-2.05;copychange=0MAP2K1\n\n Node MAP2K1\n 3\npercentile=3;foldchange=-1.35;copychange=1MAPK\nCEBPA\n\n Node CEBPA\n 92\npercentile=92;foldchange=-1.05;copychange=0JUN\n\n Node JUN\n hom\nmutations=p.S37fs;percentile=65;foldchange=1.18;copychange=-1", - "legend": "v2" + "legend": 1 }, "probeResults": [ { @@ -942,5 +942,14 @@ "key": "expression.spearman.brca.receptor", "path": "test/testData/images/spearman_brca_receptor.png" } + ], + "legends": [ + { + "id": 1, + "version": "v1", + "path": "test/testData/images/msig_snvs_all_strelka.png", + "title": "Test Pathway Analysis Legend", + "caption": "Test Pathway Analysis Legend" + } ] } From d345a2ff0946d963ed335154cfb102dbcb75cc0a Mon Sep 17 00:00:00 2001 From: sshugsc Date: Wed, 13 May 2026 15:08:45 -0700 Subject: [PATCH 02/25] add exon to small mutations model --- app/models/reports/smallMutations.js | 3 +++ ...DEVSU-2914-add-exon-column-to-small-mutations.js | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 migrations/latest/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js diff --git a/app/models/reports/smallMutations.js b/app/models/reports/smallMutations.js index 7f913c777..a97b4dfbd 100644 --- a/app/models/reports/smallMutations.js +++ b/app/models/reports/smallMutations.js @@ -134,6 +134,9 @@ module.exports = (sequelize, Sq) => { field: 'tumour_ref_copies', type: Sq.INTEGER, }, + exon: { + type: Sq.TEXT, + }, library: { type: Sq.TEXT, }, diff --git a/migrations/latest/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js b/migrations/latest/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js new file mode 100644 index 000000000..97fb0dc28 --- /dev/null +++ b/migrations/latest/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js @@ -0,0 +1,13 @@ +const TABLE = 'reports_small_mutations'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(TABLE, 'exon', { + type: Sequelize.TEXT, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(TABLE, 'exon'); + }, +}; From da18c1505e7bd7936f594beb8244d3c6f7dae3dd Mon Sep 17 00:00:00 2001 From: kttkjl Date: Tue, 19 May 2026 11:33:59 -0700 Subject: [PATCH 03/25] bugfix: add hook to therapueticTargets on update to remove signatures DEVSU-2953 --- app/routes/report/therapeuticTargets.js | 26 ++++++- test/routes/report/therapeuticTargets.test.js | 70 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/app/routes/report/therapeuticTargets.js b/app/routes/report/therapeuticTargets.js index 9c3829446..80ad07498 100644 --- a/app/routes/report/therapeuticTargets.js +++ b/app/routes/report/therapeuticTargets.js @@ -142,7 +142,7 @@ router.route('/') const {report: {id: reportId}, body} = req; try { await db.transaction(async (transaction) => { - return Promise.all(body.map((target) => { + await Promise.all(body.map((target) => { return db.models.therapeuticTarget.update( {rank: target.rank}, { @@ -155,6 +155,30 @@ router.route('/') }, ); })); + + await db.models.signatures.update({ + authorId: null, + authorSignedAt: null, + reviewerId: null, + reviewerSignedAt: null, + }, { + where: {reportId}, + individualHooks: true, + paranoid: true, + transaction, + userId: req.user.id, + }); + + // If the report was "reviewed" send it back to "ready" + await db.models.report.update({ + state: 'ready', + }, { + where: {id: reportId, state: 'reviewed'}, + individualHooks: true, + paranoid: true, + transaction, + userId: req.user.id, + }); }); return res.json({updated: true}); diff --git a/test/routes/report/therapeuticTargets.test.js b/test/routes/report/therapeuticTargets.test.js index b0187a024..ce5038f48 100644 --- a/test/routes/report/therapeuticTargets.test.js +++ b/test/routes/report/therapeuticTargets.test.js @@ -390,6 +390,76 @@ describe('/therapeutic-targets', () => { .type('json') .expect(HTTP_STATUS.BAD_REQUEST); }, LONGER_TIMEOUT); + + describe('rank update and report signatures', () => { + let signature; + let user; + let originalState; + + beforeEach(async () => { + user = await db.models.user.findOne({where: {username}}); + signature = await db.models.signatures.create({ + reportId: report.id, + authorId: user.id, + authorSignedAt: new Date(), + reviewerId: user.id, + reviewerSignedAt: new Date(), + creatorId: user.id, + creatorSignedAt: new Date(), + }); + // state is excluded from signature removal, so setting it + // here does not wipe the signature we just created + ({state: originalState} = await db.models.report.findOne({ + where: {id: report.id}, + })); + await db.models.report.update( + {state: 'reviewed'}, + {where: {id: report.id}, hooks: false}, + ); + }); + + afterEach(async () => { + if (signature) { + await db.models.signatures.destroy({ + where: {ident: signature.ident}, + force: true, + }); + } + // restore the original state - report.state is allowNull: false + await db.models.report.update( + {state: originalState}, + {where: {id: report.id}, hooks: false}, + ); + }); + + test('reorder revokes author + reviewer signatures, keeps creator', async () => { + await request + .put(`/api/reports/${report.ident}/therapeutic-targets`) + .auth(username, password) + .send([ + {ident: originalGene.ident, rank: newTarget.rank}, + {ident: newTarget.ident, rank: originalGene.rank}, + ]) + .type('json') + .expect(HTTP_STATUS.OK); + + const updatedSignature = await db.models.signatures.findOne({ + where: {reportId: report.id}, + }); + expect(updatedSignature.authorId).toBe(null); + expect(updatedSignature.authorSignedAt).toBe(null); + expect(updatedSignature.reviewerId).toBe(null); + expect(updatedSignature.reviewerSignedAt).toBe(null); + // creator signature is intentionally preserved + expect(updatedSignature.creatorId).toBe(user.id); + expect(updatedSignature.creatorSignedAt).not.toBe(null); + + const updatedReport = await db.models.report.findOne({ + where: {id: report.id}, + }); + expect(updatedReport.state).toBe('ready'); + }, LONGER_TIMEOUT); + }); }); test.todo('Bad request on update and set gene to null'); From e076d40ee4bcf51d6664b2d8f05bf95fdc400052 Mon Sep 17 00:00:00 2001 From: sshugsc Date: Tue, 19 May 2026 17:35:56 -0700 Subject: [PATCH 04/25] add migration to move seqqc data from reports to reports_seq_qc --- ...20260519215459-DEVSU-2928-datafix-seqqc.js | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js diff --git a/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js b/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js new file mode 100644 index 000000000..c2c4bad34 --- /dev/null +++ b/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js @@ -0,0 +1,92 @@ +const {v4: uuidv4} = require('uuid'); + +module.exports = { + up: async (queryInterface, Sq) => { + await queryInterface.sequelize.transaction(async (transaction) => { + const reports = await queryInterface.sequelize.query( + ` + SELECT id, seq_qc + FROM reports + WHERE seq_qc IS NOT NULL; + `, + { + type: queryInterface.sequelize.QueryTypes.SELECT, + transaction, + }, + ); + + const now = new Date(); + const rows = []; + + for (const report of reports) { + for (const seqQCItem of report.seq_qc) { + rows.push({ + ident: uuidv4(), + created_at: now, + updated_at: now, + report_id: report.id, + reads: seqQCItem.Reads, + bio_qc: seqQCItem.bioQC, + lab_qc: seqQCItem.labQC, + sample: seqQCItem.Sample, + library: seqQCItem.Library, + coverage: seqQCItem.Coverage, + input_ng: seqQCItem.Input_ng, + input_ug: seqQCItem.Input_ug, + protocol: seqQCItem.Protocol, + sample_name: seqQCItem['Sample Name'], + duplicate_reads_perc: seqQCItem.Duplicate_Reads_Perc, + }); + } + } + + if (rows.length !== 0) { + await queryInterface.bulkInsert('reports_seqqc', rows, {transaction}); + } + + await queryInterface.removeColumn('reports', 'seq_qc', {transaction}); + }); + }, + + down: async (queryInterface, Sq) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('reports', 'seq_qc', { + type: Sq.JSONB, + defaultValue: null, + }, {transaction}); + + await queryInterface.sequelize.query(` + WITH restored_seq_qc AS ( + SELECT + report_id, + jsonb_agg(jsonb_build_object( + 'Reads', reads, + 'bioQC', bio_qc, + 'labQC', lab_qc, + 'Sample', sample, + 'Library', library, + 'Coverage', coverage, + 'Input_ng', input_ng, + 'Input_ug', input_ug, + 'Protocol', protocol, + 'Sample Name', sample_name, + 'Duplicate_Reads_Perc', duplicate_reads_perc + )) AS seq_qc + FROM reports_seqqc + GROUP BY report_id + ) + UPDATE reports r + SET seq_qc = restored_seq_qc.seq_qc + FROM restored_seq_qc + WHERE r.id = restored_seq_qc.report_id; + `, {transaction}); + + await queryInterface.sequelize.query(` + DELETE FROM reports_seqqc rs + USING reports r + WHERE rs.report_id = r.id + AND r.seq_qc IS NOT NULL; + `, {transaction}); + }); + }, +}; From 066c85d03a888f57cb534bde3a8eeaeac1adb375 Mon Sep 17 00:00:00 2001 From: sshugsc Date: Tue, 19 May 2026 17:49:42 -0700 Subject: [PATCH 05/25] remove legacy seq_qc column in report model --- app/models/reports/report.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/models/reports/report.js b/app/models/reports/report.js index 56521a35c..a1211c119 100644 --- a/app/models/reports/report.js +++ b/app/models/reports/report.js @@ -76,20 +76,6 @@ module.exports = (sequelize, Sq) => { }, allowNull: false, }, - seqQc: { - name: 'seqQc', - field: 'seq_qc', - type: Sq.JSONB, - jsonSchema: { - schema: { - type: 'array', - items: { - type: 'object', - }, - example: [{Reads: '2534M', bioQC: 'passed'}], - }, - }, - }, config: { type: Sq.TEXT, }, From 53ce3ef48eebbfb5fa62129ab9810360294e8da2 Mon Sep 17 00:00:00 2001 From: sshugsc Date: Tue, 19 May 2026 17:52:21 -0700 Subject: [PATCH 06/25] lint --- migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js b/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js index c2c4bad34..e4a73d317 100644 --- a/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js +++ b/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js @@ -1,7 +1,7 @@ const {v4: uuidv4} = require('uuid'); module.exports = { - up: async (queryInterface, Sq) => { + up: async (queryInterface) => { await queryInterface.sequelize.transaction(async (transaction) => { const reports = await queryInterface.sequelize.query( ` From e0d164236e7fd01a1482f179d453fa3fc26f1b8e Mon Sep 17 00:00:00 2001 From: sshugsc Date: Tue, 19 May 2026 18:02:28 -0700 Subject: [PATCH 07/25] remove legacy seqQc --- app/routes/report/appendices.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/report/appendices.js b/app/routes/report/appendices.js index c554602ba..b77dcaa1a 100644 --- a/app/routes/report/appendices.js +++ b/app/routes/report/appendices.js @@ -13,7 +13,7 @@ router.route('/') try { const result = await db.models.report.findOne({ where: {ident: req.report.ident}, - attributes: ['seqQc', 'config'], + attributes: ['config'], include: ['seqQC'], }); return res.json(result); From 4438b7bffadbcc20dc9d6842234ceb20374ffd35 Mon Sep 17 00:00:00 2001 From: sshugsc Date: Thu, 21 May 2026 11:56:51 -0700 Subject: [PATCH 08/25] add updated_by to user and metadata table --- app/middleware/acl.js | 7 +++++-- app/routes/user/settings.js | 7 +++++-- app/routes/user/user.js | 10 ++++++++-- test/routes/user/settings.test.js | 1 + 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/middleware/acl.js b/app/middleware/acl.js index dcd931259..5153ddb42 100644 --- a/app/middleware/acl.js +++ b/app/middleware/acl.js @@ -117,13 +117,16 @@ module.exports = async (req, res, next) => { try { // Update last time the user logged in, limit to once a day const currentDate = new Date().toDateString(); - let userMetadata = await db.models.userMetadata.findOrCreate({where: {userId: req.user.id}}); + let userMetadata = await db.models.userMetadata.findOrCreate({ + where: {userId: req.user.id}, + defaults: {updatedBy: req.user.id}, + }); userMetadata = userMetadata[0]; const userLastLogin = userMetadata.lastLoginAt ? new Date(userMetadata.lastLoginAt).toDateString() : ''; if (userLastLogin !== currentDate) { - await userMetadata.update({lastLoginAt: new Date()}); + await userMetadata.update({lastLoginAt: new Date(), updatedBy: req.user.id}); } try { diff --git a/app/routes/user/settings.js b/app/routes/user/settings.js index 336f9b70c..06105cd67 100644 --- a/app/routes/user/settings.js +++ b/app/routes/user/settings.js @@ -21,11 +21,14 @@ router.route('/') }) .put(async (req, res) => { try { - await db.models.userMetadata.update({settings: req.body}, { + await db.models.userMetadata.update({ + settings: req.body, + updatedBy: req.user.id, + }, { where: { user_id: req.user.id, }, - fields: ['settings'], + fields: ['settings', 'updatedBy'], hooks: false, limit: 1, }); diff --git a/app/routes/user/user.js b/app/routes/user/user.js index e2cf8e2cd..f5e0e9ba0 100644 --- a/app/routes/user/user.js +++ b/app/routes/user/user.js @@ -260,9 +260,15 @@ router.route('/') // Create transaction transaction = await db.transaction(); // Create user - const createdUser = await db.models.user.create(req.body, {transaction}); + const createdUser = await db.models.user.create({ + ...req.body, + updatedBy: req.user.id, + }, {transaction}); // Create user metadata - await db.models.userMetadata.create({userId: createdUser.id}, {transaction}); + await db.models.userMetadata.create({ + userId: createdUser.id, + updatedBy: req.user.id, + }, {transaction}); // Commit changes await transaction.commit(); // Return new user diff --git a/test/routes/user/settings.test.js b/test/routes/user/settings.test.js index 18c434955..62a327d85 100644 --- a/test/routes/user/settings.test.js +++ b/test/routes/user/settings.test.js @@ -68,6 +68,7 @@ describe('/user/settings', () => { where: {userId: testUser.id}, }); expect(results.length).toBe(1); + expect(results[0].updatedBy).toBe(testUser.id); }); }); }); From 045615729d32303e0f07f388c34655879b0ec931 Mon Sep 17 00:00:00 2001 From: sshugsc Date: Thu, 21 May 2026 14:18:01 -0700 Subject: [PATCH 09/25] use userId in userMetadata update variable; add test --- app/middleware/acl.js | 2 +- test/routes/user/user.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/middleware/acl.js b/app/middleware/acl.js index 5153ddb42..2f5f556d7 100644 --- a/app/middleware/acl.js +++ b/app/middleware/acl.js @@ -126,7 +126,7 @@ module.exports = async (req, res, next) => { ? new Date(userMetadata.lastLoginAt).toDateString() : ''; if (userLastLogin !== currentDate) { - await userMetadata.update({lastLoginAt: new Date(), updatedBy: req.user.id}); + await userMetadata.update({lastLoginAt: new Date()}, {userId: req.user.id}); } try { diff --git a/test/routes/user/user.test.js b/test/routes/user/user.test.js index 4d2d6ba04..f62e2d029 100644 --- a/test/routes/user/user.test.js +++ b/test/routes/user/user.test.js @@ -46,6 +46,7 @@ beforeAll(async () => { // Tests for user related endpoints describe('/user', () => { let testUser; + let managerUser; let adminGroup; beforeAll(async () => { @@ -53,6 +54,9 @@ describe('/user', () => { testUser = await db.models.user.findOne({ where: {username}, }); + managerUser = await db.models.user.findOne({ + where: {username: managerUsername}, + }); adminGroup = await db.models.userGroup.findOne({ where: {name: 'admin'}, }); @@ -162,6 +166,16 @@ describe('/user', () => { }) .expect(HTTP_STATUS.CREATED); + const createdUser = await db.models.user.findOne({ + where: {ident: res.body.ident}, + }); + const createdMetadata = await db.models.userMetadata.findOne({ + where: {userId: createdUser.id}, + }); + + expect(createdUser.updatedBy).toBe(testUser.id); + expect(createdMetadata.updatedBy).toBe(testUser.id); + // Remove test user from db await db.models.user.destroy({where: {ident: res.body.ident}, force: true}); }); @@ -180,6 +194,16 @@ describe('/user', () => { }) .expect(HTTP_STATUS.CREATED); + const createdUser = await db.models.user.findOne({ + where: {ident: res.body.ident}, + }); + const createdMetadata = await db.models.userMetadata.findOne({ + where: {userId: createdUser.id}, + }); + + expect(createdUser.updatedBy).toBe(managerUser.id); + expect(createdMetadata.updatedBy).toBe(managerUser.id); + // Remove test user from db await db.models.user.destroy({where: {ident: res.body.ident}, force: true}); }); From 176cbc312874dab840046812d3b6dc139e693441 Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Wed, 17 Jun 2026 14:08:04 -0700 Subject: [PATCH 10/25] - Update legend model and file structure to be standalone table not belonging to a report - Legend records include default value that is enforced by unique index and util functions to ensure only 1 record can have True value - Update uploadLegendImage middleware function - Update legend router and routes - Update legend unit tests - Update model associations --- app/models/index.js | 9 +- app/models/legend/legend.js | 107 +++++++ app/models/reports/legend.js | 80 ------ app/routes/index.js | 4 + app/routes/legend/index.js | 104 +++++++ app/routes/report/images.js | 18 +- app/routes/report/legend/index.js | 148 ---------- ...01-add-unique-default-legend-constraint.js | 19 ++ ...21000000-DEVSU-2310-create-legend-table.js | 25 +- ...-2310-update-pathway-analysis-legend-fk.js | 14 +- test/routes/legend/legend.test.js | 239 ++++++++++++++++ test/routes/report/legend.test.js | 265 ------------------ 12 files changed, 494 insertions(+), 538 deletions(-) create mode 100644 app/models/legend/legend.js delete mode 100644 app/models/reports/legend.js create mode 100644 app/routes/legend/index.js delete mode 100644 app/routes/report/legend/index.js create mode 100644 migrations/latest/20260616000001-add-unique-default-legend-constraint.js create mode 100644 test/routes/legend/legend.test.js delete mode 100644 test/routes/report/legend.test.js diff --git a/app/models/index.js b/app/models/index.js index c9cc32cb1..40299b56b 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -134,14 +134,7 @@ summary.therapeuticTargets = require('./reports/genomic/summary/therapeuticTarge summary.microbial = require('./reports/genomic/summary/microbial')(sequelize, Sq); // Pathway Analysis Legends -const pathwayAnalysisLegends = require('./reports/legend')(sequelize, Sq); - -pathwayAnalysisLegends.belongsTo(analysisReports, { - as: 'report', foreignKey: 'reportId', targetKey: 'id', onDelete: 'CASCADE', constraints: true, -}); -analysisReports.hasMany(pathwayAnalysisLegends, { - as: 'legends', foreignKey: 'reportId', onDelete: 'CASCADE', constraints: true, -}); +const pathwayAnalysisLegends = require('./legend/legend')(sequelize, Sq); summary.pathwayAnalysis.belongsTo(pathwayAnalysisLegends, { as: 'legend', foreignKey: 'legendId', targetKey: 'id', onDelete: 'SET NULL', constraints: true, diff --git a/app/models/legend/legend.js b/app/models/legend/legend.js new file mode 100644 index 000000000..f43bc4707 --- /dev/null +++ b/app/models/legend/legend.js @@ -0,0 +1,107 @@ +const { DEFAULT_COLUMNS } = require('../base'); + +module.exports = (sequelize, Sq) => { + const legend = sequelize.define( + 'legend', + { + ...DEFAULT_COLUMNS, + format: { + type: Sq.ENUM('PNG', 'JPG'), + defaultValue: 'PNG', + }, + filename: { + type: Sq.TEXT, + allowNull: false, + }, + name: { + type: Sq.TEXT, + allowNull: false, + }, + data: { + type: Sq.TEXT, + allowNull: false, + }, + default: { + type: Sq.BOOLEAN, + defaultValue: false, + }, + }, + { + tableName: 'pathway_analysis_legends', + indexes: [ + { + unique: true, + fields: [], + where: { + default: true, + deleted_at: null, + }, + name: 'idx_one_default_legend', + }, + ], + scopes: { + public: { + attributes: { + exclude: ['id', 'deletedAt', 'updatedBy'], + }, + }, + }, + hooks: { + beforeCreate: async (instance, options) => { + // If setting to true, unset all others + if (instance.default === true) { + await sequelize.models.legend.update( + {default: false}, + { + where: {id: {[sequelize.Sequelize.Op.ne]: instance.id}}, + transaction: options.transaction, + }, + ); + } + }, + beforeUpdate: async (instance, options) => { + // If setting to true, unset all others + if (instance.changed('default') && instance.default === true) { + await sequelize.models.legend.update( + {default: false}, + { + where: {id: {[sequelize.Sequelize.Op.ne]: instance.id}}, + transaction: options.transaction, + }, + ); + } + }, + }, + }, + ); + + // set instance methods + legend.prototype.view = function (scope) { + if (scope === 'public') { + const { + id, deletedAt, updatedBy, ...publicView + } = this.dataValues; + return publicView; + } + return this; + }; + + // Ensure at least one default exists + legend.prototype.ensureDefaultExists = async function () { + const hasDefault = await sequelize.models.legend.findOne({ + where: {default: true}, + }); + + if (!hasDefault) { + const mostRecent = await sequelize.models.legend.findOne({ + order: [['createdAt', 'DESC']], + }); + + if (mostRecent) { + await mostRecent.update({default: true}); + } + } + }; + + return legend; +}; diff --git a/app/models/reports/legend.js b/app/models/reports/legend.js deleted file mode 100644 index 2ed6035fa..000000000 --- a/app/models/reports/legend.js +++ /dev/null @@ -1,80 +0,0 @@ -const {DEFAULT_COLUMNS, DEFAULT_REPORT_OPTIONS} = require('../base'); - -module.exports = (sequelize, Sq) => { - const legend = sequelize.define( - 'legend', - { - ...DEFAULT_COLUMNS, - reportId: { - name: 'reportId', - field: 'report_id', - type: Sq.INTEGER, - references: { - model: 'reports', - key: 'id', - }, - }, - format: { - type: Sq.ENUM('PNG', 'JPG'), - defaultValue: 'PNG', - }, - filename: { - type: Sq.TEXT, - allowNull: false, - }, - version: { - type: Sq.TEXT, - allowNull: false, - }, - data: { - type: Sq.TEXT, - allowNull: false, - }, - title: { - type: Sq.TEXT, - }, - caption: { - type: Sq.TEXT, - }, - height: { - type: Sq.INTEGER, - }, - width: { - type: Sq.INTEGER, - }, - }, - { - ...DEFAULT_REPORT_OPTIONS, - tableName: 'pathway_analysis_legends', - scopes: { - public: { - attributes: { - exclude: ['id', 'reportId', 'deletedAt', 'updatedBy'], - }, - }, - versionlist: { - attributes: { - exclude: ['id', 'deletedAt', 'updatedBy', 'data'], - }, - }, - }, - }, - ); - - // set instance methods - legend.prototype.view = function (scope) { - if (scope === 'public') { - const { - id, reportId, deletedAt, updatedBy, ...publicView - } = this.dataValues; - return publicView; - } - if (scope === 'versionlist') { - const {id, deletedAt, updatedBy, exclue, ...versionlistView} = this.dataValues; - return versionlistView; - } - return this; - }; - - return legend; -}; diff --git a/app/routes/index.js b/app/routes/index.js index 8faafb9f9..c1176134f 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -19,6 +19,7 @@ const notificationRoute = require('./notification'); const variantTextRoute = require('./variantText'); const templateRoute = require('./template'); const appendixRoute = require('./appendix'); +const legendRoute = require('./legend'); // Get module route files const RouterInterface = require('./routingInterface'); @@ -101,6 +102,9 @@ class Routing extends RouterInterface { // Get appendix routes this.router.use('/appendix', appendixRoute); + // Global legend routes + this.router.use('/legend', legendRoute); + return true; } } diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js new file mode 100644 index 000000000..eff868530 --- /dev/null +++ b/app/routes/legend/index.js @@ -0,0 +1,104 @@ +const HTTP_STATUS = require('http-status-codes'); +const express = require('express'); + +const db = require('../../models'); +const logger = require('../../log'); +const {uploadLegendImage} = require('../report/images'); + +const router = express.Router({mergeParams: true}); + +// Middleware for legend lookup +router.param('legend', async (req, res, next, legendIdent) => { + let result; + try { + result = await db.models.legend.findOne({ + where: {ident: legendIdent}, + }); + } catch (error) { + logger.error(`Unable to lookup legend error: ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to lookup legend'}}); + } + + if (!result) { + logger.error(`Unable to find legend ${legendIdent}`); + return res.status(HTTP_STATUS.NOT_FOUND).json({error: {message: 'Unable to find the requested legend'}}); + } + + req.legend = result; + return next(); +}); + +router.route('/:legend([A-z0-9-]{36})') + .get((req, res) => { + return res.json(req.legend.view('public')); + }) + .put(async (req, res) => { + try { + await req.legend.update(req.body, {userId: req.user.id}); + await req.legend.ensureDefaultExists(); + return res.json(req.legend.view('public')); + } catch (error) { + logger.error(`Error while updating legend image ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while updating legend image'}}); + } + }) + .delete(async (req, res) => { + // Whether to hard or soft delete legend + const force = (req.query.force === 'true'); + + // Delete legend image + try { + await req.legend.destroy({force}); + await req.legend.ensureDefaultExists(); + return res.status(HTTP_STATUS.NO_CONTENT).send(); + } catch (error) { + logger.error(`Error while deleting legend image ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while deleting legend image'}}); + } + }); + +// Route for adding a legend image +router.route('/') + .get(async (req, res) => { + try { + const legends = await db.models.legend.findAll(); + return res.json(legends.map((legend) => legend.view('public'))); + } catch (error) { + logger.error(`Error while retrieving legend images ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while retrieving legend images'}}); + } + }) + .post(async (req, res) => { + // Check that image files were uploaded + if (!req.files || Object.keys(req.files).length === 0) { + logger.error('No attached images to upload'); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); + } + + try { + const results = await Promise.all(Object.entries(req.files).map(async ([key, image]) => { + try { + // Set options (value or undefined) + const options = { + filename: image.name.trim(), + name: req.body.name || image.name.trim(), + default: req.body.default === undefined ? false : req.body.default, + }; + + // Load image + const createdLegend = await uploadLegendImage(image.data, options); + await createdLegend.ensureDefaultExists(); + // Return that this image was uploaded successfully + return {name: key, upload: 'successful'}; + } catch (error) { + return {name: key, upload: 'failed', error}; + } + })); + return res.status(HTTP_STATUS.MULTI_STATUS).json(results); + } catch (error) { + logger.error(`Error while uploading images ${error}`); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error}`}}); + } + }); + +module.exports = router; diff --git a/app/routes/report/images.js b/app/routes/report/images.js index 38b56edc9..609f39e07 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -56,16 +56,15 @@ const uploadReportImage = async (reportId, key, image, options = {}) => { * @param {object} options - An object containing additional image upload options * * @property {string} options.filename - An optional filename for the image - * @property {string} options.caption - An optional caption for the image - * @property {string} options.title - An optional title for the image - * @property {string} options.category - An optional category for the image + * @property {string} options.name - An optional name for the legend + * @property {boolean|string} options.default - Whether this legend is the default * @property {object} options.transaction - An optional transaction to run the create under * * @returns {Promise} - Returns the created legend db entry * @throws {Promise} - Something goes wrong with image processing and saving entry */ -const uploadLegendImage = async (reportId, version, image, options = {}) => { - logger.verbose(`Loading legend (${version}) image`); +const uploadLegendImage = async (image, options = {}) => { + logger.verbose('Loading legend image'); const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT}; @@ -73,16 +72,11 @@ const uploadLegendImage = async (reportId, version, image, options = {}) => { const imageData = await processImage(image, config.size, config.format); return db.models.legend.create({ - reportId, format: config.format, filename: options.filename, - version, + name: options.name || options.filename, data: imageData, - caption: options.caption, - title: options.title, - width: config.width, - height: config.height, - category: options.category, + default: options.default === true || options.default === 'true', }, {transaction: options.transaction}); } catch (error) { logger.error(`Error processing legend image ${options.filename} ${error}`); diff --git a/app/routes/report/legend/index.js b/app/routes/report/legend/index.js deleted file mode 100644 index 079c62785..000000000 --- a/app/routes/report/legend/index.js +++ /dev/null @@ -1,148 +0,0 @@ -const HTTP_STATUS = require('http-status-codes'); -const express = require('express'); - -const db = require('../../../models'); -const logger = require('../../../log'); -const {uploadLegendImage} = require('../images'); - -const router = express.Router({mergeParams: true}); - -// Middleware for legend image -router.param('legend', async (req, res, next, imgIdent) => { - let result; - try { - result = await db.models.legend.findOne({ - where: {ident: imgIdent, reportId: req.report.id}, - }); - } catch (error) { - logger.error(`Unable to lookup legend image error: ${error}`); - return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to lookup legend image'}}); - } - - if (!result) { - const message = `Unable to find legend image ${imgIdent} for report ${req.report.ident}`; - logger.error(message); - return res.status(HTTP_STATUS.NOT_FOUND).json({error: {message}}); - } - - // Add legend data to request - req.legend = result; - return next(); -}); - -// Routes for operating on specific legends -// !!Should not add update routes for legend images (PUT, PATCH, etc.) -// because updates will create duplicate image entries!! -router.route('/:legend([A-z0-9-]{36})') - .get((req, res) => { - return res.json(req.legend.view('public')); - }) - .delete(async (req, res) => { - // Whether to hard or soft delete legend - const force = (typeof req.query.force === 'boolean') ? req.query.force : false; - - // Delete legend image - try { - await req.legend.destroy({force}); - return res.status(HTTP_STATUS.NO_CONTENT).send(); - } catch (error) { - logger.error(`Error while deleting legend image ${error}`); - return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while deleting legend image'}}); - } - }); - -// Route for adding a legend image -router.route('/') - .post(async (req, res) => { - // Check that image files were uploaded - if (!req.files || Object.keys(req.files).length === 0) { - logger.error('No attached images to upload'); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); - } - - const versions = []; - - for (let [version, value] of Object.entries(req.files)) { - version = version.trim(); - - // Check if version is a duplicate - if (versions.includes(version) || Array.isArray(value)) { - logger.error(`Duplicate versions are not allowed. Duplicate version: ${version}`); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Duplicate versions are not allowed. Duplicate version: ${version}`}}); - } - - versions.push(version); - } - - try { - const results = await Promise.all(Object.entries(req.files).map(async ([version, image]) => { - // Remove trailing space from version - version = version.trim(); - - try { - // Set options (value or undefined) - const options = { - filename: image.name.trim(), - title: req.body[`${version}_title`], - caption: req.body[`${version}_caption`], - }; - - // Load image - await uploadLegendImage(req.report.id, version, image.data, options); - // Return that this image was uploaded successfully - return {version, upload: 'successful'}; - } catch (error) { - return {version, upload: 'failed', error}; - } - })); - return res.status(HTTP_STATUS.MULTI_STATUS).json(results); - } catch (error) { - logger.error(`Error while uploading images ${error}`); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error}`}}); - } - }); - -// Route for getting a legend image -router.route('/retrieve/:version') - .get(async (req, res) => { - const versions = (req.params.version.includes(',')) ? req.params.version.split(',') : [req.params.version]; - - try { - const results = await db.models.imageData.scope('public').findAll({ - where: { - reportId: req.report.id, - version: versions, - }, - order: [['version', 'ASC']], - }); - - return res.json(results); - } catch (error) { - logger.error(`Error while getting report images with version: ${req.params.version} ${error}`); - return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ - error: {message: 'Error while getting report images by version'}, - }); - } - }); - -// Route for getting a list of legend versions for the report -router.route('/versionlist') - .get(async (req, res) => { - try { - const results = await db.models.legend.scope('versionlist').findAll({ - where: { - reportId: req.report.id, - }, - order: [['version', 'ASC']], - }); - - return res.json(results); - } catch (error) { - logger.error(`Error while getting report legend versionlist: ${error}`); - return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ - error: {message: 'Error while getting report legend versionlist'}, - }); - } - }); - -module.exports = router; diff --git a/migrations/latest/20260616000001-add-unique-default-legend-constraint.js b/migrations/latest/20260616000001-add-unique-default-legend-constraint.js new file mode 100644 index 000000000..6ba8cb9f4 --- /dev/null +++ b/migrations/latest/20260616000001-add-unique-default-legend-constraint.js @@ -0,0 +1,19 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + // Add unique partial index to enforce only one default legend globally + await queryInterface.addIndex('pathway_analysis_legends', { + fields: [], + where: { + default: true, + deleted_at: null, + }, + unique: true, + name: 'idx_one_default_legend', + }); + }, + + down: async (queryInterface, Sequelize) => { + // Remove the index if rollback is needed + await queryInterface.removeIndex('pathway_analysis_legends', 'idx_one_default_legend'); + }, +}; diff --git a/migrations/v8.5.0/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/v8.5.0/20260421000000-DEVSU-2310-create-legend-table.js index 187b9274b..2b310bcc0 100644 --- a/migrations/v8.5.0/20260421000000-DEVSU-2310-create-legend-table.js +++ b/migrations/v8.5.0/20260421000000-DEVSU-2310-create-legend-table.js @@ -6,15 +6,6 @@ module.exports = { return queryInterface.sequelize.transaction(async (transaction) => { await queryInterface.createTable(TABLE, { ...DEFAULT_COLUMNS, - reportId: { - name: 'reportId', - field: 'report_id', - type: Sq.INTEGER, - references: { - model: 'reports', - key: 'id', - }, - }, format: { type: Sq.ENUM('PNG', 'JPG'), defaultValue: 'PNG', @@ -23,7 +14,7 @@ module.exports = { type: Sq.TEXT, allowNull: false, }, - version: { + name: { type: Sq.TEXT, allowNull: false, }, @@ -31,17 +22,9 @@ module.exports = { type: Sq.TEXT, allowNull: false, }, - title: { - type: Sq.TEXT, - }, - caption: { - type: Sq.TEXT, - }, - height: { - type: Sq.INTEGER, - }, - width: { - type: Sq.INTEGER, + default: { + type: Sq.BOOLEAN, + defaultValue: false, }, }, {transaction}); }); diff --git a/migrations/v8.5.0/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js b/migrations/v8.5.0/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js index e2abb7582..41d8d1e88 100644 --- a/migrations/v8.5.0/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js +++ b/migrations/v8.5.0/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js @@ -17,7 +17,7 @@ module.exports = { // Datafix: map existing legend values to legend_id using legends table await queryInterface.sequelize.query( - `UPDATE "${TABLE}" SET legend_id = l.id FROM pathway_analysis_legends l WHERE "${TABLE}".legend = l.version;`, + `UPDATE "${TABLE}" SET legend_id = l.id FROM pathway_analysis_legends l WHERE "${TABLE}".legend::text = l.name;`, {transaction}, ); @@ -34,15 +34,21 @@ module.exports = { down: (queryInterface, Sq) => { return queryInterface.sequelize.transaction(async (transaction) => { - // Remove the foreign key column - await queryInterface.removeColumn(TABLE, 'legend_id', {transaction}); - // Re-add the original ENUM column await queryInterface.addColumn(TABLE, 'legend', { type: Sq.ENUM(['v1', 'v2', 'v3', 'custom']), allowNull: false, defaultValue: 'v3', }, {transaction}); + + // Datafix: restore legend enum value from linked legend name where possible + await queryInterface.sequelize.query( + `UPDATE "${TABLE}" SET legend = l.name::"enum_reports_summary_pathway_analysis_legend" FROM pathway_analysis_legends l WHERE "${TABLE}".legend_id = l.id AND l.name IN ('v1', 'v2', 'v3', 'custom');`, + {transaction}, + ); + + // Remove the foreign key column + await queryInterface.removeColumn(TABLE, 'legend_id', {transaction}); }); }, }; diff --git a/test/routes/legend/legend.test.js b/test/routes/legend/legend.test.js new file mode 100644 index 000000000..543d75312 --- /dev/null +++ b/test/routes/legend/legend.test.js @@ -0,0 +1,239 @@ +const HTTP_STATUS = require('http-status-codes'); +const supertest = require('supertest'); +const getPort = require('get-port'); +const db = require('../../../app/models'); + +const CONFIG = require('../../../app/config'); +const {listen} = require('../../../app'); + +CONFIG.set('env', 'test'); +const {username, password} = CONFIG.get('testing'); + +let server; +let request; + +const legendProperties = [ + 'ident', 'createdAt', 'updatedAt', 'format', 'filename', + 'name', 'data', 'default', +]; + +const checkLegend = (legendObject) => { + legendProperties.forEach((field) => { + expect(legendObject).toHaveProperty(field); + }); + expect(legendObject).toEqual(expect.not.objectContaining({ + id: expect.any(Number), + reportId: expect.any(Number), + deletedAt: expect.any(String), + })); +}; + +// Start API +beforeAll(async () => { + const port = await getPort({port: CONFIG.get('web:port')}); + server = await listen(port); + request = supertest(server); +}); + +describe('/legend', () => { + let mockLegendData; + + const buildLegendData = (overrides = {}) => { + return { + filename: 'pathway_legend_v1.png', + name: 'v1', + data: 'v1Data', + default: false, + ...overrides, + }; + }; + + beforeAll(async () => { + // Create legend + mockLegendData = buildLegendData(); + }); + + afterEach(async () => { + await db.models.legend.destroy({ + where: {}, + force: true, + }); + }); + + describe('GET', () => { + test('/{legend} - 200 Success', async () => { + // Create legend + const legend = await db.models.legend.create(mockLegendData); + + const res = await request + .get(`/api/legend/${legend.ident}`) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + // Check that all fields are present and that data is correct + checkLegend(res.body); + + expect(res.body).toEqual(expect.objectContaining(mockLegendData)); + }); + + test('/{legend} - 404 Not Found', async () => { + await request + .get('/api/legend/00000000-0000-0000-0000-000000000000') + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.NOT_FOUND); + }); + }); + + describe('POST', () => { + test('POST / - 207 Multi-Status successful', async () => { + const res = await request + .post('/api/legend') + .attach('v1', 'test/testData/images/pathway_legend_v1.png') + .field('name', 'v1') + .auth(username, password) + .expect(HTTP_STATUS.MULTI_STATUS); + + // Check returned values match successful upload + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + const [result] = res.body; + + expect(result.name).toBe('v1'); + expect(result.upload).toBe('successful'); + expect(result.error).toBe(undefined); + + const legend = await db.models.legend.findOne({where: {name: 'v1'}}); + expect(legend).toEqual(expect.objectContaining({ + format: 'PNG', + filename: 'pathway_legend_v1.png', + name: 'v1', + default: true, + })); + }); + + test('POST / - default=true promotes new legend as only default', async () => { + const currentDefault = await db.models.legend.create(buildLegendData({default: true})); + + const res = await request + .post('/api/legend') + .attach('golden', 'test/testData/images/golden.jpg') + .field('name', 'Golden Test') + .field('default', 'true') + .auth(username, password) + .expect(HTTP_STATUS.MULTI_STATUS); + + // Check returned values match successful upload + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + const [result] = res.body; + + expect(result.name).toBe('golden'); + expect(result.upload).toBe('successful'); + expect(result.error).toBe(undefined); + + const [newDefault, oldDefault] = await Promise.all([ + db.models.legend.findOne({where: {name: 'Golden Test'}}), + db.models.legend.findByPk(currentDefault.id), + ]); + + expect(newDefault.default).toBe(true); + expect(oldDefault.default).toBe(false); + }); + + test('POST / - 400 Bad Request no files', async () => { + const res = await request + .post('/api/legend') + .auth(username, password) + .expect(HTTP_STATUS.BAD_REQUEST); + + expect(res.body.error).toEqual(expect.objectContaining({ + message: 'No attached images to upload', + })); + }); + }); + + describe('DELETE', () => { + let legend; + + beforeEach(async () => { + // Create legend + legend = await db.models.legend.create(mockLegendData); + }); + + test('/{legend} - 204 No Content', async () => { + await request + .delete(`/api/legend/${legend.ident}`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + // Check that legend was deleted + const deletedLegend = await db.models.legend.findOne({ + where: {id: legend.id}, + paranoid: false, + }); + + // Expect nothing to be returned + expect(deletedLegend).toBeNull(); + }); + + test('/{legend} - 204 No Content - Hard delete', async () => { + await request + .delete(`/api/legend/${legend.ident}?force=true`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + // Check that legend was hard deleted + const deletedLegend = await db.models.legend.findOne({ + where: {id: legend.id}, + paranoid: false, + }); + + // Expect nothing to be returned + expect(deletedLegend).toBeNull(); + }); + + test('/{legend} - deleting the only default assigns default to most recent remaining legend', async () => { + const defaultLegend = await db.models.legend.create(buildLegendData({default: true})); + const mostRecentLegend = await db.models.legend.create(buildLegendData({default: false})); + + await request + .delete(`/api/legend/${defaultLegend.ident}`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + const updatedMostRecent = await db.models.legend.findByPk(mostRecentLegend.id); + expect(updatedMostRecent.default).toBe(true); + }); + }); + + describe('PUT', () => { + test('/{legend} - setting a legend to default=true unsets previous default', async () => { + const firstLegend = await db.models.legend.create(buildLegendData({default: true})); + const secondLegend = await db.models.legend.create(buildLegendData({default: false})); + + await request + .put(`/api/legend/${secondLegend.ident}`) + .send({default: true}) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + const [updatedFirstLegend, updatedSecondLegend] = await Promise.all([ + db.models.legend.findByPk(firstLegend.id), + db.models.legend.findByPk(secondLegend.id), + ]); + + expect(updatedSecondLegend.default).toBe(true); + expect(updatedFirstLegend.default).toBe(false); + }); + }); +}); + +afterAll(async () => { + global.gc && global.gc(); + await server.close(); +}); diff --git a/test/routes/report/legend.test.js b/test/routes/report/legend.test.js deleted file mode 100644 index 9ea712842..000000000 --- a/test/routes/report/legend.test.js +++ /dev/null @@ -1,265 +0,0 @@ -const HTTP_STATUS = require('http-status-codes'); -const supertest = require('supertest'); -const getPort = require('get-port'); -const db = require('../../../app/models'); - -const CONFIG = require('../../../app/config'); -const {listen} = require('../../../app'); - -CONFIG.set('env', 'test'); -const {username, password} = CONFIG.get('testing'); - -let server; -let request; - -const legendProperties = [ - 'ident', 'createdAt', 'updatedAt', 'format', 'filename', - 'version', 'data', 'title', 'caption', -]; - -const checkLegend = (legendObject) => { - legendProperties.forEach((field) => { - expect(legendObject).toHaveProperty(field); - }); - expect(legendObject).toEqual(expect.not.objectContaining({ - id: expect.any(Number), - reportId: expect.any(Number), - deletedAt: expect.any(String), - })); -}; - -const checkLegends = (legends) => { - legends.forEach((legend) => { - checkLegend(legend); - }); -}; - -// Start API -beforeAll(async () => { - const port = await getPort({port: CONFIG.get('web:port')}); - server = await listen(port); - request = supertest(server); -}); - -describe('/reports/{REPORTID}/legend', () => { - let report; - let fakeLegendData; - - beforeAll(async () => { - // Get genomic template - const template = await db.models.template.findOne({where: {name: 'genomic'}}); - // Create test report - report = await db.models.report.create({ - templateId: template.id, - patientId: 'PATIENT1234', - }); - - fakeLegendData = { - reportId: report.id, - filename: 'TestFile.png', - version: 'v1', - data: 'TestFileData', - }; - }); - - describe('GET', () => { - test('/{legend} - 200 Success', async () => { - const legend = await db.models.legend.create(fakeLegendData); - - const res = await request - .get(`/api/reports/${report.ident}/legend/${legend.ident}`) - .auth(username, password) - .type('json') - .expect(HTTP_STATUS.OK); - - // Check that all fields are present and that data is correct - checkLegend(res.body); - - // Remove reportId before checking - const {reportId, ...legendData} = fakeLegendData; - expect(res.body).toEqual(expect.objectContaining(legendData)); - - await db.models.legend.destroy({where: {id: legend.id}, force: true}); - }); - - test('/retrieve/:version - 200 Success', async () => { - const version = 'v1'; - const testLegend = await db.models.legend.create({...fakeLegendData, version}); - - const res = await request - .get(`/api/reports/${report.ident}/legend/retrieve/${version}`) - .auth(username, password) - .type('json') - .expect(HTTP_STATUS.OK); - - checkLegends(res.body); - expect(res.body).toEqual(expect.arrayContaining([ - expect.objectContaining({version: expect.stringContaining(version)}), - ])); - - await db.models.legend.destroy({where: {ident: testLegend.ident}, force: true}); - }); - - test('/{legend} - 404 Not Found', async () => { - await request - .get(`/api/reports/${report.ident}/legend/00000000-0000-0000-0000-000000000000`) - .auth(username, password) - .type('json') - .expect(HTTP_STATUS.NOT_FOUND); - }); - - test('/versionlist - 200 Success', async () => { - const legend1 = await db.models.legend.create({...fakeLegendData, version: 'v1'}); - const legend2 = await db.models.legend.create({...fakeLegendData, version: 'v2'}); - const legend3 = await db.models.legend.create({...fakeLegendData, version: 'v3'}); - - const res = await request - .get(`/api/reports/${report.ident}/legend/versionlist`) - .auth(username, password) - .type('json') - .expect(HTTP_STATUS.OK); - - const versionlist = res.body.map((elem) => {return elem.version;}); - const expectedVersions = ['v1', 'v2', 'v3']; - expect(versionlist.every((elem) => {return expectedVersions.includes(elem);})).toBe(true); - - await db.models.legend.destroy({where: {ident: legend1.ident}, force: true}); - await db.models.legend.destroy({where: {ident: legend2.ident}, force: true}); - await db.models.legend.destroy({where: {ident: legend3.ident}, force: true}); - }); - }); - - describe('POST', () => { - test('POST / - 207 Multi-Status successful', async () => { - const res = await request - .post(`/api/reports/${report.ident}/legend`) - .attach('v1', 'test/testData/images/golden.jpg') - .auth(username, password) - .expect(HTTP_STATUS.MULTI_STATUS); - - // Check returned values match successful upload - expect(Array.isArray(res.body)).toBe(true); - expect(res.body.length).toBeGreaterThan(0); - - const [result] = res.body; - - expect(result.version).toBe('v1'); - expect(result.upload).toBe('successful'); - expect(result.error).toBe(undefined); - }); - - test('POST / - (With title and caption) 207 Multi-Status successful', async () => { - const res = await request - .post(`/api/reports/${report.ident}/legend`) - .attach('v2', 'test/testData/images/golden.jpg') - .field('v2_title', 'Test title') - .field('v2_caption', 'Test caption') - .auth(username, password) - .expect(HTTP_STATUS.MULTI_STATUS); - - // Check returned values match successful upload - expect(Array.isArray(res.body)).toBe(true); - expect(res.body.length).toBeGreaterThan(0); - - const [result] = res.body; - - expect(result.version).toBe('v2'); - expect(result.upload).toBe('successful'); - expect(result.error).toBe(undefined); - - // Test that title and caption were added to db - const legendData = await db.models.legend.findOne({ - where: { - reportId: report.id, - version: 'v2', - }, - }); - - expect(legendData).toEqual(expect.objectContaining({ - format: 'PNG', - filename: 'golden.jpg', - version: 'v2', - title: 'Test title', - caption: 'Test caption', - })); - }); - - test('POST / - 400 Bad Request duplicate version', async () => { - const res = await request - .post(`/api/reports/${report.ident}/legend`) - .attach('v1', 'test/testData/images/golden.jpg') - .attach('v1 ', 'test/testData/images/golden.jpg') - .auth(username, password) - .expect(HTTP_STATUS.BAD_REQUEST); - - // Check duplicate version error - expect(res.body.error).toEqual(expect.objectContaining({ - message: 'Duplicate versions are not allowed. Duplicate version: v1', - })); - }); - - test('POST / - 400 Bad Request no files', async () => { - const res = await request - .post(`/api/reports/${report.ident}/legend`) - .auth(username, password) - .expect(HTTP_STATUS.BAD_REQUEST); - - expect(res.body.error).toEqual(expect.objectContaining({ - message: 'No attached images to upload', - })); - }); - }); - - describe('DELETE', () => { - let legend; - - beforeEach(async () => { - // Create legend - legend = await db.models.legend.create(fakeLegendData); - }); - - test('/{legend} - 204 No Content - Soft delete', async () => { - await request - .delete(`/api/reports/${report.ident}/legend/${legend.ident}`) - .auth(username, password) - .expect(HTTP_STATUS.NO_CONTENT); - - // Check that legend was soft deleted - const deletedLegend = await db.models.legend.findOne({ - where: {id: legend.id}, - paranoid: false, - }); - - // Expect record to still exist, but deletedAt now has a date - expect(deletedLegend).toEqual(expect.objectContaining(fakeLegendData)); - expect(deletedLegend.deletedAt).not.toBeNull(); - }); - - test('/{legend} - 204 No Content - Hard delete', async () => { - await request - .delete(`/api/reports/${report.ident}/legend/${legend.ident}?force=true`) - .auth(username, password) - .expect(HTTP_STATUS.NO_CONTENT); - - // Check that legend was hard deleted - const deletedLegend = await db.models.legend.findOne({ - where: {id: legend.id}, - paranoid: false, - }); - - // Expect nothing to be returned - expect(deletedLegend).toBeNull(); - }); - }); - - afterAll(async () => { - // Delete newly created report and all of it's components - // indirectly by force deleting the report - return db.models.report.destroy({where: {ident: report.ident}, force: true}); - }); -}); - -afterAll(async () => { - global.gc && global.gc(); - await server.close(); -}); From 2f72e236b1146ba797029c755720b3295d5b2aad Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Wed, 17 Jun 2026 14:18:56 -0700 Subject: [PATCH 11/25] - Update swagger documentation and mockReportData --- app/routes/swagger/swagger.json | 102 +++--------------------------- test/testData/mockReportData.json | 8 +-- 2 files changed, 13 insertions(+), 97 deletions(-) diff --git a/app/routes/swagger/swagger.json b/app/routes/swagger/swagger.json index 406af819a..7fb161e2a 100644 --- a/app/routes/swagger/swagger.json +++ b/app/routes/swagger/swagger.json @@ -4595,15 +4595,11 @@ } } }, - "/reports/{report}/legend": { + "/legend": { "post": { "summary": "Add Legend Images", "description": "Upload new legend images to report", - "parameters": [ - { - "$ref": "#/components/parameters/report" - } - ], + "parameters": [], "requestBody": { "description": "Legend images to upload (allows multiple images with different versions)", "required": true, @@ -4612,18 +4608,13 @@ "schema": { "type": "object", "properties": { - "{version}": { - "type": "string", - "format": "binary", - "description": "legend image to upload" - }, - "{version}_title": { + "name": { "type": "string", - "description": "title of legend image specified by version" + "description": "legend name / version (e.g. v1, v2, etc.)" }, - "{version}_caption": { - "type": "string", - "description": "caption of legend image specified by version" + "default": { + "type": "boolean", + "description": "whether this legend image is the default" } } } @@ -4672,14 +4663,11 @@ } } }, - "/reports/{report}/legend/{legend}": { + "/legend/{legend}": { "get": { "summary": "Get Legend Image", "description": "Retrieve a specific legend image", "parameters": [ - { - "$ref": "#/components/parameters/report" - }, { "$ref": "#/components/parameters/legend" } @@ -4708,9 +4696,6 @@ "summary": "Delete Legend Image", "description": "Removes the specified legend image", "parameters": [ - { - "$ref": "#/components/parameters/report" - }, { "$ref": "#/components/parameters/legend" }, @@ -4740,76 +4725,7 @@ "$ref": "#/components/responses/UnauthorizedError" }, "404": { - "description": "Report or legend not found" - } - } - } - }, - "/reports/{report}/legend/retrieve/{version}": { - "get": { - "summary": "Get Legend Images by Version", - "description": "Retrieve legend image data for specified versions", - "parameters": [ - { - "$ref": "#/components/parameters/report" - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Comma separated list of legend versions (e.g. v1,v2)", - "schema": { - "type": "string" - } - } - ], - "tags": [ - "Legends" - ], - "security": [ - { - "basicAuth": [] - } - ], - "responses": { - "200": { - "description": "Returns an array of legend images that match the version" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "404": { - "description": "Report not found" - } - } - } - }, - "/reports/{report}/legend/versionlist": { - "get": { - "summary": "Get Legend Version List", - "description": "Retrieve a list of all legend versions for a report", - "parameters": [ - { - "$ref": "#/components/parameters/report" - } - ], - "tags": [ - "Legends" - ], - "security": [ - { - "basicAuth": [] - } - ], - "responses": { - "200": { - "description": "Returns an array of legend entries without image data" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedError" - }, - "404": { - "description": "Report not found" + "description": "legend not found" } } } diff --git a/test/testData/mockReportData.json b/test/testData/mockReportData.json index 750c83fc3..4e96eb305 100644 --- a/test/testData/mockReportData.json +++ b/test/testData/mockReportData.json @@ -946,10 +946,10 @@ "legends": [ { "id": 1, - "version": "v1", - "path": "test/testData/images/msig_snvs_all_strelka.png", - "title": "Test Pathway Analysis Legend", - "caption": "Test Pathway Analysis Legend" + "filename": "pathway_legend_v1.png", + "path": "test/testData/images/pathway_legend_v1.png", + "name": "v1", + "default": true } ] } From d40e727a3ffab01e7ad3b8831c973f903567f063 Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Wed, 17 Jun 2026 14:25:51 -0700 Subject: [PATCH 12/25] - Move migration files to latest folder - Update unique constraint index field to default - Code lint --- app/models/legend/legend.js | 2 +- app/routes/legend/index.js | 2 +- .../20260421000000-DEVSU-2310-create-legend-table.js | 0 ...21000001-DEVSU-2310-update-pathway-analysis-legend-fk.js | 0 .../20260616000001-add-unique-default-legend-constraint.js | 6 +++--- 5 files changed, 5 insertions(+), 5 deletions(-) rename migrations/{v8.5.0 => latest}/20260421000000-DEVSU-2310-create-legend-table.js (100%) rename migrations/{v8.5.0 => latest}/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js (100%) diff --git a/app/models/legend/legend.js b/app/models/legend/legend.js index f43bc4707..24496e18c 100644 --- a/app/models/legend/legend.js +++ b/app/models/legend/legend.js @@ -1,4 +1,4 @@ -const { DEFAULT_COLUMNS } = require('../base'); +const {DEFAULT_COLUMNS} = require('../base'); module.exports = (sequelize, Sq) => { const legend = sequelize.define( diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js index eff868530..8bb20caf1 100644 --- a/app/routes/legend/index.js +++ b/app/routes/legend/index.js @@ -62,7 +62,7 @@ router.route('/') .get(async (req, res) => { try { const legends = await db.models.legend.findAll(); - return res.json(legends.map((legend) => legend.view('public'))); + return res.json(legends.map((legend) => {return legend.view('public');})); } catch (error) { logger.error(`Error while retrieving legend images ${error}`); return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while retrieving legend images'}}); diff --git a/migrations/v8.5.0/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js similarity index 100% rename from migrations/v8.5.0/20260421000000-DEVSU-2310-create-legend-table.js rename to migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js diff --git a/migrations/v8.5.0/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js b/migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js similarity index 100% rename from migrations/v8.5.0/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js rename to migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js diff --git a/migrations/latest/20260616000001-add-unique-default-legend-constraint.js b/migrations/latest/20260616000001-add-unique-default-legend-constraint.js index 6ba8cb9f4..4132c4aef 100644 --- a/migrations/latest/20260616000001-add-unique-default-legend-constraint.js +++ b/migrations/latest/20260616000001-add-unique-default-legend-constraint.js @@ -1,8 +1,8 @@ module.exports = { - up: async (queryInterface, Sequelize) => { + up: async (queryInterface) => { // Add unique partial index to enforce only one default legend globally await queryInterface.addIndex('pathway_analysis_legends', { - fields: [], + fields: ['default'], where: { default: true, deleted_at: null, @@ -12,7 +12,7 @@ module.exports = { }); }, - down: async (queryInterface, Sequelize) => { + down: async (queryInterface) => { // Remove the index if rollback is needed await queryInterface.removeIndex('pathway_analysis_legends', 'idx_one_default_legend'); }, From e769fb94de9fc2f87fbf9e276cbd6709b7838290 Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Fri, 19 Jun 2026 09:46:39 -0700 Subject: [PATCH 13/25] - Add image type SVG to legend format enum - Additional default: true filter for other legends in beforeUpdate hook to update only records with default: true to prevent excessive updates - Create legend images sequentially to ensure that default true constraint apply properly - Update uploadLegendImage documentation params - Update swagger - Code lint --- app/models/legend/legend.js | 14 ++++++++++---- app/routes/legend/index.js | 10 ++++++---- app/routes/report/images.js | 4 +--- app/routes/swagger/swagger.json | 2 +- ...0260421000000-DEVSU-2310-create-legend-table.js | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/models/legend/legend.js b/app/models/legend/legend.js index 24496e18c..58156fbe8 100644 --- a/app/models/legend/legend.js +++ b/app/models/legend/legend.js @@ -6,7 +6,7 @@ module.exports = (sequelize, Sq) => { { ...DEFAULT_COLUMNS, format: { - type: Sq.ENUM('PNG', 'JPG'), + type: Sq.ENUM('PNG', 'JPG', 'SVG'), defaultValue: 'PNG', }, filename: { @@ -31,7 +31,7 @@ module.exports = (sequelize, Sq) => { indexes: [ { unique: true, - fields: [], + fields: ['default'], where: { default: true, deleted_at: null, @@ -53,7 +53,10 @@ module.exports = (sequelize, Sq) => { await sequelize.models.legend.update( {default: false}, { - where: {id: {[sequelize.Sequelize.Op.ne]: instance.id}}, + where: { + default: true, + id: {[sequelize.Sequelize.Op.ne]: instance.id}, + }, transaction: options.transaction, }, ); @@ -65,7 +68,10 @@ module.exports = (sequelize, Sq) => { await sequelize.models.legend.update( {default: false}, { - where: {id: {[sequelize.Sequelize.Op.ne]: instance.id}}, + where: { + default: true, + id: {[sequelize.Sequelize.Op.ne]: instance.id}, + }, transaction: options.transaction, }, ); diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js index 8bb20caf1..23cb45316 100644 --- a/app/routes/legend/index.js +++ b/app/routes/legend/index.js @@ -76,7 +76,8 @@ router.route('/') } try { - const results = await Promise.all(Object.entries(req.files).map(async ([key, image]) => { + const results = []; + for (const [key, image] of Object.entries(req.files)) { try { // Set options (value or undefined) const options = { @@ -88,12 +89,13 @@ router.route('/') // Load image const createdLegend = await uploadLegendImage(image.data, options); await createdLegend.ensureDefaultExists(); + // Return that this image was uploaded successfully - return {name: key, upload: 'successful'}; + results.push({name: key, upload: 'successful'}); } catch (error) { - return {name: key, upload: 'failed', error}; + results.push({name: key, upload: 'failed', error}); } - })); + } return res.status(HTTP_STATUS.MULTI_STATUS).json(results); } catch (error) { logger.error(`Error while uploading images ${error}`); diff --git a/app/routes/report/images.js b/app/routes/report/images.js index 609f39e07..cf34acdc7 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -50,13 +50,11 @@ const uploadReportImage = async (reportId, key, image, options = {}) => { /** * Resize, reformat and upload a legend image to the pathway_analysis_legends table * - * @param {Number} reportId - The primary key for the report this legend belongs to (to create FK relationship) - * @param {string} version - The legend version identifier (e.g. 'v1', 'v2', 'v3') * @param {Buffer|string} image - Buffer containing image data or the absolute path to the image file * @param {object} options - An object containing additional image upload options * * @property {string} options.filename - An optional filename for the image - * @property {string} options.name - An optional name for the legend + * @property {string} options.name - An optional name/version for the legend * @property {boolean|string} options.default - Whether this legend is the default * @property {object} options.transaction - An optional transaction to run the create under * diff --git a/app/routes/swagger/swagger.json b/app/routes/swagger/swagger.json index 7fb161e2a..1573daf93 100644 --- a/app/routes/swagger/swagger.json +++ b/app/routes/swagger/swagger.json @@ -4658,7 +4658,7 @@ "$ref": "#/components/responses/UnauthorizedError" }, "404": { - "description": "Report not found" + "description": "Legend not found" } } } diff --git a/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js index 2b310bcc0..ebc48e01c 100644 --- a/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js +++ b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js @@ -7,7 +7,7 @@ module.exports = { await queryInterface.createTable(TABLE, { ...DEFAULT_COLUMNS, format: { - type: Sq.ENUM('PNG', 'JPG'), + type: Sq.ENUM('PNG', 'JPG', 'SVG'), defaultValue: 'PNG', }, filename: { From feeeedf8cb4683b354ad6f5e22371704042850be Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Fri, 19 Jun 2026 10:26:13 -0700 Subject: [PATCH 14/25] - Update pathwayAnalysis unit test --- test/routes/report/summary/pathwayAnalysis.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/routes/report/summary/pathwayAnalysis.test.js b/test/routes/report/summary/pathwayAnalysis.test.js index ecefa20d6..dd5a1f76a 100644 --- a/test/routes/report/summary/pathwayAnalysis.test.js +++ b/test/routes/report/summary/pathwayAnalysis.test.js @@ -89,13 +89,13 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', 2) + .field('legendId', 1) .expect(HTTP_STATUS.OK); checkPathwayAnalysis(res.body); expect(res.body.pathway).not.toBeNull(); - expect(res.body.legendId).toBe(2); + expect(res.body.legendId).toBe(1); }); test('/ - 400 Bad request - Invalid legend fk', async () => { @@ -173,13 +173,13 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', 2) + .field('legendId', 1) .expect(HTTP_STATUS.CREATED); checkPathwayAnalysis(res.body); expect(res.body.pathway).not.toBeNull(); - expect(res.body.legendId).toBe(2); + expect(res.body.legendId).toBe(1); // Remove pathway analysis await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); @@ -216,7 +216,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', 2) + .field('legendId', 1) .expect(HTTP_STATUS.CONFLICT); // Remove pathway analysis From e9b714d49559508bdd163872f81390fc7dccc508 Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Fri, 19 Jun 2026 10:56:49 -0700 Subject: [PATCH 15/25] - Remove legends from mockReportData json - Update pathwayAnalysis unit tests --- app/routes/report/summary/pathwayAnalysis.js | 2 +- .../report/summary/pathwayAnalysis.test.js | 23 +++++++++++++------ test/testData/mockReportData.json | 14 ++--------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/routes/report/summary/pathwayAnalysis.js b/app/routes/report/summary/pathwayAnalysis.js index b0f959859..72234f21c 100644 --- a/app/routes/report/summary/pathwayAnalysis.js +++ b/app/routes/report/summary/pathwayAnalysis.js @@ -79,7 +79,7 @@ router.route('/') return res.json(req.pathwayAnalysis.view('public')); } catch (error) { logger.error(`Unable to update pathway analysis ${error}`); - return res.status().json({error: {message: 'Unable to update pathway analysis'}}); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to update pathway analysis'}}); } }) .delete(async (req, res) => { diff --git a/test/routes/report/summary/pathwayAnalysis.test.js b/test/routes/report/summary/pathwayAnalysis.test.js index dd5a1f76a..aa170cb7b 100644 --- a/test/routes/report/summary/pathwayAnalysis.test.js +++ b/test/routes/report/summary/pathwayAnalysis.test.js @@ -32,6 +32,7 @@ beforeAll(async () => { describe('/reports/{report}/summary/pathway-analysis', () => { let report; + let legend; beforeAll(async () => { // Get genomic template @@ -41,6 +42,13 @@ describe('/reports/{report}/summary/pathway-analysis', () => { templateId: template.id, patientId: 'TESTPATIENT1234', }); + // Create legend for pathway analysis tests + legend = await db.models.legend.create({ + filename: 'pathway_legend_v1.png', + name: 'v1', + data: 'v1Data', + default: true, + }); }); describe('GET', () => { @@ -89,13 +97,13 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', 1) + .field('legendId', legend.id) .expect(HTTP_STATUS.OK); checkPathwayAnalysis(res.body); expect(res.body.pathway).not.toBeNull(); - expect(res.body.legendId).toBe(1); + expect(res.body.legendId).toBe(legend.id); }); test('/ - 400 Bad request - Invalid legend fk', async () => { @@ -113,7 +121,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/golden.jpg') - .field('legendId', 1) + .field('legendId', legend.id) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -173,13 +181,13 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', 1) + .field('legendId', legend.id) .expect(HTTP_STATUS.CREATED); checkPathwayAnalysis(res.body); expect(res.body.pathway).not.toBeNull(); - expect(res.body.legendId).toBe(1); + expect(res.body.legendId).toBe(legend.id); // Remove pathway analysis await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); @@ -201,7 +209,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/golden.jpg') - .field('legendId', 1) + .field('legendId', legend.id) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -216,7 +224,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', 1) + .field('legendId', legend.id) .expect(HTTP_STATUS.CONFLICT); // Remove pathway analysis @@ -226,6 +234,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { // Delete report afterAll(async () => { + await db.models.legend.destroy({where: {id: legend.id}, force: true}); await db.models.report.destroy({where: {id: report.id}, force: true}); }); }); diff --git a/test/testData/mockReportData.json b/test/testData/mockReportData.json index 4e96eb305..9777724de 100644 --- a/test/testData/mockReportData.json +++ b/test/testData/mockReportData.json @@ -123,8 +123,7 @@ } ], "pathwayAnalysis": { - "pathway": "\nimage/svg+xmlGENOMIC CASE NOTES\nPATHWAY VISUALIZATION\nPancreas Cancer \nTissue Comparator: average\nDisease Comparator: PAAD\n\n none\n NOTCH1\n\n Node NOTCH1\n 99\npercentile=99;foldchange=2.35;copychange=0HES4\n\n Node HES4\n 100\npercentile=100;foldchange=25.21;copychange=-1+4\nNOTCH2\n\n Node NOTCH2\n 100\npercentile=100;foldchange=2.84;copychange=4NOTCH\nGUCA2A\n\n Node GUCA2A\n 99\npercentile=99;foldchange=30.23;copychange=-1SMAD2\n6\nSMAD4\nPDAC related genes\nRRM2\n\n Node RRM2\n percentile=18;foldchange=2.63;copychange=0RRM1\n\n Node RRM1\n percentile=54;foldchange=1.02;copychange=0CD274\n\n Node CD274\n percentile=46;foldchange=-3.03;copychange=0CTLA4\n\n Node CTLA4\n percentile=20;foldchange=-1.48;copychange=0PDCD1\n\n Node PDCD1\n percentile=27;foldchange=-1.18;copychange=0Immune Therapy\n\n none\n \n none\n MTOR\n\n Node MTOR\n percentile=36;foldchange=-1.47;copychange=-1PTEN\n\n Node PTEN\n 2\npercentile=2;foldchange=-2.1;copychange=0PIK3CA\n\n Node PIK3CA\n percentile=17;foldchange=-2.03;copychange=0PI3K/MTOR\nTP53\n\n Node TP53\n 92\npercentile=92;foldchange=1.47;copychange=0FANCD2\n\n Node FANCD2\n mutations=p.E803K;percentile=67;foldchange=1.33;copychange=0RAD50\n\n Node RAD50\n percentile=53;foldchange=-1.31;copychange=-1FAM175A\n\n Node FAM175A\n percentile=17;foldchange=-2.02;copychange=0BRCA1\n\n Node BRCA1\n 95\npercentile=95;foldchange=1.28;copychange=0BRCA2\n\n Node BRCA2\n percentile=89;foldchange=1.16;copychange=0Homologous recombination\nERBB4\n\n Node ERBB4\n percentile=31;foldchange=-2.6;copychange=0IGF2\n\n Node IGF2\n 97\npercentile=97;foldchange=1.56;copychange=0+2\nERBB2\n\n Node ERBB2\n percentile=45;foldchange=6.08;copychange=2EGFR\n\n Node EGFR\n percentile=90;foldchange=-1.06;copychange=0FGFR2\n\n Node FGFR2\n 100\npercentile=100;foldchange=3.61;copychange=0FGFR4\n\n Node FGFR4\n 98\npercentile=98;foldchange=13.39;copychange=-1FGFR3\n\n Node FGFR3\n 99\npercentile=99;foldchange=14.12;copychange=0ERBB3\n\n Node ERBB3\n 93\npercentile=93;foldchange=9.73;copychange=0RTKs\nNRG1\n\n Node NRG1\n 100\nATP1B1\nstructural-variants=translocation(ATP1B1,NRG1) and translocation(NRG1,ATP1B1);percentile=100;foldchange=39.25;copychange=-1\n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_MAP kinase signalling pathways in cancer_17496922\n MAP2K2\n\n Node MAP2K2\n percentile=73;foldchange=1.8;copychange=0BRAF\n\n Node BRAF\n 97\npercentile=97;foldchange=-1.25;copychange=0KRAS\n\n Node KRAS\n 6\npercentile=6;foldchange=-2.05;copychange=0MAP2K1\n\n Node MAP2K1\n 3\npercentile=3;foldchange=-1.35;copychange=1MAPK\nCEBPA\n\n Node CEBPA\n 92\npercentile=92;foldchange=-1.05;copychange=0JUN\n\n Node JUN\n hom\nmutations=p.S37fs;percentile=65;foldchange=1.18;copychange=-1", - "legend": 1 + "pathway": "\nimage/svg+xmlGENOMIC CASE NOTES\nPATHWAY VISUALIZATION\nPancreas Cancer \nTissue Comparator: average\nDisease Comparator: PAAD\n\n none\n NOTCH1\n\n Node NOTCH1\n 99\npercentile=99;foldchange=2.35;copychange=0HES4\n\n Node HES4\n 100\npercentile=100;foldchange=25.21;copychange=-1+4\nNOTCH2\n\n Node NOTCH2\n 100\npercentile=100;foldchange=2.84;copychange=4NOTCH\nGUCA2A\n\n Node GUCA2A\n 99\npercentile=99;foldchange=30.23;copychange=-1SMAD2\n6\nSMAD4\nPDAC related genes\nRRM2\n\n Node RRM2\n percentile=18;foldchange=2.63;copychange=0RRM1\n\n Node RRM1\n percentile=54;foldchange=1.02;copychange=0CD274\n\n Node CD274\n percentile=46;foldchange=-3.03;copychange=0CTLA4\n\n Node CTLA4\n percentile=20;foldchange=-1.48;copychange=0PDCD1\n\n Node PDCD1\n percentile=27;foldchange=-1.18;copychange=0Immune Therapy\n\n none\n \n none\n MTOR\n\n Node MTOR\n percentile=36;foldchange=-1.47;copychange=-1PTEN\n\n Node PTEN\n 2\npercentile=2;foldchange=-2.1;copychange=0PIK3CA\n\n Node PIK3CA\n percentile=17;foldchange=-2.03;copychange=0PI3K/MTOR\nTP53\n\n Node TP53\n 92\npercentile=92;foldchange=1.47;copychange=0FANCD2\n\n Node FANCD2\n mutations=p.E803K;percentile=67;foldchange=1.33;copychange=0RAD50\n\n Node RAD50\n percentile=53;foldchange=-1.31;copychange=-1FAM175A\n\n Node FAM175A\n percentile=17;foldchange=-2.02;copychange=0BRCA1\n\n Node BRCA1\n 95\npercentile=95;foldchange=1.28;copychange=0BRCA2\n\n Node BRCA2\n percentile=89;foldchange=1.16;copychange=0Homologous recombination\nERBB4\n\n Node ERBB4\n percentile=31;foldchange=-2.6;copychange=0IGF2\n\n Node IGF2\n 97\npercentile=97;foldchange=1.56;copychange=0+2\nERBB2\n\n Node ERBB2\n percentile=45;foldchange=6.08;copychange=2EGFR\n\n Node EGFR\n percentile=90;foldchange=-1.06;copychange=0FGFR2\n\n Node FGFR2\n 100\npercentile=100;foldchange=3.61;copychange=0FGFR4\n\n Node FGFR4\n 98\npercentile=98;foldchange=13.39;copychange=-1FGFR3\n\n Node FGFR3\n 99\npercentile=99;foldchange=14.12;copychange=0ERBB3\n\n Node ERBB3\n 93\npercentile=93;foldchange=9.73;copychange=0RTKs\nNRG1\n\n Node NRG1\n 100\nATP1B1\nstructural-variants=translocation(ATP1B1,NRG1) and translocation(NRG1,ATP1B1);percentile=100;foldchange=39.25;copychange=-1\n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_MAP kinase signalling pathways in cancer_17496922\n MAP2K2\n\n Node MAP2K2\n percentile=73;foldchange=1.8;copychange=0BRAF\n\n Node BRAF\n 97\npercentile=97;foldchange=-1.25;copychange=0KRAS\n\n Node KRAS\n 6\npercentile=6;foldchange=-2.05;copychange=0MAP2K1\n\n Node MAP2K1\n 3\npercentile=3;foldchange=-1.35;copychange=1MAPK\nCEBPA\n\n Node CEBPA\n 92\npercentile=92;foldchange=-1.05;copychange=0JUN\n\n Node JUN\n hom\nmutations=p.S37fs;percentile=65;foldchange=1.18;copychange=-1" }, "probeResults": [ { @@ -942,14 +941,5 @@ "key": "expression.spearman.brca.receptor", "path": "test/testData/images/spearman_brca_receptor.png" } - ], - "legends": [ - { - "id": 1, - "filename": "pathway_legend_v1.png", - "path": "test/testData/images/pathway_legend_v1.png", - "name": "v1", - "default": true - } ] -} +} \ No newline at end of file From 469b640b696bda8985d329e6c8890d257bcb1933 Mon Sep 17 00:00:00 2001 From: bnguyen-bcgsc Date: Mon, 29 Jun 2026 14:29:42 -0700 Subject: [PATCH 16/25] - Update model to enforce default value - Convert ensureDefaultExists to static function - Wrap crud functions in routes in the same transaction as ensureDefaultExists - Update swagger json - Update error messages displaying in routes --- app/models/legend/legend.js | 18 +++++++++++--- app/routes/legend/index.js | 43 +++++++++++++++++++++------------ app/routes/report/images.js | 2 +- app/routes/swagger/swagger.json | 32 ++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/app/models/legend/legend.js b/app/models/legend/legend.js index 58156fbe8..4ba7a43a7 100644 --- a/app/models/legend/legend.js +++ b/app/models/legend/legend.js @@ -24,6 +24,9 @@ module.exports = (sequelize, Sq) => { default: { type: Sq.BOOLEAN, defaultValue: false, + set(value) { + this.setDataValue('default', value === true || value === 'true'); + }, }, }, { @@ -55,7 +58,6 @@ module.exports = (sequelize, Sq) => { { where: { default: true, - id: {[sequelize.Sequelize.Op.ne]: instance.id}, }, transaction: options.transaction, }, @@ -93,21 +95,29 @@ module.exports = (sequelize, Sq) => { }; // Ensure at least one default exists - legend.prototype.ensureDefaultExists = async function () { + legend.ensureDefaultExists = async function (options = {}) { + const {transaction} = options; const hasDefault = await sequelize.models.legend.findOne({ where: {default: true}, + transaction, + lock: transaction ? transaction.LOCK.UPDATE : undefined, }); if (!hasDefault) { const mostRecent = await sequelize.models.legend.findOne({ order: [['createdAt', 'DESC']], + transaction, + lock: transaction ? transaction.LOCK.UPDATE : undefined, }); - if (mostRecent) { - await mostRecent.update({default: true}); + await mostRecent.update({default: true}, {transaction}); } } }; + legend.prototype.ensureDefaultExists = async function (options = {}) { + return legend.ensureDefaultExists(options); + }; + return legend; }; diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js index 23cb45316..5717dbfa7 100644 --- a/app/routes/legend/index.js +++ b/app/routes/legend/index.js @@ -15,7 +15,7 @@ router.param('legend', async (req, res, next, legendIdent) => { where: {ident: legendIdent}, }); } catch (error) { - logger.error(`Unable to lookup legend error: ${error}`); + logger.error(`Unable to lookup legend error: ${error.message}`); return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to lookup legend'}}); } @@ -34,26 +34,35 @@ router.route('/:legend([A-z0-9-]{36})') }) .put(async (req, res) => { try { - await req.legend.update(req.body, {userId: req.user.id}); - await req.legend.ensureDefaultExists(); + await db.transaction(async (transaction) => { + await req.legend.update(req.body, {userId: req.user.id, transaction}); + await db.models.legend.ensureDefaultExists({transaction}); + }); return res.json(req.legend.view('public')); } catch (error) { logger.error(`Error while updating legend image ${error}`); - return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while updating legend image'}}); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR) + .json({error: {message: 'Error while updating legend image'}}); } }) .delete(async (req, res) => { - // Whether to hard or soft delete legend const force = (req.query.force === 'true'); + const wasDefault = req.legend.default; - // Delete legend image try { - await req.legend.destroy({force}); - await req.legend.ensureDefaultExists(); + await db.transaction(async (transaction) => { + await req.legend.destroy({force, transaction}); + + // Only re-evaluate the default if we just removed the default one. + if (wasDefault) { + await db.models.legend.ensureDefaultExists({transaction}); + } + }); return res.status(HTTP_STATUS.NO_CONTENT).send(); } catch (error) { logger.error(`Error while deleting legend image ${error}`); - return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while deleting legend image'}}); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR) + .json({error: {message: 'Error while deleting legend image'}}); } }); @@ -64,7 +73,7 @@ router.route('/') const legends = await db.models.legend.findAll(); return res.json(legends.map((legend) => {return legend.view('public');})); } catch (error) { - logger.error(`Error while retrieving legend images ${error}`); + logger.error(`Error while retrieving legend images ${error.message}`); return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while retrieving legend images'}}); } }) @@ -83,23 +92,25 @@ router.route('/') const options = { filename: image.name.trim(), name: req.body.name || image.name.trim(), - default: req.body.default === undefined ? false : req.body.default, + default: req.body.default, }; // Load image - const createdLegend = await uploadLegendImage(image.data, options); - await createdLegend.ensureDefaultExists(); + await db.transaction(async (transaction) => { + await uploadLegendImage(image.data, {...options, transaction}); + await db.models.legend.ensureDefaultExists({transaction}); + }); // Return that this image was uploaded successfully results.push({name: key, upload: 'successful'}); } catch (error) { - results.push({name: key, upload: 'failed', error}); + results.push({name: key, upload: 'failed', error: {message: error.message}}); } } return res.status(HTTP_STATUS.MULTI_STATUS).json(results); } catch (error) { - logger.error(`Error while uploading images ${error}`); - return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error}`}}); + logger.error(`Error while uploading images ${error.message}`); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error.message}`}}); } }); diff --git a/app/routes/report/images.js b/app/routes/report/images.js index cf34acdc7..01684531a 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -74,7 +74,7 @@ const uploadLegendImage = async (image, options = {}) => { filename: options.filename, name: options.name || options.filename, data: imageData, - default: options.default === true || options.default === 'true', + default: options.default, }, {transaction: options.transaction}); } catch (error) { logger.error(`Error processing legend image ${options.filename} ${error}`); diff --git a/app/routes/swagger/swagger.json b/app/routes/swagger/swagger.json index 1573daf93..bfc5eb293 100644 --- a/app/routes/swagger/swagger.json +++ b/app/routes/swagger/swagger.json @@ -4596,6 +4596,34 @@ } }, "/legend": { + "get": { + "summary": "Get Legend Images", + "description": "Retrieve all legend images", + "parameters": [ + { + "$ref": "#/components/parameters/legend" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the details of all legend images" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Legends not found" + } + } + }, "post": { "summary": "Add Legend Images", "description": "Upload new legend images to report", @@ -4639,7 +4667,7 @@ "items": { "type": "object", "properties": { - "version": { + "name": { "type": "string" }, "upload": { @@ -4688,7 +4716,7 @@ "$ref": "#/components/responses/UnauthorizedError" }, "404": { - "description": "Report or legend not found" + "description": "Legend not found" } } }, From ea763a70d1ce44397facc9a27abb2ce97a60d51e Mon Sep 17 00:00:00 2001 From: kttkjl Date: Wed, 15 Jul 2026 02:59:56 -0700 Subject: [PATCH 17/25] feat: add image data to PUT path - Add updateLegendImage helper that processes an uploaded file and updates the legend's data/format/filename (plus any metadata) within a transaction - Use it from the PUT route when a file is attached; otherwise fall back to a metadata-only update - Reload the instance after commit so the response reflects persisted state (fixes stale default flag in the response) - Add tests for image replacement and metadata-only update DEVSU-2310 --- app/routes/legend/index.js | 12 +++++++-- app/routes/report/images.js | 35 +++++++++++++++++++++++++ test/routes/legend/legend.test.js | 43 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js index 5717dbfa7..1ca1c8a9b 100644 --- a/app/routes/legend/index.js +++ b/app/routes/legend/index.js @@ -3,7 +3,7 @@ const express = require('express'); const db = require('../../models'); const logger = require('../../log'); -const {uploadLegendImage} = require('../report/images'); +const {uploadLegendImage, updateLegendImage} = require('../report/images'); const router = express.Router({mergeParams: true}); @@ -33,11 +33,19 @@ router.route('/:legend([A-z0-9-]{36})') return res.json(req.legend.view('public')); }) .put(async (req, res) => { + // Use the first uploaded file, if any, to replace the stored image + const [image] = req.files ? Object.values(req.files) : []; + try { await db.transaction(async (transaction) => { - await req.legend.update(req.body, {userId: req.user.id, transaction}); + if (image) { + await updateLegendImage(req.legend, image, {updates: req.body, userId: req.user.id, transaction}); + } else { + await req.legend.update(req.body, {userId: req.user.id, transaction}); + } await db.models.legend.ensureDefaultExists({transaction}); }); + await req.legend.reload(); return res.json(req.legend.view('public')); } catch (error) { logger.error(`Error while updating legend image ${error}`); diff --git a/app/routes/report/images.js b/app/routes/report/images.js index 01684531a..708dbda8b 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -82,7 +82,42 @@ const uploadLegendImage = async (image, options = {}) => { } }; +/** + * Resize, reformat and replace the image on an existing legend entry + * + * @param {object} legend - The legend db instance to update + * @param {object} image - The uploaded file, containing a data buffer and name + * @param {object} options - An object containing additional update options + * + * @property {object} options.updates - Additional legend fields to update (e.g. name, default) + * @property {Number} options.userId - The id of the user performing the update + * @property {object} options.transaction - An optional transaction to run the update under + * + * @returns {Promise} - Returns the updated legend db entry + * @throws {Promise} - Something goes wrong with image processing and saving entry + */ +const updateLegendImage = async (legend, image, options = {}) => { + logger.verbose('Updating legend image'); + + const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT}; + + try { + const imageData = await processImage(image.data, config.size, config.format); + + return legend.update({ + ...options.updates, + format: config.format, + filename: image.name.trim(), + data: imageData, + }, {userId: options.userId, transaction: options.transaction}); + } catch (error) { + logger.error(`Error processing legend image ${image.name} ${error}`); + throw new Error(`Error processing legend image ${image.name} ${error}`); + } +}; + module.exports = { uploadReportImage, uploadLegendImage, + updateLegendImage, }; diff --git a/test/routes/legend/legend.test.js b/test/routes/legend/legend.test.js index 543d75312..848829dcc 100644 --- a/test/routes/legend/legend.test.js +++ b/test/routes/legend/legend.test.js @@ -230,6 +230,49 @@ describe('/legend', () => { expect(updatedSecondLegend.default).toBe(true); expect(updatedFirstLegend.default).toBe(false); }); + + test('/{legend} - uploading a new image replaces the stored image', async () => { + const legend = await db.models.legend.create( + buildLegendData({data: 'oldData', filename: 'old.png'}), + ); + + const res = await request + .put(`/api/legend/${legend.ident}`) + .attach('image', 'test/testData/images/golden.jpg') + .field('name', 'updated name') + .auth(username, password) + .expect(HTTP_STATUS.OK); + + checkLegend(res.body); + expect(res.body.name).toBe('updated name'); + expect(res.body.filename).toBe('golden.jpg'); + expect(res.body.format).toBe('PNG'); + expect(res.body.data).not.toBe('oldData'); + + // The replacement is persisted, not just reflected in the response + const updated = await db.models.legend.findByPk(legend.id); + expect(updated.data).not.toBe('oldData'); + expect(updated.filename).toBe('golden.jpg'); + }); + + test('/{legend} - metadata-only update preserves the existing image', async () => { + const legend = await db.models.legend.create( + buildLegendData({data: 'keepThisData', name: 'original'}), + ); + + const res = await request + .put(`/api/legend/${legend.ident}`) + .send({name: 'renamed'}) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + expect(res.body.name).toBe('renamed'); + expect(res.body.data).toBe('keepThisData'); + + const updated = await db.models.legend.findByPk(legend.id); + expect(updated.data).toBe('keepThisData'); + }); }); }); From 943d8bc5aa1a6c84a30329611d4ec7f868fe99a8 Mon Sep 17 00:00:00 2001 From: kttkjl Date: Mon, 27 Jul 2026 22:48:23 -0700 Subject: [PATCH 18/25] chore: move 2310 migration into two batches DEVSU-2310 --- .../20260421000000-DEVSU-2310-create-legend-table.js | 2 +- ...260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js | 0 .../20260616000001-add-unique-default-legend-constraint.js | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename migrations/latest/{ => batch1}/20260421000000-DEVSU-2310-create-legend-table.js (93%) rename migrations/latest/{ => batch2}/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js (100%) rename migrations/latest/{ => batch2}/20260616000001-add-unique-default-legend-constraint.js (100%) diff --git a/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/latest/batch1/20260421000000-DEVSU-2310-create-legend-table.js similarity index 93% rename from migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js rename to migrations/latest/batch1/20260421000000-DEVSU-2310-create-legend-table.js index ebc48e01c..ac53c4f8f 100644 --- a/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js +++ b/migrations/latest/batch1/20260421000000-DEVSU-2310-create-legend-table.js @@ -1,5 +1,5 @@ const TABLE = 'pathway_analysis_legends'; -const {DEFAULT_COLUMNS} = require('../../app/models/base'); +const {DEFAULT_COLUMNS} = require('../../../app/models/base'); module.exports = { up: (queryInterface, Sq) => { diff --git a/migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js b/migrations/latest/batch2/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js similarity index 100% rename from migrations/latest/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js rename to migrations/latest/batch2/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js diff --git a/migrations/latest/20260616000001-add-unique-default-legend-constraint.js b/migrations/latest/batch2/20260616000001-add-unique-default-legend-constraint.js similarity index 100% rename from migrations/latest/20260616000001-add-unique-default-legend-constraint.js rename to migrations/latest/batch2/20260616000001-add-unique-default-legend-constraint.js From 723ec2f8095169f2ced7a45afc1a516825b461a8 Mon Sep 17 00:00:00 2001 From: kttkjl Date: Mon, 27 Jul 2026 22:51:59 -0700 Subject: [PATCH 19/25] feat: add route to query by legend-id - add swagger doc DEVSU-2310 --- app/routes/legend/index.js | 20 +++++++++++++++++ app/routes/swagger/swagger.json | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js index 1ca1c8a9b..f91b7f508 100644 --- a/app/routes/legend/index.js +++ b/app/routes/legend/index.js @@ -74,6 +74,26 @@ router.route('/:legend([A-z0-9-]{36})') } }); +// Route for querying legend by numeric id +router.route('/:legendId(\\d+)') + .get(async (req, res) => { + try { + const legend = await db.models.legend.findByPk(req.params.legendId); + if (!legend) { + logger.error(`Unable to find legend with id ${req.params.legendId}`); + const msg = 'Unable to find the requested legend'; + return res.status(HTTP_STATUS.NOT_FOUND) + .json({error: {message: msg}}); + } + return res.json(legend.view('public')); + } catch (error) { + logger.error(`Unable to lookup legend by id error: ${error.message}`); + const msg = 'Unable to lookup legend'; + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR) + .json({error: {message: msg}}); + } + }); + // Route for adding a legend image router.route('/') .get(async (req, res) => { diff --git a/app/routes/swagger/swagger.json b/app/routes/swagger/swagger.json index bfc5eb293..5edea12f4 100644 --- a/app/routes/swagger/swagger.json +++ b/app/routes/swagger/swagger.json @@ -4758,6 +4758,36 @@ } } }, + "/legend/{legendId}": { + "get": { + "summary": "Get Legend Image by ID", + "description": "Retrieve a specific legend image by numeric ID", + "parameters": [ + { + "$ref": "#/components/parameters/legendId" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the details of the specified legend image" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Legend not found" + } + } + } + }, "/reports/{report}/kb-matches": { "get": { "summary": "Get KB Matches", @@ -9010,6 +9040,15 @@ "format": "UUIDv4" } }, + "legendId": { + "name": "legendId", + "in": "path", + "required": true, + "description": "legend image ID", + "schema": { + "type": "integer" + } + }, "alteration": { "name": "alteration", "in": "path", From fb6f2afb88b6fc50a9ebb844c4ca8fab255a875b Mon Sep 17 00:00:00 2001 From: kttkjl Date: Tue, 28 Jul 2026 01:03:45 -0700 Subject: [PATCH 20/25] feat: update migration script to account for subfolders in latest DEVSU-2310 --- migrationTools/migratedb.sh | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/migrationTools/migratedb.sh b/migrationTools/migratedb.sh index c38602468..51b9c2c06 100644 --- a/migrationTools/migratedb.sh +++ b/migrationTools/migratedb.sh @@ -5,9 +5,30 @@ echo "Migrating against: $IPR_DATABASE_NAME" parent_folder="migrations" -mapfile -t subdirs < <(find "$parent_folder" -mindepth 1 -maxdepth 1 -type d) +run_migration() { + local migration_path=$1 + echo "$migration_path" + npx sequelize-cli db:migrate --migrations-path "$migration_path" --url "postgres://$IPR_SERVICE_USER:$IPR_SERVICE_PASS@$IPR_DATABASE_SERVER/$IPR_DATABASE_NAME" +} -for dir in "${subdirs[@]}"; do - echo "$dir" - npx sequelize-cli db:migrate --migrations-path "$dir" --url "postgres://$IPR_SERVICE_USER:$IPR_SERVICE_PASS@$IPR_DATABASE_SERVER/$IPR_DATABASE_NAME" +# Handle latest migrations (check for batch subdirectories first) +if [ -d "$parent_folder/latest" ]; then + latest_batches=$(find "$parent_folder/latest" -maxdepth 1 -type d ! -name "latest" | sort) + if [ -n "$latest_batches" ]; then + # Run batch migrations in order + while IFS= read -r batch_dir; do + run_migration "$batch_dir" + done <<< "$latest_batches" + fi + # Also run non-batched migrations in latest (if any exist) + if [ -n "$(find "$parent_folder/latest" -maxdepth 1 -name '*.js' -type f)" ]; then + run_migration "$parent_folder/latest" + fi +fi + +# Handle other migration directories (legacy, etc.) +mapfile -t other_dirs < <(find "$parent_folder" -mindepth 1 -maxdepth 1 -type d ! -name "latest") + +for dir in "${other_dirs[@]}"; do + run_migration "$dir" done From bab16a3ad20ab7594d80c6dcface7a371e8997db Mon Sep 17 00:00:00 2001 From: kttkjl Date: Tue, 28 Jul 2026 13:47:53 -0700 Subject: [PATCH 21/25] chore: separate legend creation migration into first release DEVSU-2310 --- ...21000000-DEVSU-2310-create-legend-table.js | 2 +- ...-2310-update-pathway-analysis-legend-fk.js | 54 ------------------- ...01-add-unique-default-legend-constraint.js | 19 ------- 3 files changed, 1 insertion(+), 74 deletions(-) rename migrations/latest/{batch1 => }/20260421000000-DEVSU-2310-create-legend-table.js (93%) delete mode 100644 migrations/latest/batch2/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js delete mode 100644 migrations/latest/batch2/20260616000001-add-unique-default-legend-constraint.js diff --git a/migrations/latest/batch1/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js similarity index 93% rename from migrations/latest/batch1/20260421000000-DEVSU-2310-create-legend-table.js rename to migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js index ac53c4f8f..ebc48e01c 100644 --- a/migrations/latest/batch1/20260421000000-DEVSU-2310-create-legend-table.js +++ b/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js @@ -1,5 +1,5 @@ const TABLE = 'pathway_analysis_legends'; -const {DEFAULT_COLUMNS} = require('../../../app/models/base'); +const {DEFAULT_COLUMNS} = require('../../app/models/base'); module.exports = { up: (queryInterface, Sq) => { diff --git a/migrations/latest/batch2/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js b/migrations/latest/batch2/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js deleted file mode 100644 index 41d8d1e88..000000000 --- a/migrations/latest/batch2/20260421000001-DEVSU-2310-update-pathway-analysis-legend-fk.js +++ /dev/null @@ -1,54 +0,0 @@ -const TABLE = 'reports_summary_pathway_analysis'; - -module.exports = { - up: (queryInterface, Sq) => { - return queryInterface.sequelize.transaction(async (transaction) => { - // Add the new legend_id column first - await queryInterface.addColumn(TABLE, 'legend_id', { - type: Sq.INTEGER, - references: { - model: 'pathway_analysis_legends', - key: 'id', - }, - onDelete: 'SET NULL', - onUpdate: 'CASCADE', - allowNull: true, - }, {transaction}); - - // Datafix: map existing legend values to legend_id using legends table - await queryInterface.sequelize.query( - `UPDATE "${TABLE}" SET legend_id = l.id FROM pathway_analysis_legends l WHERE "${TABLE}".legend::text = l.name;`, - {transaction}, - ); - - // Remove the existing ENUM column - await queryInterface.removeColumn(TABLE, 'legend', {transaction}); - - // Drop the ENUM type created by Sequelize - await queryInterface.sequelize.query( - 'DROP TYPE IF EXISTS "enum_reports_summary_pathway_analysis_legend";', - {transaction}, - ); - }); - }, - - down: (queryInterface, Sq) => { - return queryInterface.sequelize.transaction(async (transaction) => { - // Re-add the original ENUM column - await queryInterface.addColumn(TABLE, 'legend', { - type: Sq.ENUM(['v1', 'v2', 'v3', 'custom']), - allowNull: false, - defaultValue: 'v3', - }, {transaction}); - - // Datafix: restore legend enum value from linked legend name where possible - await queryInterface.sequelize.query( - `UPDATE "${TABLE}" SET legend = l.name::"enum_reports_summary_pathway_analysis_legend" FROM pathway_analysis_legends l WHERE "${TABLE}".legend_id = l.id AND l.name IN ('v1', 'v2', 'v3', 'custom');`, - {transaction}, - ); - - // Remove the foreign key column - await queryInterface.removeColumn(TABLE, 'legend_id', {transaction}); - }); - }, -}; diff --git a/migrations/latest/batch2/20260616000001-add-unique-default-legend-constraint.js b/migrations/latest/batch2/20260616000001-add-unique-default-legend-constraint.js deleted file mode 100644 index 4132c4aef..000000000 --- a/migrations/latest/batch2/20260616000001-add-unique-default-legend-constraint.js +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - up: async (queryInterface) => { - // Add unique partial index to enforce only one default legend globally - await queryInterface.addIndex('pathway_analysis_legends', { - fields: ['default'], - where: { - default: true, - deleted_at: null, - }, - unique: true, - name: 'idx_one_default_legend', - }); - }, - - down: async (queryInterface) => { - // Remove the index if rollback is needed - await queryInterface.removeIndex('pathway_analysis_legends', 'idx_one_default_legend'); - }, -}; From 219d0e68bb22375ff6510323ee8115bbbf4ed5b8 Mon Sep 17 00:00:00 2001 From: kttkjl Date: Tue, 28 Jul 2026 17:16:19 -0700 Subject: [PATCH 22/25] chore: comment out part 2 migration to pass tests for now DEVSU-2310 --- app/models/index.js | 20 +++-- .../genomic/summary/pathwayAnalysis.js | 19 ++-- .../report/summary/pathwayAnalysis.test.js | 90 ++++++++++--------- 3 files changed, 71 insertions(+), 58 deletions(-) diff --git a/app/models/index.js b/app/models/index.js index 40299b56b..0ef38f099 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -134,14 +134,18 @@ summary.therapeuticTargets = require('./reports/genomic/summary/therapeuticTarge summary.microbial = require('./reports/genomic/summary/microbial')(sequelize, Sq); // Pathway Analysis Legends -const pathwayAnalysisLegends = require('./legend/legend')(sequelize, Sq); - -summary.pathwayAnalysis.belongsTo(pathwayAnalysisLegends, { - as: 'legend', foreignKey: 'legendId', targetKey: 'id', onDelete: 'SET NULL', constraints: true, -}); -pathwayAnalysisLegends.hasMany(summary.pathwayAnalysis, { - as: 'pathwayAnalyses', foreignKey: 'legendId', onDelete: 'SET NULL', constraints: true, -}); +// DEVSU-2310 batch 2: restore `const pathwayAnalysisLegends =` when re-enabling the associations below +require('./legend/legend')(sequelize, Sq); + +// DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration. +// These associations define the legendId attribute on pathwayAnalysis, so they must +// stay commented out alongside the model attribute until legend_id exists. +// summary.pathwayAnalysis.belongsTo(pathwayAnalysisLegends, { +// as: 'legend', foreignKey: 'legendId', targetKey: 'id', onDelete: 'SET NULL', constraints: true, +// }); +// pathwayAnalysisLegends.hasMany(summary.pathwayAnalysis, { +// as: 'pathwayAnalyses', foreignKey: 'legendId', onDelete: 'SET NULL', constraints: true, +// }); analysisReports.belongsTo(user, { as: 'createdBy', foreignKey: 'createdBy_id', targetKey: 'id', onDelete: 'SET NULL', controlled: true, diff --git a/app/models/reports/genomic/summary/pathwayAnalysis.js b/app/models/reports/genomic/summary/pathwayAnalysis.js index 52f6a5826..d3a44d74c 100644 --- a/app/models/reports/genomic/summary/pathwayAnalysis.js +++ b/app/models/reports/genomic/summary/pathwayAnalysis.js @@ -12,15 +12,16 @@ module.exports = (sequelize, Sq) => { key: 'id', }, }, - legendId: { - name: 'legendId', - field: 'legend_id', - type: Sq.INTEGER, - references: { - model: 'pathway_analysis_legends', - key: 'id', - }, - }, + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + // legendId: { + // name: 'legendId', + // field: 'legend_id', + // type: Sq.INTEGER, + // references: { + // model: 'pathway_analysis_legends', + // key: 'id', + // }, + // }, pathway: { type: Sq.TEXT, allowNull: true, diff --git a/test/routes/report/summary/pathwayAnalysis.test.js b/test/routes/report/summary/pathwayAnalysis.test.js index aa170cb7b..4c469997f 100644 --- a/test/routes/report/summary/pathwayAnalysis.test.js +++ b/test/routes/report/summary/pathwayAnalysis.test.js @@ -14,6 +14,8 @@ let request; const pathwayProperties = ['ident', 'createdAt', 'updatedAt', 'pathway', 'legendId']; +// DEVSU-2310 batch 2: only used by the tests marked test.todo below; drop this disable when they return +// eslint-disable-next-line no-unused-vars const checkPathwayAnalysis = (pathwayObject) => { pathwayProperties.forEach((element) => { expect(pathwayObject).toHaveProperty(element); @@ -65,16 +67,18 @@ describe('/reports/{report}/summary/pathway-analysis', () => { await db.models.pathwayAnalysis.destroy({where: {ident: pathwayAnalysis.ident}, force: true}); }); - test('/ - 200 Success', async () => { - const res = await request - .get(`/api/reports/${report.ident}/summary/pathway-analysis`) - .auth(username, password) - .type('json') - .expect(HTTP_STATUS.OK); - - checkPathwayAnalysis(res.body); - expect(res.body.ident).toBe(pathwayAnalysis.ident); - }); + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + test.todo('/ - 200 Success'); + // test('/ - 200 Success', async () => { + // const res = await request + // .get(`/api/reports/${report.ident}/summary/pathway-analysis`) + // .auth(username, password) + // .type('json') + // .expect(HTTP_STATUS.OK); + // + // checkPathwayAnalysis(res.body); + // expect(res.body.ident).toBe(pathwayAnalysis.ident); + // }); }); describe('PUT', () => { @@ -91,20 +95,22 @@ describe('/reports/{report}/summary/pathway-analysis', () => { await db.models.pathwayAnalysis.destroy({where: {ident: pathwayAnalysis.ident}, force: true}); }); - test('/ - 200 Success', async () => { - const res = await request - .put(`/api/reports/${report.ident}/summary/pathway-analysis`) - .auth(username, password) - .type('json') - .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', legend.id) - .expect(HTTP_STATUS.OK); - - checkPathwayAnalysis(res.body); - - expect(res.body.pathway).not.toBeNull(); - expect(res.body.legendId).toBe(legend.id); - }); + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + test.todo('/ - 200 Success'); + // test('/ - 200 Success', async () => { + // const res = await request + // .put(`/api/reports/${report.ident}/summary/pathway-analysis`) + // .auth(username, password) + // .type('json') + // .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') + // .field('legendId', legend.id) + // .expect(HTTP_STATUS.OK); + // + // checkPathwayAnalysis(res.body); + // + // expect(res.body.pathway).not.toBeNull(); + // expect(res.body.legendId).toBe(legend.id); + // }); test('/ - 400 Bad request - Invalid legend fk', async () => { await request @@ -175,23 +181,25 @@ describe('/reports/{report}/summary/pathway-analysis', () => { }); describe('POST', () => { - test('/ - 201 Created', async () => { - const res = await request - .post(`/api/reports/${report.ident}/summary/pathway-analysis`) - .auth(username, password) - .type('json') - .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legendId', legend.id) - .expect(HTTP_STATUS.CREATED); - - checkPathwayAnalysis(res.body); - - expect(res.body.pathway).not.toBeNull(); - expect(res.body.legendId).toBe(legend.id); - - // Remove pathway analysis - await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); - }); + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + test.todo('/ - 201 Created'); + // test('/ - 201 Created', async () => { + // const res = await request + // .post(`/api/reports/${report.ident}/summary/pathway-analysis`) + // .auth(username, password) + // .type('json') + // .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') + // .field('legendId', legend.id) + // .expect(HTTP_STATUS.CREATED); + // + // checkPathwayAnalysis(res.body); + // + // expect(res.body.pathway).not.toBeNull(); + // expect(res.body.legendId).toBe(legend.id); + // + // // Remove pathway analysis + // await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); + // }); test('/ - 400 Bad request - Invalid legend id', async () => { await request From 8595688dfae06873a852fd6c2d85dec569bde1b0 Mon Sep 17 00:00:00 2001 From: kttkjl Date: Wed, 29 Jul 2026 14:50:25 -0700 Subject: [PATCH 23/25] 8.6.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2fd5e8df5..1d60f9ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ipr-api", - "version": "8.5.0", + "version": "8.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ipr-api", - "version": "8.4.0", + "version": "8.6.0", "license": "GPL-3.0", "dependencies": { "@alt3/sequelize-to-json-schemas": "^0.3.56", diff --git a/package.json b/package.json index 7c3386d27..24c683f8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "ipr-api", - "version": "8.5.0", + "version": "8.6.0", "description": "Integrated Pipeline Reports API", "main": "bin/server.js", "scripts": { From 05eb2451a7c779b54a71cda613c80c8fe4c9cede Mon Sep 17 00:00:00 2001 From: kttkjl Date: Wed, 29 Jul 2026 14:50:25 -0700 Subject: [PATCH 24/25] bump to v8.6.0 --- .../20260421000000-DEVSU-2310-create-legend-table.js | 0 ...0260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js | 0 .../{latest => v8.6.0}/20260519215459-DEVSU-2928-datafix-seqqc.js | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename migrations/{latest => v8.6.0}/20260421000000-DEVSU-2310-create-legend-table.js (100%) rename migrations/{latest => v8.6.0}/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js (100%) rename migrations/{latest => v8.6.0}/20260519215459-DEVSU-2928-datafix-seqqc.js (100%) diff --git a/migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/v8.6.0/20260421000000-DEVSU-2310-create-legend-table.js similarity index 100% rename from migrations/latest/20260421000000-DEVSU-2310-create-legend-table.js rename to migrations/v8.6.0/20260421000000-DEVSU-2310-create-legend-table.js diff --git a/migrations/latest/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js b/migrations/v8.6.0/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js similarity index 100% rename from migrations/latest/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js rename to migrations/v8.6.0/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js diff --git a/migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js b/migrations/v8.6.0/20260519215459-DEVSU-2928-datafix-seqqc.js similarity index 100% rename from migrations/latest/20260519215459-DEVSU-2928-datafix-seqqc.js rename to migrations/v8.6.0/20260519215459-DEVSU-2928-datafix-seqqc.js From 84b9f3c0b783aee727a738be45bf4d5926a862f2 Mon Sep 17 00:00:00 2001 From: kttkjl Date: Fri, 31 Jul 2026 16:53:24 -0700 Subject: [PATCH 25/25] chore: update demo dump no-ticket --- demo/ipr_demodb.postgres.dump | Bin 30440143 -> 30440524 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/demo/ipr_demodb.postgres.dump b/demo/ipr_demodb.postgres.dump index bde88c4d55e83f7dd8c4b6c1ef8fdbc6bce0b719..da2bcd22d605ddc80e8f6d04ddeaf8e21833697d 100644 GIT binary patch delta 10071 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