Skip to content

feat(#11192): support extension libs in messages#11244

Open
belwalshubham wants to merge 10 commits into
medic:masterfrom
belwalshubham:11192-extension-libs-messages
Open

feat(#11192): support extension libs in messages#11244
belwalshubham wants to merge 10 commits into
medic:masterfrom
belwalshubham:11192-extension-libs-messages

Conversation

@belwalshubham

@belwalshubham belwalshubham commented Jul 12, 2026

Copy link
Copy Markdown

Summary

Adds project-defined extension-libs as synchronous Mustache section helpers for translations and outgoing messages.

This allows configurers to transform message values without adding a new built-in helper to CHT Core, for example:

{{#to_devanagari}}{{patient_id}}{{/to_devanagari}}

Closes #11192.

Architecture

flowchart LR
  Couch[("CouchDB<br/>extension-libs")]
  Loader["Extension-libs loader<br/>decode + atomic registry replacement"]

  Couch --> Loader

  Loader --> Webapp["Webapp cache"]
  Loader --> API["API config cache"]
  Loader --> Sentinel["Sentinel config cache"]
  Loader --> Admin["Admin preview load"]

  Webapp --> Translation["Webapp translations"]
  Webapp --> Report["Report detail messages"]
  API --> Export["Message export rendering"]
  Sentinel --> Transitions["Transitions and scheduled SMS"]
  Admin --> Preview["Message queue preview"]

  Translation --> MessageUtils["message-utils<br/>template(opts) / generate(opts)"]
  Report --> MessageUtils
  Export --> MessageUtils
  Transitions --> MessageUtils
  Preview --> MessageUtils

  MessageUtils --> Output["Consistent rendered message"]
Loading

Changes

  • Adds @medic/extension-libs for loading and decoding the extension-libs CouchDB document.
  • Refactors message-utils.generate and message-utils.template to accept an options object containing extensionLibs.
  • Keeps positional argument compatibility for downstream callers while migrating all CHT Core production call sites.
  • Exposes function exports as Mustache section helpers, using the attachment filename without the .js suffix.
  • Ensures built-in helpers take precedence over extension-lib name collisions.
  • Loads and refreshes extension-libs in API and Sentinel when the document changes.
  • Passes extension-libs through transitions, scheduled/due messages, API message exports, Webapp report details and validation messages, and Admin message queue previews.
  • Adds a Webapp translation parser that uses extension-lib helpers for Mustache sections while preserving the default parser for normal and compiled MessageFormat translations.
  • Treats a missing document as an empty registry, skips malformed attachments, and retains the previous valid registry after transient load failures.

Security

Extension-libs are trusted application configuration uploaded by authorized administrators. This change does not present extension-lib execution as sandboxed.

Verification

Manual end-to-end evidence

The following screenshot records the manually executed live CouchDB flow. It uses a real extension-libs document and attachments, the production @medic/extension-libs loader, and the production message-utils rendering path.

Manual end-to-end verification: real CouchDB extension-lib attachment loading, outgoing SMS transformation, document replacement, reload, and second transformation

Manual steps verified:

  1. Created an isolated cht_11192_e2e database on CouchDB 3.5.2.
  2. Uploaded to_devanagari.js as an attachment on the extension-libs document.
  3. Loaded the real attachment with the production extension-libs loader.
  4. Rendered an outgoing message through message-utils.generate and confirmed ID १२३४५.
  5. Replaced the document attachment with uppercase.js.
  6. Reloaded the extension-libs registry and confirmed the previous helper was atomically replaced.
  7. Rendered through message-utils.template and confirmed Hello ADA.
  8. Deleted the isolated test database after verification.

This screenshot is evidence of the loader/reload/rendering end-to-end path. The Admin, API, Sentinel, and Webapp integrations are additionally covered by their regression suites listed below.

  • npm run build-dev
  • npm run lint
  • COUCH_URL=http://medic:password@localhost:5984/medic npm run unit-api — 1,636 passing
  • npm run unit-admin — 435 passing
  • TZ=UTC COUCH_URL=http://medic:password@localhost:5984/medic npm run unit-sentinel — 231 passing
  • npm test --workspace=@medic/extension-libs — 6 passing, 100% coverage
  • npm test --workspace=@medic/message-utils — 112 passing, 100% statements/lines
  • Webapp Angular compilation and Karma suite command completed successfully.
  • Docker-backed API integration suite completed successfully.
  • Live CouchDB smoke test:
    • uploaded to_devanagari.js as an attachment;
    • loaded it through the production loader;
    • rendered ID १२३४५ through message-utils.generate;
    • replaced the document with uppercase.js;
    • reloaded it and rendered Hello ADA through message-utils.template;
    • deleted the isolated temporary database afterward.

Notes for reviewers

  • The shared loader centralizes identical attachment decoding and error behavior across browser and server runtimes.
  • Application services still own their loading/cache lifecycle and explicitly inject the current extension-lib snapshot into message-utils.
  • The options-object refactor follows the direction discussed in Support extension-libs in translations and outgoing messages #11192 and avoids adding another positional parameter to the existing APIs.

Comment thread shared-libs/extension-libs/src/index.js Fixed
@belwalshubham
belwalshubham force-pushed the 11192-extension-libs-messages branch from aeba59b to 54db099 Compare July 12, 2026 14:43
@belwalshubham
belwalshubham marked this pull request as ready for review July 21, 2026 14:57
@belwalshubham

Copy link
Copy Markdown
Author

@binokaryg could you review this PR ?

@binokaryg
binokaryg self-requested a review July 22, 2026 20:19

@binokaryg binokaryg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @belwalshubham for working on this.

I tested manually on a local instance, and it was mostly working.

I added an extension-lib to_devanagari to my local config and tested it across:

  • outgoing messages
    • admin message queue
    • registration reply SMS
    • scheduled/reminder SMS
  • validation error (muting)
  • app form

Also confirmed:

  • live reload of extension-lib works
  • offline availability works
  • UI-displayed translations work

Also thank you for adding the unit tests.

I did some review with Claude's assistance and find some issues, which I have flagged in the code inline.

One major fail was the silent content loss on a missing/incorrect helper:

A Mustache section over an unknown key renders nothing, dropping the entire block, not just the value, but any literal text inside it. Verified through the real message-utils render path:

Template (patient_id = 48549) Renders
ID: {{#to_devanagari}}{{patient_id}}{{/to_devanagari}} (correct) ID: ४८५४९
ID: {{#to_devanagri}}{{patient_id}}{{/to_devanagri}} (typo) ID:
ID: {{#to_nepali}}{{patient_id}}{{/to_nepali}} (never uploaded) ID:
ID: {{to_devanagri}} (typo as plain var) ID:
ID: {{#to_nepali}}12345{{/to_nepali}} (literal inside) ID:

In the above example, a one-character typo in a helper name ships the id missing entirely (ID: ), with no server- or client-side warning. This is standard Mustache behavior (the existing built-ins share it, e.g. a typo'd {{#dat}}…{{/dat}}), but the feature widens the surface since configurers now author helper names. Mitigation is process (preview/test configured messages before deploy. The platform won't catch it) and strengthens the case for a debug/warn log on unknown-helper / name-collision.

We also need to add a documentation on the usage of this feature.

Comment thread .github/cht-11192-e2e-verification.jpg Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not include this image in the PR. It can stay on the PR body.

translate: value => value,
doc: params || {},
content: { message: expression },
extensionLibs: extensionLibs.getAll(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Translations can silently lose content before extension-libs load.

This is populated lazily, only when something first awaits CHTDatasourceService.isInitialized() (webapp/src/ts/services/cht-datasource.service.ts:31-42). Nothing guarantees this completes before translations render at startup.

Mustache drops a section whose key is missing (the PR's own test confirms: a non-function helper renders 'value: '). So a translation like Hello {{#uppercase}}{{name}}{{/uppercase}} rendered before the libs load produces Hello — silent content loss, and components won't re-render when the libs later arrive.

Suggestion: load extension-libs eagerly during bootstrap (before/alongside translation loading, e.g. in the bootstrapper or an APP_INITIALIZER), or make the parser fall back to default interpolation until the registry is populated.

Object
.entries(doc?._attachments ?? {})
.forEach(([k, v]) => this.extensionLibs[k] = this.decodeExtensionLib(k, v));
return extensionLibs.load(this.dbService.get());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API and Sentinel watch the changes feed and reload; Admin reloads per query; the webapp loads once per session and only watches DOC_IDS.SETTINGS (webapp/src/ts/services/cht-datasource.service.ts:65-71). This staleness predates the PR for getExtensionLib(), but translations now depend on it too, so the gap is more visible.

Suggestion: add a changes subscription for DOC_IDS.EXTENSION_LIBS, or explicitly document the reload-on-refresh behavior.

Comment thread shared-libs/message-utils/src/index.js Outdated
const helperName = fileName.replace(/\.js$/, '');
helpers[helperName] = function() {
return function(text, renderText) {
return extensionLib(renderText(text));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A throwing extension lib halts the unprotected message paths

The helper wrapper calls the lib directly extensionLib(renderText(text)) in getExtensionLibHelpers. If a configured lib throws at render time, mustache.render throws. transitions/src/lib/messages.js wraps generate in try/catch, but due_tasks.js, the API message export, admin message queue, and webapp format-data-record do not.

I verified this on a local instance: with a helper that throws. A due scheduled reminder stayed stuck scheduled with no message for 7+ minutes (multiple due_tasks cycles) and only recovered once the lib was fixed (baseline with a working lib generated in ~220s). Because due_tasks.js has no try/catch around generate, the throw isn't isolated to the offending message. It rejects the whole batch run (caught only at runTasks' top-level logger), so one bad helper silently blocks all pending scheduled messages every cycle until the lib is corrected, with no user-visible error.

Suggestion: wrap the helper invocation in try/catch, log the error and fall back to the untransformed rendered text, so a single misbehaving lib degrades one message instead of halting the batch.

Comment thread admin/src/js/services/message-queue.js Outdated
DB({ remote: true }).query('medic-admin/message_queue', params.list),
DB({ remote: true }).query('medic-admin/message_queue', params.count)
DB({ remote: true }).query('medic-admin/message_queue', params.count),
MessageQueueUtils.loadExtensionLibs(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin message queue hard-fails if extension-libs can't load.

API and Sentinel tolerate load failures but admin's query() rejects entirely. A transient failure fetching the doc shouldn't take down the whole message-queue tab.

Suggestion: catch and fall back to {}, matching the behavior everywhere else.

Comment thread shared-libs/message-utils/src/index.js Outdated
exports.template = function(config, translate, doc, content, extraContext) {
extraContext = extraContext || {};
exports.template = function(options, ...legacyArgs) {
const { config, translate, doc, content, extraContext, extensionLibs } = legacyArgs.length ? {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated legacy-args normalization: normalizeGenerateOptions exists for generate, but template re-implements the same logic inline. Extract one shared normalizer.

Comment thread shared-libs/message-utils/src/index.js Outdated
const getExtensionLibHelpers = (extensionLibs = {}) => Object.entries(extensionLibs)
.filter(([, extensionLib]) => typeof extensionLib === 'function')
.reduce((helpers, [fileName, extensionLib]) => {
const helperName = fileName.replace(/\.js$/, '');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper name collisions: uppercase.js and uppercase both map to helper uppercase — last one silently wins. A debug log would help configurers.

Comment thread shared-libs/message-utils/src/index.js Outdated

const render = function(config, template, view, locale) {
return mustache.render(template, Object.assign(view, {
const getExtensionLibHelpers = (extensionLibs = {}) => Object.entries(extensionLibs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: getExtensionLibHelpers(extensionLibs) rebuilds the helper map — iterating the registry, filtering non-functions, stripping .js suffixes, wrapping each lib in the Mustache lambda pattern — on every call to render, plus again for each nested render triggered by formatDate / local_phone. When Sentinel processes a batch of scheduled messages, that's the same map being reconstructed once per message from an unchanged registry. Since set() returns a fresh object on each reload, object identity could serve as the cache key. Worth caching?

Comment thread admin/src/js/services/message-queue.js Outdated
const findIdByKey = (contactsByReference, key) => {
const row = contactsByReference.rows.find((row) => row.key[1] === key);
return row && row.id;
return row && row.id; // NOSONAR: browserify-ngannotate does not support optional chaining.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

misplaced comment?

Comment on lines +7 to +17
const getLegacyGenerateArgs = call => {
const options = call[0];
return [
options.config,
options.translate,
options.doc,
options.content,
options.recipient,
options.extraContext,
];
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks misplaced, and the name also seems to be misleading. It destructures the new options object into a positional array to preserve old assertions. Please consider moving it below the imports and consider a clearer name like pickGenerateArgs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support extension-libs in translations and outgoing messages

3 participants