Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9462467
- DEVSU-2310
bnguyen-bcgsc Apr 22, 2026
64b740e
Merge pull request #492 from bcgsc/master
bnguyen-bcgsc May 7, 2026
d345a2f
add exon to small mutations model
sshugsc May 13, 2026
1c9d192
Merge branch 'develop' into feature/DEVSU-2914-add-exon-column-to-IPR…
bnguyen-bcgsc May 13, 2026
59015da
Merge branch 'develop' into feature/DEVSU-2310-rework-pathway-analysi…
bnguyen-bcgsc May 19, 2026
da18c15
bugfix: add hook to therapueticTargets on update to remove signatures
kttkjl May 19, 2026
e076d40
add migration to move seqqc data from reports to reports_seq_qc
sshugsc May 20, 2026
066c85d
remove legacy seq_qc column in report model
sshugsc May 20, 2026
53ce3ef
lint
sshugsc May 20, 2026
e0d1642
remove legacy seqQc
sshugsc May 20, 2026
4438b7b
add updated_by to user and metadata table
sshugsc May 21, 2026
0456157
use userId in userMetadata update variable; add test
sshugsc May 21, 2026
1e8db5c
Merge pull request #493 from bcgsc/feature/DEVSU-2914-add-exon-column…
sshugsc May 29, 2026
0af2472
Merge branch 'develop' into feature/DEVSU-2928-datafix-seqqc
sshugsc May 29, 2026
e22105e
Merge pull request #495 from bcgsc/feature/DEVSU-2928-datafix-seqqc
sshugsc Jun 1, 2026
996f9d8
Merge branch 'develop' into Bugfix/DEVSU-2931-updated_by-is-not-getti…
sshugsc Jun 1, 2026
54a6ad7
Merge branch 'develop' into bugfix/DEVSU-2953-to-table-sig-remove-on-…
bnguyen-bcgsc Jun 1, 2026
f8065da
Merge pull request #496 from bcgsc/Bugfix/DEVSU-2931-updated_by-is-no…
sshugsc Jun 1, 2026
68a57bd
Merge branch 'develop' into feature/DEVSU-2310-rework-pathway-analysi…
bnguyen-bcgsc Jun 3, 2026
4349abf
Merge branch 'develop' into bugfix/DEVSU-2953-to-table-sig-remove-on-…
kttkjl Jun 10, 2026
fbe0b74
Merge pull request #494 from bcgsc/bugfix/DEVSU-2953-to-table-sig-rem…
kttkjl Jun 10, 2026
176cbc3
- Update legend model and file structure to be standalone table not b…
bnguyen-bcgsc Jun 17, 2026
2f72e23
- Update swagger documentation and mockReportData
bnguyen-bcgsc Jun 17, 2026
d40e727
- Move migration files to latest folder
bnguyen-bcgsc Jun 17, 2026
af9b7d8
Merge branch 'develop' into feature/DEVSU-2310-rework-pathway-analysi…
bnguyen-bcgsc Jun 17, 2026
e769fb9
- Add image type SVG to legend format enum
bnguyen-bcgsc Jun 19, 2026
feeeedf
- Update pathwayAnalysis unit test
bnguyen-bcgsc Jun 19, 2026
e9b714d
- Remove legends from mockReportData json
bnguyen-bcgsc Jun 19, 2026
469b640
- Update model to enforce default value
bnguyen-bcgsc Jun 29, 2026
ea763a7
feat: add image data to PUT path
kttkjl Jul 15, 2026
652e94f
Merge pull request #500 from bcgsc/feature/DEVSU-2310-fix-legend-put-…
elewis2 Jul 20, 2026
943d8bc
chore: move 2310 migration into two batches
kttkjl Jul 28, 2026
723ec2f
feat: add route to query by legend-id
kttkjl Jul 28, 2026
fb6f2af
feat: update migration script to account for subfolders in latest
kttkjl Jul 28, 2026
bab16a3
chore: separate legend creation migration into first release
kttkjl Jul 28, 2026
219d0e6
chore: comment out part 2 migration to pass tests for now
kttkjl Jul 29, 2026
649bbde
Merge pull request #498 from bcgsc/feature/DEVSU-2310-rework-pathway-…
kttkjl Jul 29, 2026
8595688
8.6.0
kttkjl Jul 29, 2026
05eb245
bump to v8.6.0
kttkjl Jul 29, 2026
84b9f3c
chore: update demo dump
kttkjl Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions app/middleware/acl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()}, {userId: req.user.id});
}

try {
Expand Down
14 changes: 14 additions & 0 deletions app/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ 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
// 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,
});
Expand Down
123 changes: 123 additions & 0 deletions app/models/legend/legend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const {DEFAULT_COLUMNS} = require('../base');

module.exports = (sequelize, Sq) => {
const legend = sequelize.define(
'legend',
{
...DEFAULT_COLUMNS,
format: {
type: Sq.ENUM('PNG', 'JPG', 'SVG'),
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,
set(value) {
this.setDataValue('default', value === true || value === 'true');
},
},
},
{
tableName: 'pathway_analysis_legends',
indexes: [
{
unique: true,
fields: ['default'],
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: {
default: true,
},
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: {
default: true,
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.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}, {transaction});
}
}
};

legend.prototype.ensureDefaultExists = async function (options = {}) {
return legend.ensureDefaultExists(options);
};

return legend;
};
15 changes: 10 additions & 5 deletions app/models/reports/genomic/summary/pathwayAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ module.exports = (sequelize, Sq) => {
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,
Expand All @@ -20,11 +30,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',
Expand Down
14 changes: 0 additions & 14 deletions app/models/reports/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
3 changes: 3 additions & 0 deletions app/models/reports/smallMutations.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ module.exports = (sequelize, Sq) => {
field: 'tumour_ref_copies',
type: Sq.INTEGER,
},
exon: {
type: Sq.TEXT,
},
library: {
type: Sq.TEXT,
},
Expand Down
4 changes: 4 additions & 0 deletions app/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
}
Expand Down
145 changes: 145 additions & 0 deletions app/routes/legend/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
const HTTP_STATUS = require('http-status-codes');
const express = require('express');

const db = require('../../models');
const logger = require('../../log');
const {uploadLegendImage, updateLegendImage} = 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.message}`);
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) => {
// 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) => {
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}`);
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
.json({error: {message: 'Error while updating legend image'}});
}
})
.delete(async (req, res) => {
const force = (req.query.force === 'true');
const wasDefault = req.legend.default;

try {
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'}});
}
});

// 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) => {
try {
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.message}`);
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 = [];
for (const [key, image] of Object.entries(req.files)) {
try {
// Set options (value or undefined)
const options = {
filename: image.name.trim(),
name: req.body.name || image.name.trim(),
default: req.body.default,
};

// Load image
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: {message: error.message}});
}
}
return res.status(HTTP_STATUS.MULTI_STATUS).json(results);
} catch (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}`}});
}
});

module.exports = router;
2 changes: 1 addition & 1 deletion app/routes/report/appendices.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading