- {{ version }}
+
{{
+ version
+ }}
+
{{ version }}
diff --git a/lib/nemsis/emsXsdParser.js b/lib/nemsis/emsXsdParser.js
index ca229f5e..9e5f020d 100644
--- a/lib/nemsis/emsXsdParser.js
+++ b/lib/nemsis/emsXsdParser.js
@@ -3,85 +3,121 @@ const path = require('path');
const { XmlParser } = require('./xmlParser');
class NemsisEmsXsdParser extends XmlParser {
- onStartElement(name, attr) {
- if (name === 'xs:include') {
+ getCurrentXsdPath() {
+ return `/${this.stack
+ .map((s) => {
+ let component = s.element;
+ if (s.attr.id) {
+ component += `[@id='${s.attr.id}']`;
+ } else if (s.attr.name) {
+ component += `[@name='${s.attr.name}']`;
+ }
+ return component;
+ })
+ .join('/')}`;
+ }
+
+ getCurrentXmlPath() {
+ return (
+ this.xmlXPathPrefix +
+ this.nodes.map((n) => `${n.name}${n.maxOccurs === null || (Number.isInteger(n.maxOccurs) && n.maxOccurs > 1) ? `[]` : ''}`).join('/')
+ );
+ }
+
+ onStartElement(element, attr) {
+ this.stack.push({ xsd: this.xsd, element, attr });
+ if (element === 'xs:include') {
this.includes.push(attr.schemaLocation);
- }
- if (this.stack.length === 0) {
+ } else if (this.nodes.length === 0) {
// watch for start element
- if (attr.name.startsWith(this.startElement.name)) {
- this.stack.push(this.startElement);
+ if (attr.name?.startsWith(this.startingNode.name)) {
+ this.nodes.push(this.startingNode);
+ this.stack[this.stack.length - 1].node = this.startingNode;
+ this.startingNode.xsdPath = this.getCurrentXsdPath();
+ this.startingNode.xmlPath = this.getCurrentXmlPath();
}
- } else if (name === 'xs:element') {
+ } else if (element === 'xs:element') {
const minOccurs = parseInt(attr.minOccurs ?? '1', 10);
const maxOccurs = parseInt(attr.maxOccurs ?? '1', 10);
const node = {
- name: attr.name,
xsd: this.xsd,
+ name: attr.name,
minOccurs,
maxOccurs: Number.isNaN(maxOccurs) ? null : maxOccurs,
+ xsdPath: '',
+ xmlPath: '',
children: [],
};
if (attr.nillable) {
node.isNillable = attr.nillable === 'true';
}
- for (let i = this.stack.length - 1; i >= 0; i -= 1) {
- if (this.stack[i].name) {
- this.stack[i].children.push(node);
+ for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
+ if (this.nodes[i].name) {
+ this.nodes[i].children?.push(node);
break;
}
}
- this.stack.push(node);
- } else {
- this.stack.push({ attr });
+ this.nodes.push(node);
+ this.stack[this.stack.length - 1].node = node;
+ node.xsdPath = this.getCurrentXsdPath();
+ node.xmlPath = this.getCurrentXmlPath();
}
}
- onEndElement(name) {
- if (this.stack.length > 0) {
- const { _text } = this.stack[this.stack.length - 1];
- let element;
- for (let i = this.stack.length - 2; i >= 0; i -= 1) {
- if (this.stack[i].name) {
- element = this.stack[i];
- break;
- }
- }
- if ((name === 'definition' || name === 'xs:documentation') && element && _text) {
- element.definition = _text;
- } else if (name === 'name' && element && _text) {
- element.displayName = _text;
- } else if (name === 'national' && element) {
- element.isNational = _text === 'Yes';
- } else if (name === 'state' && element) {
- element.isState = _text === 'Yes';
- } else if (name === 'usage' && element) {
- element.usage = _text;
- }
- delete this.stack[this.stack.length - 1]._text;
- this.stack.pop();
- if (this.stack.length === 0) {
- this.resolve();
+ onEndElement(element) {
+ const { _text } = this.stack[this.stack.length - 1];
+ let node;
+ for (let i = this.stack.length - 2; i >= 0; i -= 1) {
+ if (this.stack[i].node) {
+ ({ node } = this.stack[i]);
+ break;
}
}
+ if ((element === 'definition' || element === 'xs:documentation') && node && _text) {
+ node.definition = _text;
+ } else if (element === 'name' && node && _text) {
+ node.displayName = _text;
+ } else if (element === 'national' && node) {
+ node.isNational = _text === 'Yes';
+ } else if (element === 'state' && node) {
+ node.isState = _text === 'Yes';
+ } else if (element === 'usage' && node) {
+ node.usage = _text;
+ }
+ if (this.stack[this.stack.length - 1].node) {
+ this.nodes.pop();
+ }
+ this.stack.pop();
+ if (this.stack.length === 0) {
+ this.resolve();
+ }
}
async parsePatientCareReport() {
this.includes = [];
+ this.nodes = [];
this.xsd = path.basename(this.filePath);
- this.result = { xsd: this.xsd, name: 'PatientCareReport', children: [] };
- this.startElement = this.result;
+ this.result = {
+ xsd: this.xsd,
+ name: 'PatientCareReport',
+ xsdPath: '',
+ xmlPath: '',
+ children: [],
+ };
+ this.startingNode = this.result;
+ this.xmlXPathPrefix = '/';
await this.parse((_, parser) => {
parser.on('startElement', (name, attr) => this.onStartElement(name, attr));
parser.on('endElement', (name) => this.onEndElement(name));
});
+ this.xmlXPathPrefix = '/PatientCareReport/';
for (const child of this.result.children) {
const xsd = this.includes.find((i) => i.startsWith(child.name));
if (xsd) {
this.filePath = path.resolve(path.dirname(this.filePath), xsd);
this.xsd = xsd;
- this.startElement = child;
- this.startElement.xsd = xsd;
+ this.startingNode = child;
+ this.startingNode.xsd = xsd;
// eslint-disable-next-line no-await-in-loop
await this.parse((_, parser) => {
parser.on('startElement', (name, attr) => this.onStartElement(name, attr));
diff --git a/migrations/20250715014215-fix-interface-screens.js b/migrations/20250715014215-fix-interface-screens.js
new file mode 100644
index 00000000..ff669fe3
--- /dev/null
+++ b/migrations/20250715014215-fix-interface-screens.js
@@ -0,0 +1,45 @@
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.dropTable('interface_screens');
+ await queryInterface.addColumn('screens', 'position', {
+ type: Sequelize.INTEGER,
+ });
+ await queryInterface.sequelize.query('ALTER TABLE "screens" ALTER COLUMN "interface_id" SET NOT NULL;');
+ },
+
+ async down(queryInterface, Sequelize) {
+ await queryInterface.sequelize.query('ALTER TABLE "screens" ALTER COLUMN "interface_id" DROP NOT NULL;');
+ await queryInterface.removeColumn('screens', 'position');
+ await queryInterface.createTable('interface_screens', {
+ id: {
+ allowNull: false,
+ defaultValue: Sequelize.literal('gen_random_uuid()'),
+ primaryKey: true,
+ type: Sequelize.UUID,
+ },
+ interface_id: {
+ type: Sequelize.UUID,
+ references: {
+ model: {
+ tableName: 'interfaces',
+ },
+ key: 'id',
+ },
+ },
+ screen_id: {
+ type: Sequelize.UUID,
+ references: {
+ model: {
+ tableName: 'screens',
+ },
+ key: 'id',
+ },
+ },
+ position: {
+ type: Sequelize.UUID,
+ },
+ });
+ await queryInterface.addIndex('interface_screens', ['interface_id', 'screen_id'], { unique: true });
+ },
+};
diff --git a/migrations/20260329001859-add-name-to-section-elements.js b/migrations/20260329001859-add-name-to-section-elements.js
new file mode 100644
index 00000000..3af2925e
--- /dev/null
+++ b/migrations/20260329001859-add-name-to-section-elements.js
@@ -0,0 +1,12 @@
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn('section_elements', 'name', {
+ type: Sequelize.STRING,
+ });
+ },
+
+ async down(queryInterface, Sequelize) {
+ await queryInterface.removeColumn('section_elements', 'name');
+ },
+};
diff --git a/migrations/20260411172934-add-xsd-and-xml-paths-to-nemsis-elements.js b/migrations/20260411172934-add-xsd-and-xml-paths-to-nemsis-elements.js
new file mode 100644
index 00000000..ea238cab
--- /dev/null
+++ b/migrations/20260411172934-add-xsd-and-xml-paths-to-nemsis-elements.js
@@ -0,0 +1,17 @@
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn('nemsis_elements', 'xsd_path', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ await queryInterface.addColumn('nemsis_elements', 'xml_path', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ },
+
+ async down(queryInterface, Sequelize) {
+ await queryInterface.removeColumn('nemsis_elements', 'xsd_path');
+ await queryInterface.removeColumn('nemsis_elements', 'xml_path');
+ },
+};
diff --git a/models/interface.js b/models/interface.js
index 2feeb89f..5dc312ae 100644
--- a/models/interface.js
+++ b/models/interface.js
@@ -5,8 +5,7 @@ module.exports = (sequelize, DataTypes) => {
static associate(models) {
Interface.belongsTo(models.User, { as: 'createdBy' });
Interface.belongsTo(models.User, { as: 'updatedBy' });
- Interface.hasMany(models.InterfaceScreen, { as: 'interfaceScreens' });
- Interface.hasMany(models.Screen, { as: 'screens' });
+ Interface.hasMany(models.Screen, { as: 'screens', foreignKey: 'interfaceId' });
}
}
Interface.init(
diff --git a/models/interfaceScreen.js b/models/interfaceScreen.js
deleted file mode 100644
index 4be2d630..00000000
--- a/models/interfaceScreen.js
+++ /dev/null
@@ -1,22 +0,0 @@
-const { Model } = require('sequelize');
-
-module.exports = (sequelize, DataTypes) => {
- class InterfaceScreen extends Model {
- static associate(models) {
- InterfaceScreen.belongsTo(models.Interface, { as: 'interface' });
- InterfaceScreen.belongsTo(models.Screen, { as: 'screen' });
- }
- }
- InterfaceScreen.init(
- {
- position: DataTypes.STRING,
- },
- {
- sequelize,
- modelName: 'InterfaceScreen',
- tableName: 'interface_screens',
- underscored: true,
- },
- );
- return InterfaceScreen;
-};
diff --git a/models/nemsisElement.js b/models/nemsisElement.js
index 9ee27574..da77b33e 100644
--- a/models/nemsisElement.js
+++ b/models/nemsisElement.js
@@ -64,6 +64,8 @@ module.exports = (sequelize, DataTypes) => {
nemsisVersion: DataTypes.STRING,
dataSet: DataTypes.STRING,
xsd: DataTypes.STRING,
+ xsdPath: DataTypes.TEXT,
+ xmlPath: DataTypes.TEXT,
name: DataTypes.STRING,
displayName: DataTypes.STRING,
definition: DataTypes.TEXT,
diff --git a/models/screen.js b/models/screen.js
index c6a2806d..8a0792db 100644
--- a/models/screen.js
+++ b/models/screen.js
@@ -4,15 +4,15 @@ module.exports = (sequelize, DataTypes) => {
class Screen extends Model {
static associate(models) {
Screen.belongsTo(models.Interface, { as: 'interface' });
- Screen.hasOne(models.InterfaceScreen, { as: 'interfaceScreen' });
Screen.belongsTo(models.User, { as: 'createdBy' });
Screen.belongsTo(models.User, { as: 'updatedBy' });
- Screen.hasMany(models.Section, { as: 'sections' });
+ Screen.hasMany(models.Section, { as: 'sections', foreignKey: 'screenId' });
}
}
Screen.init(
{
name: DataTypes.STRING,
+ position: DataTypes.INTEGER,
},
{
sequelize,
diff --git a/models/section.js b/models/section.js
index cf4b3651..a9814e52 100644
--- a/models/section.js
+++ b/models/section.js
@@ -6,7 +6,7 @@ module.exports = (sequelize, DataTypes) => {
Section.belongsTo(models.Screen, { as: 'screen' });
Section.belongsTo(models.User, { as: 'createdBy' });
Section.belongsTo(models.User, { as: 'updatedBy' });
- Section.hasMany(models.SectionElement, { as: 'elements' });
+ Section.hasMany(models.SectionElement, { as: 'elements', foreignKey: 'sectionId' });
}
}
Section.init(
diff --git a/models/sectionElement.js b/models/sectionElement.js
index 72650fa6..f0167bd6 100644
--- a/models/sectionElement.js
+++ b/models/sectionElement.js
@@ -15,6 +15,7 @@ module.exports = (sequelize, DataTypes) => {
position: DataTypes.INTEGER,
column: DataTypes.INTEGER,
customId: DataTypes.STRING,
+ name: DataTypes.STRING,
},
{
sequelize,
diff --git a/routes/api/interfaces.js b/routes/api/interfaces.js
index 2452f786..1af4edd5 100644
--- a/routes/api/interfaces.js
+++ b/routes/api/interfaces.js
@@ -35,6 +35,48 @@ router.get(
}),
);
+router.get(
+ '/:id/export',
+ interceptors.requireAdmin,
+ helpers.async(async (req, res) => {
+ const { id } = req.params;
+ const record = await models.Interface.findByPk(id, {
+ include: [
+ {
+ model: models.Screen,
+ as: 'screens',
+ order: [['position', 'ASC']],
+ include: [
+ {
+ model: models.Section,
+ as: 'sections',
+ order: [['position', 'ASC']],
+ include: [
+ {
+ model: models.SectionElement,
+ as: 'elements',
+ order: [['position', 'ASC']],
+ include: [
+ {
+ model: models.NemsisElement,
+ as: 'nemsisElement',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+ if (record) {
+ res.json(record.toJSON());
+ } else {
+ res.status(StatusCodes.NOT_FOUND).end();
+ }
+ }),
+);
+
router.post(
'/',
interceptors.requireAdmin,
diff --git a/routes/api/screens.js b/routes/api/screens.js
index 5a9568c3..dfa19933 100644
--- a/routes/api/screens.js
+++ b/routes/api/screens.js
@@ -45,7 +45,7 @@ router.post(
'/',
interceptors.requireAdmin,
helpers.async(async (req, res) => {
- const record = models.Screen.build(_.pick(req.body, ['interfaceId', 'name']));
+ const record = models.Screen.build(_.pick(req.body, ['interfaceId', 'name', 'position']));
record.createdById = req.user.id;
record.updatedById = req.user.id;
await record.save();
@@ -61,7 +61,7 @@ router.patch(
await models.sequelize.transaction(async (transaction) => {
record = await models.Screen.findByPk(req.params.id, { transaction });
if (record) {
- record.set(_.pick(req.body, ['name']));
+ record.set(_.pick(req.body, ['name', 'position']));
record.updatedById = req.user.id;
await record.save({ transaction });
}
diff --git a/test/unit/lib/nemsis/emsXsdParser.js b/test/unit/lib/nemsis/emsXsdParser.js
index 2629374a..33281279 100644
--- a/test/unit/lib/nemsis/emsXsdParser.js
+++ b/test/unit/lib/nemsis/emsXsdParser.js
@@ -20,6 +20,8 @@ describe('lib', () => {
assert.deepStrictEqual(result.children?.length, 25);
assert.deepStrictEqual(result.children[0].name, 'eRecord');
assert.deepStrictEqual(result.children[0].xsd, 'eRecord_v3.xsd');
+ assert.deepStrictEqual(result.children[0].xsdPath, "/xs:schema/xs:complexType[@id='eRecord.RecordInformation']");
+ assert.deepStrictEqual(result.children[0].xmlPath, '/PatientCareReport/eRecord');
assert.deepStrictEqual(result.children[0].minOccurs, 1);
assert.deepStrictEqual(result.children[0].maxOccurs, 1);
assert.deepStrictEqual(result.children[0].definition, 'Patient Record Information');