fhirsmith 0.18.0 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +72 -10
  2. package/ips/ips.js +251 -26
  3. package/ips/jurisdictions.js +65 -0
  4. package/ips/pipeline.js +67 -6
  5. package/ips/profile-editor.js +283 -4
  6. package/ips/readme.md +48 -5
  7. package/ips/render.js +317 -46
  8. package/ips/scripts.js +46 -0
  9. package/ips/store.js +55 -0
  10. package/library/daily-report.js +25 -15
  11. package/package.json +3 -3
  12. package/registry/api.js +179 -1
  13. package/registry/crawler.js +2 -0
  14. package/registry/documentation/api.md +68 -2
  15. package/registry/documentation/internals.md +4 -1
  16. package/registry/documentation/readme.md +3 -2
  17. package/registry/model.js +8 -0
  18. package/registry/openapi.yaml +84 -0
  19. package/registry/registry.js +53 -2
  20. package/translations/Messages.properties +1 -0
  21. package/tx/README.md +4 -0
  22. package/tx/closure/closure-store.js +14 -0
  23. package/tx/cm/cm-package.js +21 -15
  24. package/tx/cs/cs-api.js +3 -1
  25. package/tx/cs/cs-cs.js +59 -12
  26. package/tx/cs/cs-icf.js +47 -34
  27. package/tx/cs/cs-loinc.js +257 -3
  28. package/tx/cs/cs-snomed.js +544 -55
  29. package/tx/importers/csv-records.js +35 -0
  30. package/tx/importers/import-loinc.module.js +177 -41
  31. package/tx/importers/import-sct.module.js +131 -1
  32. package/tx/importers/subset-loinc.module.js +25 -6
  33. package/tx/library/codesystem.js +4 -1
  34. package/tx/library/designations.js +23 -30
  35. package/tx/library/tho-precedence.js +40 -0
  36. package/tx/openapi-operations.js +2 -1
  37. package/tx/provider.js +46 -29
  38. package/tx/sct/documentation.md +45 -21
  39. package/tx/sct/ecl.js +34 -11
  40. package/tx/sct/structures.js +15 -2
  41. package/tx/tx.fhir.org.yml +2 -2
  42. package/tx/vs/vs-package.js +17 -13
  43. package/tx/vs/vs-vsac.js +1 -1
  44. package/tx/workers/closure.js +20 -0
  45. package/tx/workers/expand.js +2 -2
  46. package/tx/workers/validate.js +10 -0
  47. package/utilities/make-sct-loinc-subset.js +250 -0
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(' &nbsp;|&nbsp; ')}</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.</p>`;
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
- const v = r.result;
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(r.made)}` +
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
- h += `<form method="get" action="${escape(options.docUrl || '')}" class="ips-filter mb-2"><input type="hidden" name="view" value="validation"/>` +
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> &raquo; ${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&amp;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)}/editor.js"></script>`;
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> &raquo; 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
- /** One rule of a profile, for reading: the shape as words, or the expression. */
1205
- function ruleHtml(expression, titleOf) {
1206
- const rshape = profiles.parseResourceShape(expression);
1207
- if (rshape) {
1208
- const d = profiles.describeResource(rshape, titleOf);
1209
- const which = d.valueSet !== undefined
1210
- ? `with a code in the value set <b>${escape(d.title || d.valueSet)}</b>${d.title ? ` <span class="ips-small">(${escape(d.valueSet)})</span>` : ''}`
1211
- : `with the code <b>${escape(d.code)}</b>`;
1212
- return `${d.type ? `<b>${escape(d.type)}</b> resources` : 'Resources'} ${which}: <code>${escape(d.rest)}</code>` +
1213
- `<div class="ips-pf-expr">${escape(expression)}</div>`;
1214
- }
1215
- const shape = profiles.parseShape(expression);
1216
- if (!shape) {
1217
- return `<code>${escape(expression)}</code>`;
1218
- }
1219
- const part = (p, isSection) => {
1220
- const d = profiles.describePart(p, isSection, titleOf);
1221
- return `<b>${escape(d.what)}</b>${d.code ? ` <span class="ips-small">(${escape(d.code)})</span>` : ''}` +
1222
- (d.card ? ` <span class="ips-pf-card-text">[${escape(d.card)}]</span>` : '');
1223
- };
1224
- const entry = !shape.entry ? '' : (shape.entry.valueSet !== undefined ? ', with entries in the value set ' : ', with an entry ') + part(shape.entry, false);
1225
- return 'Section ' + part(shape.section, true) + entry + `<div class="ips-pf-expr">${escape(expression)}</div>`;
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
- /** A clinical profile, read-only (the administrator gets edit and delete). */
1229
- function renderProfile(sd, ctx) {
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 &nbsp;&middot;&nbsp; <code>${escape(sd.url)}</code> &nbsp;&middot;&nbsp; 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
- (ctx.user?.isAdmin ? ` <a class="btn btn-primary" href="${url}/edit">Edit</a> ` +
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
- if (constraints.length === 0) {
1242
- h += '<p>This profile has no constraints yet.</p>';
1243
- } else {
1244
- for (const [kind, heading] of PROFILE_PARTS) {
1245
- const these = constraints.filter(c => profiles.kindOf(c.expression || '') === kind);
1246
- if (!these.length) {
1247
- continue;
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
- h += `<h4>${escape(heading)}</h4><table class="table table-sm ips-table ips-pf-rule"><tr><th style="width: 35%">Expectation</th><th>Rule</th></tr>`;
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>${ruleHtml(c.expression || '', ctx.valueSetTitle)}</td></tr>`;
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.</p>';
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)}/profile-editor.js"></script>`;
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. */