fhirsmith 0.18.1 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -9
- package/ips/ips.js +251 -26
- package/ips/jurisdictions.js +65 -0
- package/ips/pipeline.js +67 -6
- package/ips/profile-editor.js +283 -4
- package/ips/readme.md +48 -5
- package/ips/render.js +317 -46
- package/ips/scripts.js +46 -0
- package/ips/store.js +55 -0
- package/package.json +3 -3
- package/registry/api.js +179 -1
- package/registry/crawler.js +2 -0
- package/registry/documentation/api.md +68 -2
- package/registry/documentation/internals.md +4 -1
- package/registry/documentation/readme.md +3 -2
- package/registry/model.js +8 -0
- package/registry/openapi.yaml +84 -0
- package/registry/registry.js +53 -2
- package/tx/README.md +4 -0
- package/tx/cm/cm-package.js +21 -15
- package/tx/cs/cs-cs.js +51 -10
- package/tx/cs/cs-loinc.js +8 -1
- package/tx/cs/cs-snomed.js +253 -31
- package/tx/importers/import-sct.module.js +69 -4
- package/tx/library/tho-precedence.js +40 -0
- package/tx/provider.js +36 -25
- package/tx/sct/documentation.md +45 -21
- package/tx/sct/ecl.js +34 -11
- package/tx/sct/structures.js +9 -2
- package/tx/tx.fhir.org.yml +1 -1
- package/tx/vs/vs-package.js +17 -13
- package/tx/vs/vs-vsac.js +1 -1
package/ips/render.js
CHANGED
|
@@ -26,7 +26,10 @@ const { STATUSES } = require('./search');
|
|
|
26
26
|
const profiles = require('./profiles');
|
|
27
27
|
const { terminologyOf, systemName, describeVersion, linkForSystem, linkForCode, isExternalSystem } = require('./terminology');
|
|
28
28
|
const { issueSummary, checkOf, translationOf } = require('./pipeline');
|
|
29
|
+
const { jurisdiction } = require('./jurisdictions');
|
|
29
30
|
const { dateText } = require('./presenter');
|
|
31
|
+
const scripts = require('./scripts');
|
|
32
|
+
const { LOINC } = require('./editor-schema');
|
|
30
33
|
|
|
31
34
|
const STATUS_COLOURS = {
|
|
32
35
|
submitted: '#6c757d',
|
|
@@ -114,6 +117,27 @@ const STYLE = `
|
|
|
114
117
|
.ips-pf-bad { border-color: #c82333 !important; }
|
|
115
118
|
.ips-pf-rule { width: 100%; table-layout: fixed; }
|
|
116
119
|
.ips-pf-rule td { vertical-align: top; overflow-wrap: anywhere; }
|
|
120
|
+
.ips-pf-rule .ips-pf-actions { width: 7em; white-space: nowrap; text-align: right; }
|
|
121
|
+
.ips-pf-rule td.ips-pf-actions { padding-top: 1px; padding-bottom: 1px; }
|
|
122
|
+
.ips-pf-part-head { display: flex; align-items: baseline; gap: 12px; margin-top: 18px; }
|
|
123
|
+
.ips-pf-part-head h4 { margin: 0 0 6px; }
|
|
124
|
+
.ips-pf-part-head .ips-pf-add { font-size: 80%; padding: 0 8px; line-height: 1.6; }
|
|
125
|
+
.ips-pf-rule .ips-pf-actions .btn { font-size: 75%; padding: 0 5px; line-height: 1.5; border-radius: 3px; vertical-align: baseline; }
|
|
126
|
+
.ips-pf-how { align-items: center; gap: 8px; margin-top: 6px; } .ips-pf-how input { width: 7em; flex: none; } .ips-pf-how .ips-pf-hint { margin: 0; }
|
|
127
|
+
.ips-pf-dialog { width: min(760px, 95vw); max-height: 90vh; padding: 0; border: 1px solid #bbb; border-radius: 6px; box-shadow: 0 8px 30px rgba(0,0,0,0.3); }
|
|
128
|
+
.ips-pf-dialog::backdrop { background: rgba(0,0,0,0.35); }
|
|
129
|
+
.ips-pf-dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid #dee2e6; background: #f8f9fa; }
|
|
130
|
+
.ips-pf-dialog-head h4 { margin: 0; }
|
|
131
|
+
.ips-pf-dialog-x { border: none; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: #666; }
|
|
132
|
+
.ips-pf-dialog-body { padding: 12px 16px; overflow: visible; }
|
|
133
|
+
.ips-pf-dialog-foot { padding: 10px 16px; border-top: 1px solid #dee2e6; text-align: right; }
|
|
134
|
+
.ips-pf-field { margin-bottom: 14px; }
|
|
135
|
+
.ips-pf-field > .ips-pf-label { display: block; font-weight: bold; margin-bottom: 4px; }
|
|
136
|
+
.ips-pf-field .ips-ed-input, .ips-pf-field textarea { width: 100%; box-sizing: border-box; }
|
|
137
|
+
.ips-pf-field > * + * { margin-top: 4px; }
|
|
138
|
+
.ips-pf-field .ips-pf-pair .ips-ed-input { width: auto; flex: 1; }
|
|
139
|
+
.ips-pf-field .ips-pf-how .ips-ed-input { width: 7em; flex: none; }
|
|
140
|
+
.ips-pf-hint { font-size: 85%; color: #6c757d; }
|
|
117
141
|
.ips-pf-rule .ips-pf-card-text { white-space: nowrap; }
|
|
118
142
|
.ips-ed-section { border: 1px solid #dee2e6; border-radius: 4px; margin-bottom: 8px; }
|
|
119
143
|
.ips-ed-section-required { border-color: #b8c4e8; }
|
|
@@ -241,6 +265,7 @@ function userBar(ctx) {
|
|
|
241
265
|
parts.push(`<a href="${base}/admin/projects">Projects</a>`);
|
|
242
266
|
parts.push(`<a href="${base}/admin/users">Users</a>`);
|
|
243
267
|
parts.push(`<a href="${base}/StructureDefinition">Clinical profiles</a>`);
|
|
268
|
+
parts.push(`<a href="${base}/national-profiles">National profiles</a>`);
|
|
244
269
|
}
|
|
245
270
|
parts.push(`<form method="post" action="${base}/logout" class="ips-inline">${csrfField(ctx.csrf)}<button type="submit" class="btn btn-link" style="padding: 0; font-size: 100%; vertical-align: baseline">Logout</button></form>`);
|
|
246
271
|
return `<div style="float: right; font-size: 85%">${parts.join(' | ')}</div>`;
|
|
@@ -272,7 +297,8 @@ function renderHome(projects, ctx) {
|
|
|
272
297
|
let h = STYLE + userBar(ctx);
|
|
273
298
|
h += '<p>Each project is a collection of International Patient Summary documents, with its own FHIR endpoint ' +
|
|
274
299
|
'(<code>POST</code> / <code>GET {project}/Bundle</code>) and web pages. ' +
|
|
275
|
-
`<a href="${escape(ctx.base)}/StructureDefinition">Clinical profiles</a> say what kinds of clinical document are expected to contain
|
|
300
|
+
`<a href="${escape(ctx.base)}/StructureDefinition">Clinical profiles</a> say what kinds of clinical document are expected to contain, and ` +
|
|
301
|
+
`<a href="${escape(ctx.base)}/national-profiles">national profiles</a> are the IPS document profiles that national implementation guides define.</p>`;
|
|
276
302
|
if (projects.length === 0) {
|
|
277
303
|
return h + '<p>There are no projects yet' + (ctx.user?.isAdmin ? `: <a href="${escape(ctx.base)}/admin/projects">create one</a>` : '') + '.</p>';
|
|
278
304
|
}
|
|
@@ -1074,9 +1100,21 @@ function renderValidation(row, options = {}) {
|
|
|
1074
1100
|
if (!r || (r.result.status && r.result.status !== 'done')) {
|
|
1075
1101
|
return noResultNote(row, 'validation', 'There is no validation result for this document', options);
|
|
1076
1102
|
}
|
|
1077
|
-
|
|
1103
|
+
return validationOutcome(r.made, r.result, { ...options, hidden: { view: 'validation' } });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* What the validator made of the document, once: when, with what, the issue counts, the
|
|
1108
|
+
* issues (as filtered by options.level) and the OperationOutcome. The validation view and
|
|
1109
|
+
* each national profile's page show it.
|
|
1110
|
+
* @param {string} made - when the validation was done
|
|
1111
|
+
* @param {{counts?: Object, outcome?: Object, profiles?: string[], validatorVersion?: string, ms?: number}} v
|
|
1112
|
+
* @param {{level?: string, docUrl?: string, hidden?: Object}} options - hidden: the fields the
|
|
1113
|
+
* level form keeps (the view, and which national profile)
|
|
1114
|
+
*/
|
|
1115
|
+
function validationOutcome(made, v, options = {}) {
|
|
1078
1116
|
const counts = v.counts || {};
|
|
1079
|
-
let h = `<p class="ips-small">Validated ${escape(
|
|
1117
|
+
let h = `<p class="ips-small">Validated ${escape(made)}` +
|
|
1080
1118
|
(v.validatorVersion ? ` with validator ${escape(String(v.validatorVersion))}` : '') +
|
|
1081
1119
|
(Array.isArray(v.profiles) && v.profiles.length ? ` against ${v.profiles.map(p => `<code>${escape(p)}</code>`).join(', ')}` : '') +
|
|
1082
1120
|
(typeof v.ms === 'number' ? ` in ${escape(String(v.ms))} ms` : '') + '</p>';
|
|
@@ -1089,7 +1127,8 @@ function renderValidation(row, options = {}) {
|
|
|
1089
1127
|
const min = SEVERITY_RANK[level];
|
|
1090
1128
|
const issues = all.filter(i => (SEVERITY_RANK[text(i.severity)] ?? 3) >= min);
|
|
1091
1129
|
// the minimum level: a form that submits itself on change (and has a button without script)
|
|
1092
|
-
|
|
1130
|
+
const hidden = Object.entries(options.hidden || { view: 'validation' }).map(([k, val]) => `<input type="hidden" name="${escape(k)}" value="${escape(String(val))}"/>`).join('');
|
|
1131
|
+
h += `<form method="get" action="${escape(options.docUrl || '')}" class="ips-filter mb-2">${hidden}` +
|
|
1093
1132
|
'<label for="f-level">Show</label> <select id="f-level" name="level" onchange="this.form.submit()">' +
|
|
1094
1133
|
LEVELS.map(([l, label]) => `<option value="${l}"${l === level ? ' selected' : ''}>${escape(label)}</option>`).join('') +
|
|
1095
1134
|
'</select><noscript><button type="submit" class="btn btn-default ips-btn">Apply</button></noscript>';
|
|
@@ -1121,6 +1160,58 @@ document.querySelectorAll('.ips-oo-toggle').forEach(function (b) {
|
|
|
1121
1160
|
return h;
|
|
1122
1161
|
}
|
|
1123
1162
|
|
|
1163
|
+
// a national profile's outcome on the page: [label, colour]
|
|
1164
|
+
const NATIONAL_OUTCOMES = {
|
|
1165
|
+
pass: ['Passed', '#1e7e34'],
|
|
1166
|
+
fail: ['Failed', '#c82333'],
|
|
1167
|
+
unknown: ['Not checked', '#d39e00']
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* The national profiles step's result: the list of national profiles, and whether the
|
|
1172
|
+
* document passed each (no errors); with options.np (an index into the list), that
|
|
1173
|
+
* profile's validation, shown as the validation view shows the base validation.
|
|
1174
|
+
* @param {Object} row - from store.read(), with .results.national or not
|
|
1175
|
+
* @param {{np?: string, level?: string, docUrl?: string, base?: string, canManage?: boolean}} [options]
|
|
1176
|
+
*/
|
|
1177
|
+
function renderNational(row, options = {}) {
|
|
1178
|
+
const r = row.results?.national;
|
|
1179
|
+
if (!r || (r.result.status && r.result.status !== 'done') || !Array.isArray(r.result.profiles)) {
|
|
1180
|
+
return noResultNote(row, 'national', 'The document has not been validated against the national profiles', options);
|
|
1181
|
+
}
|
|
1182
|
+
const v = r.result;
|
|
1183
|
+
const base = escape(options.base || '');
|
|
1184
|
+
const docUrl = escape(options.docUrl || '');
|
|
1185
|
+
if (v.profiles.length === 0) {
|
|
1186
|
+
return `<p>There were no <a href="${base}/national-profiles">national profiles</a> to validate the document against when it was processed.</p>`;
|
|
1187
|
+
}
|
|
1188
|
+
const outcome = (p) => NATIONAL_OUTCOMES[p.pass === true ? 'pass' : p.pass === false ? 'fail' : 'unknown'];
|
|
1189
|
+
const index = typeof options.np === 'string' && /^\d{1,4}$/.test(options.np) ? parseInt(options.np, 10) : -1;
|
|
1190
|
+
const p = index >= 0 ? v.profiles[index] : null;
|
|
1191
|
+
if (p) {
|
|
1192
|
+
const [label, colour] = outcome(p);
|
|
1193
|
+
let h = `<p class="ips-small"><a href="${docUrl}?view=national">National profiles</a> » ${escape(`${p.packageId}#${p.version}`)}</p>`;
|
|
1194
|
+
h += `<p>${jurisdictionHtml(p.jurisdiction)}: <code>${escape(p.url)}</code> (${escape(`${p.packageId}#${p.version}`)}): <span class="ips-badge" style="background: ${colour}">${escape(label)}</span></p>`;
|
|
1195
|
+
if (p.pass === null || p.pass === undefined) {
|
|
1196
|
+
return h + `<p><i>The document could not be validated against this profile${options.canManage && p.error ? `: ${escape(p.error)}` : ' (the reason is shown to the project\'s managers)'}.</i></p>`;
|
|
1197
|
+
}
|
|
1198
|
+
return h + validationOutcome(r.made, { counts: p.counts, outcome: p.outcome, profiles: [p.url], validatorVersion: v.validatorVersion, ms: p.ms },
|
|
1199
|
+
{ ...options, hidden: { view: 'national', np: String(index) } });
|
|
1200
|
+
}
|
|
1201
|
+
let h = '<table class="table table-sm ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Outcome</th><th>Issues</th></tr>';
|
|
1202
|
+
v.profiles.forEach((np, i) => {
|
|
1203
|
+
const [label, colour] = outcome(np);
|
|
1204
|
+
const issues = np.pass === null || np.pass === undefined ? '<span class="ips-small">(not validated)</span>' : escape(issueSummary(np.counts || {}));
|
|
1205
|
+
h += `<tr><td>${jurisdictionHtml(np.jurisdiction)}</td><td><a href="${docUrl}?view=national&np=${i}"><code>${escape(`${np.packageId}#${np.version}`)}</code></a></td>` +
|
|
1206
|
+
`<td><span class="ips-badge" style="background: ${colour}">${escape(label)}</span></td><td>${issues}</td></tr>`;
|
|
1207
|
+
});
|
|
1208
|
+
h += '</table>';
|
|
1209
|
+
h += `<p class="ips-small">Validated ${escape(r.made)} against the <a href="${base}/national-profiles">national profiles</a> as they were when the document was processed ` +
|
|
1210
|
+
`(${v.profiles.length} profile${v.profiles.length === 1 ? '' : 's'}, ${escape(String(v.ms))} ms); a document passes a profile when the validator reports no errors. ` +
|
|
1211
|
+
'Reprocess the document to validate it against them as they are now.</p>';
|
|
1212
|
+
return h;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1124
1215
|
/** The editor page: a container the script fills, and what it needs. */
|
|
1125
1216
|
function renderCompose(project, ctx, options) {
|
|
1126
1217
|
const purl = projectUrl(ctx, project);
|
|
@@ -1139,10 +1230,70 @@ function renderCompose(project, ctx, options) {
|
|
|
1139
1230
|
};
|
|
1140
1231
|
// JSON in a script: </script> and the like must not end the block
|
|
1141
1232
|
h += `<script>window.IPS_EDITOR = ${JSON.stringify(cfg).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')};</script>`;
|
|
1142
|
-
h += `<script src="${escape(ctx.base
|
|
1233
|
+
h += `<script src="${escape(scripts.src(ctx.base, 'editor.js'))}"></script>`;
|
|
1143
1234
|
return h;
|
|
1144
1235
|
}
|
|
1145
1236
|
|
|
1237
|
+
// ---- national profiles -----------------------------------------------------------------
|
|
1238
|
+
|
|
1239
|
+
/** A national profile's jurisdiction, for the page: its code, and the country's or region's name. */
|
|
1240
|
+
function jurisdictionHtml(code) {
|
|
1241
|
+
if (!code) {
|
|
1242
|
+
return '<span class="ips-small">(not given)</span>';
|
|
1243
|
+
}
|
|
1244
|
+
const j = jurisdiction(code);
|
|
1245
|
+
return `<code>${escape(code)}</code>` + (j ? ` ${escape(j.display)}` : '');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* The national profiles: for each, the national IG's package (id and version) and the url of
|
|
1250
|
+
* its profile on Bundle. Everyone sees the list; the administrator adds, edits and deletes.
|
|
1251
|
+
* @param {Array} list - from store.nationalProfiles()
|
|
1252
|
+
*/
|
|
1253
|
+
function renderNationalProfiles(list, ctx, message) {
|
|
1254
|
+
const base = escape(ctx.base);
|
|
1255
|
+
const isAdmin = !!ctx.user?.isAdmin;
|
|
1256
|
+
let h = STYLE + userBar(ctx) + `<p class="ips-small"><a href="${base}">IPS projects</a> » National profiles</p>` + alert(message);
|
|
1257
|
+
h += '<div class="ips-banner"><b class="ips-project-name">National profiles</b></div>';
|
|
1258
|
+
h += '<p>A national profile is the profile a national implementation guide defines for an IPS document: the country or region it is for, the IG\'s package (its id and version), ' +
|
|
1259
|
+
'and the canonical URL of its profile on Bundle. They apply across all projects.' + (isAdmin ? '' : ' Only the administrator can change them.') + '</p>';
|
|
1260
|
+
if (list.length === 0) {
|
|
1261
|
+
h += '<p>There are no national profiles yet.</p>';
|
|
1262
|
+
}
|
|
1263
|
+
if (!isAdmin) {
|
|
1264
|
+
if (list.length === 0) {
|
|
1265
|
+
return h;
|
|
1266
|
+
}
|
|
1267
|
+
h += '<table class="table table-sm table-striped ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Version</th><th>Profile on Bundle</th></tr>';
|
|
1268
|
+
for (const np of list) {
|
|
1269
|
+
h += `<tr><td>${jurisdictionHtml(np.jurisdiction)}</td><td><code>${escape(np.packageId)}</code></td><td>${escape(np.version)}</td><td><code>${escape(np.url)}</code></td></tr>`;
|
|
1270
|
+
}
|
|
1271
|
+
return h + '</table>';
|
|
1272
|
+
}
|
|
1273
|
+
const fields = (form, np) =>
|
|
1274
|
+
`<td><input form="${form}" type="text" name="jurisdiction" required class="form-control" style="font-size: 85%; width: 6em" placeholder="AU, or 150" ` +
|
|
1275
|
+
`title="A country's ISO 3166 two-letter code, or a region's UN M49 three-digit code" value="${escape(np ? (np.jurisdiction || '') : '')}"/>` +
|
|
1276
|
+
(np && np.jurisdiction && jurisdiction(np.jurisdiction) ? `<span class="ips-small">${escape(jurisdiction(np.jurisdiction).display)}</span>` : '') + '</td>' +
|
|
1277
|
+
`<td><input form="${form}" type="text" name="packageId" required class="form-control" style="font-size: 85%; width: 15em" placeholder="e.g. hl7.fhir.au.ips" value="${escape(np ? np.packageId : '')}"/></td>` +
|
|
1278
|
+
`<td><input form="${form}" type="text" name="version" required class="form-control" style="font-size: 85%; width: 8em" placeholder="version" value="${escape(np ? np.version : '')}"/></td>` +
|
|
1279
|
+
`<td><input form="${form}" type="url" name="url" required class="form-control" style="font-size: 85%; width: 100%; min-width: 30em" placeholder="url of the profile on Bundle" value="${escape(np ? np.url : '')}"/></td>`;
|
|
1280
|
+
// the package id and version are short; the url takes the rest of the row
|
|
1281
|
+
h += '<table class="table table-sm ips-table"><tr><th>Jurisdiction</th><th>Package</th><th>Version</th><th style="width: 100%">Profile on Bundle</th><th></th></tr>';
|
|
1282
|
+
for (const np of list) {
|
|
1283
|
+
const form = `np-${np.id}`;
|
|
1284
|
+
const action = `${base}/national-profiles/${np.id}`;
|
|
1285
|
+
h += '<tr>' + fields(form, np) +
|
|
1286
|
+
`<td style="white-space: nowrap"><form id="${form}" method="post" action="${action}" class="ips-inline">${csrfField(ctx.csrf)}` +
|
|
1287
|
+
'<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Save</button></form> ' +
|
|
1288
|
+
`<form method="post" action="${action}/delete" class="ips-inline" data-confirm="${escape(`Delete the national profile for ${np.packageId}#${np.version}?`)}">${csrfField(ctx.csrf)}` +
|
|
1289
|
+
'<button type="submit" class="btn btn-default ips-btn" style="font-size: 85%; padding: 1px 10px">Delete</button></form></td></tr>';
|
|
1290
|
+
}
|
|
1291
|
+
h += '<tr>' + fields('np-new', null) +
|
|
1292
|
+
`<td><form id="np-new" method="post" action="${base}/national-profiles">${csrfField(ctx.csrf)}` +
|
|
1293
|
+
'<button type="submit" class="btn btn-primary ips-btn" style="font-size: 85%; padding: 1px 10px">Add</button></form></td></tr>';
|
|
1294
|
+
return h + '</table>';
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1146
1297
|
// ---- clinical profiles -----------------------------------------------------------------
|
|
1147
1298
|
|
|
1148
1299
|
function profileCrumbs(ctx, extra) {
|
|
@@ -1201,63 +1352,180 @@ function renderProfiles(list, ctx) {
|
|
|
1201
1352
|
// the parts of a profile, as the view and the editor show them
|
|
1202
1353
|
const PROFILE_PARTS = [['section', 'Required sections and entries'], ['resource', 'Rules about resources'], ['other', 'Other rules']];
|
|
1203
1354
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1355
|
+
// the code systems for the categories of the resources in an IPS, for a rule's category
|
|
1356
|
+
const CATEGORY_SYSTEMS = [
|
|
1357
|
+
{ url: 'http://terminology.hl7.org/CodeSystem/condition-category', name: 'Condition category' },
|
|
1358
|
+
{ url: 'http://terminology.hl7.org/CodeSystem/observation-category', name: 'Observation category' },
|
|
1359
|
+
{ url: 'http://terminology.hl7.org/CodeSystem/medicationrequest-category', name: 'MedicationRequest category' }
|
|
1360
|
+
];
|
|
1361
|
+
|
|
1362
|
+
/** A cardinality, for the profile's page: " [m..n]", unless it is unstated or 1..1. */
|
|
1363
|
+
function cardSuffix(p) {
|
|
1364
|
+
return p.min === null || p.min === undefined || (p.min === 1 && p.max === 1) ? '' : ` [${p.min}..${p.max}]`;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/** The name of a code system, when it has one we know, or its URL. */
|
|
1368
|
+
function profileSystemName(url) {
|
|
1369
|
+
const sys = profiles.SYSTEMS.find(s => s.url === url);
|
|
1370
|
+
return sys ? sys.name : (systemName(url) || url);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** The Section and Entry columns of a required section/entry row. */
|
|
1374
|
+
function sectionColumns(shape, titleOf) {
|
|
1375
|
+
const s = shape.section;
|
|
1376
|
+
const named = s.system === LOINC ? profiles.IPS_SECTIONS.find(x => x.code === s.code) : null;
|
|
1377
|
+
const section = (named
|
|
1378
|
+
? `${escape(named.title)} <span class="ips-small">(${escape(`${profileSystemName(s.system)}|${s.code}`)})</span>`
|
|
1379
|
+
: escape(`${profileSystemName(s.system)}|${s.code}`)) + escape(cardSuffix(s));
|
|
1380
|
+
const e = shape.entry;
|
|
1381
|
+
let entry = '';
|
|
1382
|
+
if (e && e.valueSet !== undefined) {
|
|
1383
|
+
const title = typeof titleOf === 'function' ? titleOf(e.valueSet) : null;
|
|
1384
|
+
entry = `Code is in <b>${escape(title || e.valueSet)}</b>` + (title ? ` <span class="ips-small">(${escape(e.valueSet)})</span>` : '') + escape(cardSuffix(e));
|
|
1385
|
+
} else if (e) {
|
|
1386
|
+
entry = `Code is <b>${escape(`${profileSystemName(e.system)}|${e.code}`)}</b>` + escape(cardSuffix(e));
|
|
1387
|
+
}
|
|
1388
|
+
return [section, entry];
|
|
1226
1389
|
}
|
|
1227
1390
|
|
|
1228
|
-
/**
|
|
1229
|
-
function
|
|
1391
|
+
/** The resources a rule is about, as a plural: MedicationAdministrations, or Any resource. */
|
|
1392
|
+
function resourceTypePlural(type) {
|
|
1393
|
+
if (!type) {
|
|
1394
|
+
return 'Any resource';
|
|
1395
|
+
}
|
|
1396
|
+
return /[^aeiou]y$/.test(type) ? `${type.slice(0, -1)}ies` : `${type}s`;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// the tails of a rule about resources that are said in words: each of the patterns, as it
|
|
1400
|
+
// is, or (where a resource at a time is what is meant) inside .all(...)
|
|
1401
|
+
const STR_ARG = "\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*";
|
|
1402
|
+
const NAME = '[A-Za-z][A-Za-z0-9_]*';
|
|
1403
|
+
const allOf = (body) => new RegExp(`^\\.\\s*(?:${body}|all\\s*\\(\\s*${body}\\s*\\))$`);
|
|
1404
|
+
const TAILS = [
|
|
1405
|
+
[/^\.\s*exists\s*\(\s*\)$/, () => 'Must be present somewhere'],
|
|
1406
|
+
[/^\.\s*all\s*\(\s*sameSubject\s*\(\s*\)\s*\)$/, () => 'Patient must match the Document'],
|
|
1407
|
+
[allOf(`conformsTo\\s*\\(${STR_ARG}\\)`), (m, ctx) => {
|
|
1408
|
+
const url = unescapeArg(m[1] !== undefined ? m[1] : m[2]);
|
|
1409
|
+
const title = typeof ctx.profileTitle === 'function' ? ctx.profileTitle(url) : null;
|
|
1410
|
+
return `Conform to <b>${escape(title || url)}</b>` + (title ? ` <span class="ips-small">(${escape(url)})</span>` : '');
|
|
1411
|
+
}],
|
|
1412
|
+
[allOf(`category\\s*\\.\\s*hasCoding\\s*\\(${STR_ARG},${STR_ARG}(?:,${STR_ARG})?\\)`), (m) => {
|
|
1413
|
+
const [system, code, version] = (m[1] !== undefined ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]]).map(x => x === undefined ? undefined : unescapeArg(x));
|
|
1414
|
+
return `Has category <b>${escape(`${profileSystemName(system)}|${code}`)}</b>` + (version !== undefined ? ` <span class="ips-small">(version ${escape(version)})</span>` : '');
|
|
1415
|
+
}],
|
|
1416
|
+
// an element, or an element's element (no deeper): Must have an x, Must have an x.y
|
|
1417
|
+
[new RegExp(`^\\.\\s*(${NAME})\\s*(?:\\.\\s*(${NAME})\\s*)?\\.\\s*exists\\s*\\(\\s*\\)$`), (m) => {
|
|
1418
|
+
const path = m[2] ? `${m[1]}.${m[2]}` : m[1];
|
|
1419
|
+
return `Must have ${/^[aeiou]/i.test(path) ? 'an' : 'a'} <code>${escape(path)}</code>`;
|
|
1420
|
+
}]
|
|
1421
|
+
];
|
|
1422
|
+
|
|
1423
|
+
/** A FHIRPath string's content, unescaped. */
|
|
1424
|
+
function unescapeArg(s) {
|
|
1425
|
+
return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (x, c) => c.length === 5 ? String.fromCharCode(parseInt(c.substring(1), 16)) : ({ n: '\n', r: '\r', t: '\t', f: '\f' }[c] || c));
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** The Resource Set and Rule columns of a rule about resources. */
|
|
1429
|
+
function resourceColumns(rshape, ctx) {
|
|
1430
|
+
const titleOf = ctx.valueSetTitle;
|
|
1431
|
+
let which;
|
|
1432
|
+
if (rshape.valueSet !== undefined) {
|
|
1433
|
+
const title = typeof titleOf === 'function' ? titleOf(rshape.valueSet) : null;
|
|
1434
|
+
which = `Code is in <b>${escape(title || rshape.valueSet)}</b>` + (title ? ` <span class="ips-small">(${escape(rshape.valueSet)})</span>` : '');
|
|
1435
|
+
} else {
|
|
1436
|
+
which = `Code is <b>${escape(`${profileSystemName(rshape.system)}|${rshape.code}`)}</b>`;
|
|
1437
|
+
}
|
|
1438
|
+
const set = `${escape(resourceTypePlural(rshape.type))} where ${which}`;
|
|
1439
|
+
const rest = rshape.rest.trim();
|
|
1440
|
+
for (const [re, words] of TAILS) {
|
|
1441
|
+
const m = re.exec(rest);
|
|
1442
|
+
if (m) {
|
|
1443
|
+
return [set, words(m, ctx)];
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return [set, `<code>${escape(rest)}</code>`];
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* A clinical profile: the same page for everyone. The administrator also gets add, edit and
|
|
1451
|
+
* delete on each row (a row at a time, the script edits it in place), and the full editor.
|
|
1452
|
+
*/
|
|
1453
|
+
function renderProfile(sd, ctx, editor = null) {
|
|
1230
1454
|
const base = escape(ctx.base);
|
|
1231
1455
|
const url = `${base}/StructureDefinition/${escape(sd.id)}`;
|
|
1456
|
+
const admin = !!(editor && ctx.user?.isAdmin);
|
|
1232
1457
|
let h = STYLE + userBar(ctx) + profileCrumbs(ctx, sd.title);
|
|
1233
1458
|
h += `<div class="ips-banner"><b class="ips-project-name">${escape(sd.title)}</b>` +
|
|
1234
1459
|
`<div class="ips-facts">Clinical profile on Bundle · <code>${escape(sd.url)}</code> · updated ${escape(dateOnly(sd.date))}</div></div>`;
|
|
1235
1460
|
h += '<div class="ips-actions-line">' +
|
|
1236
1461
|
`<a class="btn btn-default" href="${url}?download=1">Download JSON</a>` +
|
|
1237
|
-
(
|
|
1462
|
+
(admin ? ` <a class="btn btn-default" href="${url}/edit">Edit all</a> ` +
|
|
1238
1463
|
`<form method="post" action="${url}/delete" class="ips-inline" data-confirm="${escape(`Delete the clinical profile ${sd.title}?`)}">${csrfField(ctx.csrf)}` +
|
|
1239
|
-
'<button type="submit" class="btn btn-default">Delete</button></form>' : '') + '</div>';
|
|
1240
|
-
const constraints = sd.differential?.element?.[0]?.constraint || [];
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1464
|
+
'<button type="submit" class="btn btn-default">Delete profile</button></form>' : '') + '</div>';
|
|
1465
|
+
const constraints = (sd.differential?.element?.[0]?.constraint || []).filter(c => c && typeof c === 'object');
|
|
1466
|
+
// the actions on a row, for the administrator
|
|
1467
|
+
const actions = (c) => !admin ? '' : '<td class="ips-pf-actions">' +
|
|
1468
|
+
'<button type="button" class="btn btn-default ips-btn ips-pf-edit">Edit</button> ' +
|
|
1469
|
+
`<form method="post" action="${url}/constraint/${escape(c.key || '')}/delete" class="ips-inline" data-confirm="${escape(`Delete the rule '${c.human || c.key}'?`)}">${csrfField(ctx.csrf)}` +
|
|
1470
|
+
'<button type="submit" class="btn btn-default ips-btn">Delete</button></form></td>';
|
|
1471
|
+
const rowAttrs = (c) => (admin ? ` data-key="${escape(c.key || '')}"` : '') + ` title="${escape(c.expression || '')}"`;
|
|
1472
|
+
const actionsHead = admin ? '<th class="ips-pf-actions"></th>' : '';
|
|
1473
|
+
const ADD = { section: 'Add a required section/entry', resource: 'Add a rule about resources', other: 'Add another rule' };
|
|
1474
|
+
for (const [kind, heading] of PROFILE_PARTS) {
|
|
1475
|
+
const these = constraints.filter(c => profiles.kindOf(c.expression || '') === kind);
|
|
1476
|
+
if (!these.length && !admin) {
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
// the heading, and for the administrator the part's Add button, to its right
|
|
1480
|
+
h += `<div class="ips-pf-part-head"><h4>${escape(heading)}</h4>` +
|
|
1481
|
+
(admin ? `<button type="button" class="btn btn-default ips-pf-add" data-part="${kind}">${escape(ADD[kind])}</button>` : '') + '</div>';
|
|
1482
|
+
if (kind === 'section') {
|
|
1483
|
+
h += `<table class="table table-sm ips-table ips-pf-rule" data-part="${kind}"><tr><th style="width: 40%">Expectation</th><th>Section</th><th>Entry</th>${actionsHead}</tr>`;
|
|
1484
|
+
for (const c of these) {
|
|
1485
|
+
const [section, entry] = sectionColumns(profiles.parseShape(c.expression || ''), ctx.valueSetTitle);
|
|
1486
|
+
h += `<tr${rowAttrs(c)}><td>${escape(c.human || '')}</td><td>${section}</td><td>${entry}</td>${actions(c)}</tr>`;
|
|
1487
|
+
}
|
|
1488
|
+
} else if (kind === 'resource') {
|
|
1489
|
+
h += `<table class="table table-sm ips-table ips-pf-rule" data-part="${kind}"><tr><th style="width: 40%">Expectation</th><th>Resource Set</th><th>Rule</th>${actionsHead}</tr>`;
|
|
1490
|
+
for (const c of these) {
|
|
1491
|
+
const [set, rule] = resourceColumns(profiles.parseResourceShape(c.expression || ''), ctx);
|
|
1492
|
+
h += `<tr${rowAttrs(c)}><td>${escape(c.human || '')}</td><td>${set}</td><td>${rule}</td>${actions(c)}</tr>`;
|
|
1248
1493
|
}
|
|
1249
|
-
|
|
1494
|
+
} else {
|
|
1495
|
+
h += `<table class="table table-sm ips-table ips-pf-rule" data-part="${kind}"><tr><th style="width: 35%">Expectation</th><th>Rule</th>${actionsHead}</tr>`;
|
|
1250
1496
|
for (const c of these) {
|
|
1251
|
-
h += `<tr><td>${escape(c.human || '')}</td><td>${
|
|
1497
|
+
h += `<tr${rowAttrs(c)}><td>${escape(c.human || '')}</td><td><code>${escape(c.expression || '')}</code></td>${actions(c)}</tr>`;
|
|
1252
1498
|
}
|
|
1253
|
-
h += '</table>';
|
|
1254
1499
|
}
|
|
1500
|
+
h += '</table>';
|
|
1501
|
+
}
|
|
1502
|
+
if (constraints.length === 0 && !admin) {
|
|
1503
|
+
h += '<p>This profile has no constraints yet.</p>';
|
|
1255
1504
|
}
|
|
1256
1505
|
h += '<p class="ips-small">Each rule is checked against the document (the Bundle). <code>section(system, code, min, max)</code> finds the sections of the document\'s Composition ' +
|
|
1257
1506
|
'(nested ones too) with that code, when there are min..max of them; <code>entry(system, code, min, max)</code> finds a section\'s entries with that code ' +
|
|
1258
1507
|
'(their <code>code</code>, <code>medicationCodeableConcept</code> or <code>vaccineCode</code>), when there are min..max of them; ' +
|
|
1259
1508
|
'<code>resources(system, code, type)</code> finds the resources in the document with that code (of that type, when one is given); ' +
|
|
1260
|
-
'<code>entryIn(valueSet, min, max)</code> and <code>resourcesIn(valueSet, type)</code> are the same with a code in a value set, asked of the terminology server
|
|
1509
|
+
'<code>entryIn(valueSet, min, max)</code> and <code>resourcesIn(valueSet, type)</code> are the same with a code in a value set, asked of the terminology server. ' +
|
|
1510
|
+
'A cardinality <code>[m..n]</code> after a section or an entry is how many there may be; with none, there must be at least one.</p>';
|
|
1511
|
+
if (admin) {
|
|
1512
|
+
const cfg = {
|
|
1513
|
+
mode: 'rows', profile: { id: sd.id, title: sd.title, constraints: editor.constraints },
|
|
1514
|
+
sections: editor.sections, systems: editor.systems, resourceTypes: editor.resourceTypes, sectionSystem: editor.sectionSystem, csrf: ctx.csrf || '',
|
|
1515
|
+
parts: PROFILE_PARTS,
|
|
1516
|
+
urls: {
|
|
1517
|
+
constraint: `${ctx.base}/StructureDefinition/${sd.id}/constraint`,
|
|
1518
|
+
lookup: editor.canLookup ? `${ctx.base}/lookup` : null,
|
|
1519
|
+
lookupVs: `${ctx.base}/lookup-vs`
|
|
1520
|
+
},
|
|
1521
|
+
// the code systems offered for a resource's category, first the default
|
|
1522
|
+
categorySystem: CATEGORY_SYSTEMS[0].url,
|
|
1523
|
+
categorySystems: CATEGORY_SYSTEMS
|
|
1524
|
+
};
|
|
1525
|
+
h += '<div id="ips-profile-editor"></div>';
|
|
1526
|
+
h += `<script>window.IPS_PROFILE = ${JSON.stringify(cfg).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')};</script>`;
|
|
1527
|
+
h += `<script src="${escape(scripts.src(ctx.base, 'profile-editor.js'))}"></script>`;
|
|
1528
|
+
}
|
|
1261
1529
|
return h;
|
|
1262
1530
|
}
|
|
1263
1531
|
|
|
@@ -1286,7 +1554,7 @@ function renderProfileEditor(sd, ctx, options) {
|
|
|
1286
1554
|
}
|
|
1287
1555
|
};
|
|
1288
1556
|
h += `<script>window.IPS_PROFILE = ${JSON.stringify(cfg).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')};</script>`;
|
|
1289
|
-
h += `<script src="${escape(base
|
|
1557
|
+
h += `<script src="${escape(scripts.src(base, 'profile-editor.js'))}"></script>`;
|
|
1290
1558
|
return h;
|
|
1291
1559
|
}
|
|
1292
1560
|
|
|
@@ -1362,6 +1630,7 @@ const VIEWS = [
|
|
|
1362
1630
|
['translation', 'Translation'],
|
|
1363
1631
|
['generated', 'Generated Narrative'],
|
|
1364
1632
|
['validation', 'Validation'],
|
|
1633
|
+
['national', 'National Profiles'],
|
|
1365
1634
|
['terminology', 'Terminology'],
|
|
1366
1635
|
['profiles', 'Clinical Profiles'],
|
|
1367
1636
|
['ai', 'AI Assessment'],
|
|
@@ -1469,6 +1738,8 @@ function renderDocument(project, row, view, ctx) {
|
|
|
1469
1738
|
h += renderAi(row, { canManage: ctx.canManage });
|
|
1470
1739
|
} else if (view === 'validation') {
|
|
1471
1740
|
h += renderValidation(row, { level: ctx.level, docUrl: durl, canManage: ctx.canManage });
|
|
1741
|
+
} else if (view === 'national') {
|
|
1742
|
+
h += renderNational(row, { np: ctx.np, level: ctx.level, docUrl: durl, base: ctx.base, canManage: ctx.canManage });
|
|
1472
1743
|
} else {
|
|
1473
1744
|
h += assembledNarrative(row.bundle);
|
|
1474
1745
|
}
|
|
@@ -1477,6 +1748,6 @@ function renderDocument(project, row, view, ctx) {
|
|
|
1477
1748
|
|
|
1478
1749
|
module.exports = {
|
|
1479
1750
|
renderHome, renderLogin, renderProjects, renderUsers, renderProject, renderSubmit, renderSettings, renderDocument,
|
|
1480
|
-
assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderShare, renderCompose, renderProfileChecks,
|
|
1481
|
-
renderProfiles, renderProfile, renderProfileEditor, VIEWS
|
|
1751
|
+
assembledNarrative, renderTerminology, renderTranslation, renderGenerated, renderAi, renderValidation, renderNational, renderShare, renderCompose, renderProfileChecks,
|
|
1752
|
+
renderProfiles, renderProfile, renderProfileEditor, renderNationalProfiles, VIEWS
|
|
1482
1753
|
};
|
package/ips/scripts.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The module's browser scripts: its own files, read once and served from memory (so no
|
|
9
|
+
* request reaches the file system), with an ETag. A page links a script with its version
|
|
10
|
+
* (?v=, from the script's content), so a browser that has kept a copy picks up a changed
|
|
11
|
+
* script as soon as the server serves it, not when its cached copy expires.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const SCRIPTS = new Map();
|
|
18
|
+
|
|
19
|
+
function load(name) {
|
|
20
|
+
if (!SCRIPTS.has(name)) {
|
|
21
|
+
const body = fs.readFileSync(path.join(__dirname, name), 'utf8');
|
|
22
|
+
const hash = crypto.createHash('sha256').update(body).digest('base64url');
|
|
23
|
+
SCRIPTS.set(name, { body, etag: `"${hash.substring(0, 27)}"`, version: hash.substring(0, 12) });
|
|
24
|
+
}
|
|
25
|
+
return SCRIPTS.get(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The route handler that serves the script. */
|
|
29
|
+
function handler(name) {
|
|
30
|
+
const s = load(name);
|
|
31
|
+
return (req, res) => {
|
|
32
|
+
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
33
|
+
res.setHeader('ETag', s.etag);
|
|
34
|
+
if (req.get('If-None-Match') === s.etag) {
|
|
35
|
+
return res.status(304).end();
|
|
36
|
+
}
|
|
37
|
+
res.type('application/javascript').send(s.body);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The script's URL, for a page: base/name?v=version (not escaped). */
|
|
42
|
+
function src(base, name) {
|
|
43
|
+
return `${base}/${name}?v=${load(name).version}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { handler, src };
|
package/ips/store.js
CHANGED
|
@@ -102,6 +102,17 @@ const SCHEMA = `
|
|
|
102
102
|
json TEXT NOT NULL
|
|
103
103
|
);
|
|
104
104
|
|
|
105
|
+
-- national profiles: the profile a national IG defines for an IPS document (the Bundle), across all projects
|
|
106
|
+
CREATE TABLE IF NOT EXISTS national_profiles (
|
|
107
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
108
|
+
package_id TEXT NOT NULL, -- the IG's package id
|
|
109
|
+
version TEXT NOT NULL, -- the package version
|
|
110
|
+
url TEXT NOT NULL, -- the canonical URL of the IG's profile on Bundle
|
|
111
|
+
jurisdiction TEXT, -- the country (ISO 3166 two-letter code) or region (UN M49 three digits)
|
|
112
|
+
updated_ms INTEGER NOT NULL,
|
|
113
|
+
UNIQUE (package_id, version)
|
|
114
|
+
);
|
|
115
|
+
|
|
105
116
|
CREATE TABLE IF NOT EXISTS ips_index (
|
|
106
117
|
doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
107
118
|
res TEXT NOT NULL, -- Bundle | Composition | Patient
|
|
@@ -158,6 +169,11 @@ function summaryOf(bundle) {
|
|
|
158
169
|
};
|
|
159
170
|
}
|
|
160
171
|
|
|
172
|
+
/** A national_profiles row, as the store gives it. */
|
|
173
|
+
function nationalProfileOf(r) {
|
|
174
|
+
return { id: r.id, jurisdiction: r.jurisdiction || null, packageId: r.package_id, version: r.version, url: r.url, updated_ms: r.updated_ms };
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
class IpsStore {
|
|
162
178
|
/**
|
|
163
179
|
* @param {string} dbPath - file name, or ':memory:'
|
|
@@ -203,6 +219,9 @@ class IpsStore {
|
|
|
203
219
|
if (!has('documents', 'analysed_json')) {
|
|
204
220
|
this.db.exec('ALTER TABLE documents ADD COLUMN analysed_json TEXT');
|
|
205
221
|
}
|
|
222
|
+
if (!has('national_profiles', 'jurisdiction')) {
|
|
223
|
+
this.db.exec('ALTER TABLE national_profiles ADD COLUMN jurisdiction TEXT');
|
|
224
|
+
}
|
|
206
225
|
if (!has('projects', 'ai_json')) {
|
|
207
226
|
// the project's AI assessment settings (JSON; the API key encrypted)
|
|
208
227
|
this.db.exec('ALTER TABLE projects ADD COLUMN ai_json TEXT');
|
|
@@ -261,6 +280,42 @@ class IpsStore {
|
|
|
261
280
|
return this.db.prepare('DELETE FROM clinical_profiles WHERE id = ?').run(id).changes > 0;
|
|
262
281
|
}
|
|
263
282
|
|
|
283
|
+
// ---- national profiles ----
|
|
284
|
+
|
|
285
|
+
/** All the national profiles: {id, jurisdiction, packageId, version, url, updated_ms}, by package id and version. */
|
|
286
|
+
nationalProfiles() {
|
|
287
|
+
return this.db.prepare('SELECT id, package_id, version, url, jurisdiction, updated_ms FROM national_profiles ORDER BY package_id, version, id').all()
|
|
288
|
+
.map(nationalProfileOf);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** @returns {Object|null} {id, jurisdiction, packageId, version, url, updated_ms} */
|
|
292
|
+
nationalProfile(id) {
|
|
293
|
+
const r = this.db.prepare('SELECT id, package_id, version, url, jurisdiction, updated_ms FROM national_profiles WHERE id = ?').get(id);
|
|
294
|
+
return r ? nationalProfileOf(r) : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The national profile for a package id and version, or null. */
|
|
298
|
+
nationalProfileByPackage(packageId, version) {
|
|
299
|
+
const r = this.db.prepare('SELECT id FROM national_profiles WHERE package_id = ? AND version = ?').get(packageId, version);
|
|
300
|
+
return r ? this.nationalProfile(r.id) : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @returns {number} the new national profile's id */
|
|
304
|
+
createNationalProfile(packageId, version, url, jurisdiction, nowMs = Date.now()) {
|
|
305
|
+
return Number(this.db.prepare('INSERT INTO national_profiles (package_id, version, url, jurisdiction, updated_ms) VALUES (?, ?, ?, ?, ?)')
|
|
306
|
+
.run(packageId, version, url, jurisdiction || null, nowMs).lastInsertRowid);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @returns {boolean} whether there was such a national profile */
|
|
310
|
+
updateNationalProfile(id, packageId, version, url, jurisdiction, nowMs = Date.now()) {
|
|
311
|
+
return this.db.prepare('UPDATE national_profiles SET package_id = ?, version = ?, url = ?, jurisdiction = ?, updated_ms = ? WHERE id = ?')
|
|
312
|
+
.run(packageId, version, url, jurisdiction || null, nowMs, id).changes > 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
deleteNationalProfile(id) {
|
|
316
|
+
return this.db.prepare('DELETE FROM national_profiles WHERE id = ?').run(id).changes > 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
264
319
|
// ---- projects ----
|
|
265
320
|
|
|
266
321
|
/** All projects, with their document counts and managers; by name. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.18.
|
|
4
|
-
"txVersion": "1.10.
|
|
3
|
+
"version": "0.18.2",
|
|
4
|
+
"txVersion": "1.10.1",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"engines": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"express-rate-limit": "^7.4.1",
|
|
49
49
|
"express-session": "^1.19.0",
|
|
50
50
|
"fast-xml-parser": "^5.3.4",
|
|
51
|
-
"fhir-validator-wrapper": "1.
|
|
51
|
+
"fhir-validator-wrapper": "1.6.1",
|
|
52
52
|
"fhirpath": "^5.2.1",
|
|
53
53
|
"fs-extra": "^11.3.3",
|
|
54
54
|
"ini": "^6.0.0",
|