diff --git a/.eslintrc b/.eslintrc index 3227d7b1c99..ede9d7641aa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,6 +15,7 @@ "api/src/public/login/lib-bowser.js", "build/**", "jsdocs/**", + "shared-libs/cht-datasource/dist/**", "tests/scalability/report*/**", "tests/scalability/jmeter/**", "webapp/src/ts/providers/xpath-element-path.provider.ts" diff --git a/.mocharc.js b/.mocharc.js new file mode 100644 index 00000000000..51771015629 --- /dev/null +++ b/.mocharc.js @@ -0,0 +1,6 @@ +const chaiExclude = require('chai-exclude'); +const chaiAsPromised = require('chai-as-promised'); +const chai = require('chai'); + +chai.use(chaiExclude); +chai.use(chaiAsPromised); diff --git a/admin/package-lock.json b/admin/package-lock.json index f86c9255673..8a2bff8d39f 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -45,8 +45,8 @@ "moment": "^2.29.1" } }, - "../shared-libs/cht-script-api": { - "name": "@medic/cht-script-api", + "../shared-libs/cht-datasource": { + "name": "@medic/cht-datasource", "version": "1.0.0", "extraneous": true, "license": "Apache-2.0" diff --git a/admin/src/js/services/auth.js b/admin/src/js/services/auth.js index c8f9b164d59..74fbd46cdb8 100644 --- a/admin/src/js/services/auth.js +++ b/admin/src/js/services/auth.js @@ -1,4 +1,5 @@ -const chtScriptApi = require('@medic/cht-script-api'); +const cht = require('@medic/cht-datasource'); +const chtDatasource = cht.getDatasource(cht.getRemoteDataContext()); angular.module('inboxServices').factory('Auth', function( @@ -36,7 +37,7 @@ angular.module('inboxServices').factory('Auth', return false; } - return chtScriptApi.v1.hasAnyPermission(permissionsGroupList, userCtx.roles, settings.permissions); + return chtDatasource.v1.hasAnyPermission(permissionsGroupList, userCtx.roles, settings.permissions); }) .catch(() => false); }; @@ -62,7 +63,7 @@ angular.module('inboxServices').factory('Auth', return false; } - return chtScriptApi.v1.hasPermissions(permissions, userCtx.roles, settings.permissions); + return chtDatasource.v1.hasPermissions(permissions, userCtx.roles, settings.permissions); }) .catch(() => false); }; diff --git a/api/.eslintrc b/api/.eslintrc index d6e5feac063..f47fb1b0aac 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -14,7 +14,7 @@ { "allowModules": [ "@medic/bulk-docs-utils", - "@medic/cht-script-api", + "@medic/cht-datasource", "@medic/contact-types-utils", "@medic/contacts", "@medic/couch-request", diff --git a/api/package.json b/api/package.json index 4361f1edd82..be506165310 100644 --- a/api/package.json +++ b/api/package.json @@ -13,7 +13,8 @@ }, "scripts": { "toc": "doctoc --github --maxlevel 2 README.md", - "postinstall": "patch-package" + "postinstall": "patch-package", + "run-watch": "TZ=UTC nodemon --inspect=0.0.0.0:9229 --ignore 'build/static' --ignore 'build/public' --watch ./ --watch '../shared-libs/**/src/**' server.js -- --allow-cors" }, "dependencies": { "@medic/logger": "file:../shared-libs/logger", diff --git a/api/src/controllers/person.js b/api/src/controllers/person.js new file mode 100644 index 00000000000..34cb257df84 --- /dev/null +++ b/api/src/controllers/person.js @@ -0,0 +1,20 @@ +const { Person, Qualifier } = require('@medic/cht-datasource'); +const ctx = require('../services/data-context'); +const serverUtils = require('../server-utils'); +const auth = require('../auth'); + +const getPerson = (qualifier) => ctx.bind(Person.v1.get)(qualifier); + +module.exports = { + v1: { + get: serverUtils.doOrError(async (req, res) => { + await auth.check(req, 'can_view_contacts'); + const { uuid } = req.params; + const person = await getPerson(Qualifier.byUuid(uuid)); + if (!person) { + return serverUtils.error({ status: 404, message: 'Person not found' }, req, res); + } + return res.json(person); + }) + } +}; diff --git a/api/src/routing.js b/api/src/routing.js index 10567b2bb4c..90d274016fc 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -35,6 +35,7 @@ const exportData = require('./controllers/export-data'); const records = require('./controllers/records'); const forms = require('./controllers/forms'); const users = require('./controllers/users'); +const person = require('./controllers/person'); const { people, places } = require('@medic/contacts')(config, db); const upgrade = require('./controllers/upgrade'); const settings = require('./controllers/settings'); @@ -475,6 +476,8 @@ app.postJson('/api/v1/people', function(req, res) { .catch(err => serverUtils.error(err, req, res)); }); +app.get('/api/v1/person/:uuid', person.v1.get); + app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete); // offline users are not allowed to hydrate documents via the hydrate API diff --git a/api/src/server-utils.js b/api/src/server-utils.js index b1c7e75c2c4..c5472105032 100644 --- a/api/src/server-utils.js +++ b/api/src/server-utils.js @@ -130,4 +130,12 @@ module.exports = { }, wantsJSON, + + doOrError: (fn) => async (req, res) => { + try { + return await fn(req, res); + } catch (err) { + module.exports.error(err, req, res); + } + } }; diff --git a/api/src/services/data-context.js b/api/src/services/data-context.js new file mode 100644 index 00000000000..18f46f263c2 --- /dev/null +++ b/api/src/services/data-context.js @@ -0,0 +1,5 @@ +const { getLocalDataContext } = require('@medic/cht-datasource'); +const db = require('../db'); +const config = require('../config'); + +module.exports = getLocalDataContext(config, db); diff --git a/api/tests/mocha/controllers/person.spec.js b/api/tests/mocha/controllers/person.spec.js new file mode 100644 index 00000000000..cdc2785cf05 --- /dev/null +++ b/api/tests/mocha/controllers/person.spec.js @@ -0,0 +1,90 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); +const { Person, Qualifier } = require('@medic/cht-datasource'); +const auth = require('../../../src/auth'); +const controller = require('../../../src/controllers/person'); +const dataContext = require('../../../src/services/data-context'); +const serverUtils = require('../../../src/server-utils'); + +describe('Person Controller', () => { + let authCheck; + let dataContextBind; + let serverUtilsError; + let req; + let res; + + beforeEach(() => { + authCheck = sinon.stub(auth, 'check'); + dataContextBind = sinon.stub(dataContext, 'bind'); + serverUtilsError = sinon.stub(serverUtils, 'error'); + res = { + json: sinon.stub(), + }; + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const qualifier = Object.freeze({ uuid: 'uuid' }); + let byUuid; + let personGet; + + beforeEach(() => { + req = { params: { uuid: 'uuid' } }; + byUuid = sinon + .stub(Qualifier, 'byUuid') + .returns(qualifier); + personGet = sinon.stub(); + dataContextBind + .withArgs(Person.v1.get) + .returns(personGet); + }); + + it('returns a person', async () => { + const person = { name: 'John Doe' }; + personGet.resolves(person); + + await controller.v1.get(req, res); + + expect(authCheck.calledOnceWithExactly(req, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(byUuid.calledOnceWithExactly(req.params.uuid)).to.be.true; + expect(personGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(res.json.calledOnceWithExactly(person)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + + it('returns a 404 error if person is not found', async () => { + personGet.resolves(null); + + await controller.v1.get(req, res); + + expect(authCheck.calledOnceWithExactly(req, 'can_view_contacts')).to.be.true; + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(byUuid.calledOnceWithExactly(req.params.uuid)).to.be.true; + expect(personGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly( + { status: 404, message: 'Person not found' }, + req, + res + )).to.be.true; + }); + + it('returns error if user unauthorized', async () => { + const error = new Error('Unauthorized'); + authCheck.rejects(error); + + await controller.v1.get(req, res); + + expect(authCheck.calledOnceWithExactly(req, 'can_view_contacts')).to.be.true; + expect(dataContextBind.notCalled).to.be.true; + expect(byUuid.notCalled).to.be.true; + expect(personGet.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + }); + }); +}); diff --git a/api/tests/mocha/server-utils.spec.js b/api/tests/mocha/server-utils.spec.js index b9b109681c5..2cea26f833f 100644 --- a/api/tests/mocha/server-utils.spec.js +++ b/api/tests/mocha/server-utils.spec.js @@ -243,4 +243,31 @@ describe('Server utils', () => { }); }); + describe('doOrError', () => { + let serverUtilsError; + + beforeEach(() => { + serverUtilsError = sinon.stub(serverUtils, 'error'); + }); + + it('returns the function output when no error is thrown', async () => { + const fn = sinon.stub().resolves('result'); + + const result = await serverUtils.doOrError(fn)(req, res); + + chai.expect(result).to.equal('result'); + chai.expect(fn.calledOnceWithExactly(req, res)).to.be.true; + chai.expect(serverUtilsError.notCalled).to.be.true; + }); + + it('calls error when an error is thrown', async () => { + const error = new Error('error'); + const fn = sinon.stub().rejects(error); + + await serverUtils.doOrError(fn)(req, res); + + chai.expect(fn.calledOnceWithExactly(req, res)).to.be.true; + chai.expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true; + }); + }); }); diff --git a/api/tests/mocha/services/data-context.spec.js b/api/tests/mocha/services/data-context.spec.js new file mode 100644 index 00000000000..de0d5a2014c --- /dev/null +++ b/api/tests/mocha/services/data-context.spec.js @@ -0,0 +1,14 @@ +const dataSource = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const db = require('../../../src/db'); +const config = require('../../../src/config'); +const dataContext = require('../../../src/services/data-context'); + +describe('Data context service', () => { + it('is initialized with the methods from the data context', () => { + const expectedDataContext = dataSource.getLocalDataContext(config, db); + + expect(dataContext.bind).is.a('function'); + expect(dataContext).excluding('bind').to.deep.equal(expectedDataContext); + }); +}); diff --git a/package-lock.json b/package-lock.json index 2e1a6c70dcd..01a63f38dc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@commitlint/config-conventional": "^19.2.2", "@faker-js/faker": "^8.0.2", "@medic/eslint-config": "^1.1.0", + "@tsconfig/node20": "^20.1.4", "@types/chai": "^4.3.6", "@types/chai-as-promised": "^7.1.6", "@types/jquery": "^3.5.19", @@ -70,12 +71,14 @@ "eslint-plugin-compat": "^4.2.0", "eslint-plugin-couchdb": "^0.2.0", "eslint-plugin-jasmine": "^4.1.3", + "eslint-plugin-jsdoc": "^48.2.5", "eslint-plugin-json": "^3.1.0", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.1.1", "eurodigit": "^3.1.3", "express": "^4.19.2", + "fetch-cookie": "^3.0.1", "flat": "^6.0.0", "gaze": "^1.1.3", "husky": "^8.0.0", @@ -4433,6 +4436,23 @@ "node": ">=10.0.0" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz", + "integrity": "sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ==", + "dev": true, + "dependencies": { + "@types/eslint": "^8.56.5", + "@types/estree": "^1.0.5", + "@typescript-eslint/types": "^7.2.0", + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.1.tgz", @@ -5317,8 +5337,8 @@ "resolved": "shared-libs/calendar-interval", "link": true }, - "node_modules/@medic/cht-script-api": { - "resolved": "shared-libs/cht-script-api", + "node_modules/@medic/cht-datasource": { + "resolved": "shared-libs/cht-datasource", "link": true }, "node_modules/@medic/contact-types-utils": { @@ -6872,6 +6892,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -9806,6 +9832,15 @@ "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -11100,6 +11135,18 @@ "node": ">=0.2.0" } }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/builtin-status-codes": { "version": "3.0.0", "dev": true, @@ -13146,6 +13193,15 @@ "dev": true, "license": "MIT" }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", @@ -16739,6 +16795,63 @@ "npm": ">=6" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "48.2.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.5.tgz", + "integrity": "sha512-ZeTfKV474W1N9niWfawpwsXGu+ZoMXu4417eBROX31d7ZuOk8zyG66SO77DpJ2+A9Wa2scw/jRqBPnnQo7VbcQ==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.43.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.6.1", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/eslint-plugin-json": { "version": "3.1.0", "dev": true, @@ -17980,15 +18093,37 @@ } }, "node_modules/fetch-cookie": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", - "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.0.1.tgz", + "integrity": "sha512-ZGXe8Y5Z/1FWqQ9q/CrJhkUD73DyBU9VF0hBQmEO/wPHe4A9PKTjplFDLeFX8aOsYypZUcX5Ji/eByn3VCVO3Q==", "dev": true, "dependencies": { - "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fetch-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" }, "engines": { - "node": ">=8" + "node": ">=6" + } + }, + "node_modules/fetch-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/figgy-pudding": { @@ -20284,6 +20419,21 @@ "version": "1.1.6", "license": "MIT" }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-callable": { "version": "1.2.3", "dev": true, @@ -21643,6 +21793,15 @@ "node": ">=12.0.0" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jsdoc/node_modules/escape-string-regexp": { "version": "2.0.0", "dev": true, @@ -27948,6 +28107,18 @@ "node-fetch": "2.6.7" } }, + "node_modules/pouchdb-fetch/node_modules/fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "dependencies": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pouchdb-json": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/pouchdb-json/-/pouchdb-json-7.3.1.tgz", @@ -28019,6 +28190,18 @@ "npm": ">=8.3.1" } }, + "node_modules/pouchdb-session-authentication/node_modules/fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "dependencies": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pouchdb-session-authentication/node_modules/pouchdb-fetch": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/pouchdb-fetch/-/pouchdb-fetch-8.0.1.tgz", @@ -28417,6 +28600,12 @@ "node": ">=0.4.x" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -29924,6 +30113,12 @@ "dev": true, "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "node_modules/set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", @@ -32579,6 +32774,16 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/url-parse-lax": { "version": "3.0.0", "dev": true, @@ -35723,10 +35928,25 @@ "moment": "^2.29.1" } }, - "shared-libs/cht-script-api": { - "name": "@medic/cht-script-api", + "shared-libs/cht-datasource": { + "name": "@medic/cht-datasource", "version": "1.0.0", - "license": "Apache-2.0" + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/logger": "file:../logger" + } + }, + "shared-libs/cht-datasource2": { + "name": "@medic/cht-datasource2", + "version": "1.0.0", + "extraneous": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@medic/cht-datasource": "file:../cht-datasource" + } }, "shared-libs/contact-types-utils": { "name": "@medic/contact-types-utils", @@ -38954,6 +39174,20 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@es-joy/jsdoccomment": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.43.0.tgz", + "integrity": "sha512-Q1CnsQrytI3TlCB1IVWXWeqUIPGVEKGaE7IbVdt13Nq/3i0JESAkQQERrfiQkmlpijl+++qyqPgaS31Bvc1jRQ==", + "dev": true, + "requires": { + "@types/eslint": "^8.56.5", + "@types/estree": "^1.0.5", + "@typescript-eslint/types": "^7.2.0", + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + } + }, "@esbuild/aix-ppc64": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.1.tgz", @@ -39495,8 +39729,12 @@ "moment": "^2.29.1" } }, - "@medic/cht-script-api": { - "version": "file:shared-libs/cht-script-api" + "@medic/cht-datasource": { + "version": "file:shared-libs/cht-datasource", + "requires": { + "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/logger": "file:../logger" + } }, "@medic/contact-types-utils": { "version": "file:shared-libs/contact-types-utils" @@ -40628,6 +40866,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "dev": true + }, "@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -42893,6 +43137,12 @@ "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, + "are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true + }, "arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -43875,6 +44125,12 @@ "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", "dev": true }, + "builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true + }, "builtin-status-codes": { "version": "3.0.0", "dev": true @@ -45358,6 +45614,12 @@ "version": "2.20.3", "dev": true }, + "comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true + }, "common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", @@ -48170,6 +48432,47 @@ "integrity": "sha512-q8j8KnLH/4uwmPELFZvEyfEcuCuGxXScJaRdqHjOjz064GcfX6aoFbzy5VohZ5QYk2+WvoqMoqDSb9nRLf89GQ==", "dev": true }, + "eslint-plugin-jsdoc": { + "version": "48.2.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.5.tgz", + "integrity": "sha512-ZeTfKV474W1N9niWfawpwsXGu+ZoMXu4417eBROX31d7ZuOk8zyG66SO77DpJ2+A9Wa2scw/jRqBPnnQo7VbcQ==", + "dev": true, + "requires": { + "@es-joy/jsdoccomment": "~0.43.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.6.1", + "spdx-expression-parse": "^4.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true + }, + "spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + } + } + }, "eslint-plugin-json": { "version": "3.1.0", "dev": true, @@ -48833,12 +49136,33 @@ } }, "fetch-cookie": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", - "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.0.1.tgz", + "integrity": "sha512-ZGXe8Y5Z/1FWqQ9q/CrJhkUD73DyBU9VF0hBQmEO/wPHe4A9PKTjplFDLeFX8aOsYypZUcX5Ji/eByn3VCVO3Q==", "dev": true, "requires": { - "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + }, + "dependencies": { + "tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + } + }, + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true + } } }, "figgy-pudding": { @@ -50464,6 +50788,15 @@ "is-buffer": { "version": "1.1.6" }, + "is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "requires": { + "builtin-modules": "^3.3.0" + } + }, "is-callable": { "version": "1.2.3", "dev": true @@ -51430,6 +51763,12 @@ } } }, + "jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true + }, "jsesc": { "version": "2.5.2", "dev": true @@ -56117,6 +56456,17 @@ "abort-controller": "3.0.0", "fetch-cookie": "0.11.0", "node-fetch": "2.6.7" + }, + "dependencies": { + "fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "requires": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + } + } } }, "pouchdb-json": { @@ -56186,6 +56536,15 @@ "pouchdb-fetch": "^8.0.1" }, "dependencies": { + "fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "dev": true, + "requires": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + } + }, "pouchdb-fetch": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/pouchdb-fetch/-/pouchdb-fetch-8.0.1.tgz", @@ -56494,6 +56853,12 @@ "version": "0.2.1", "dev": true }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -57610,6 +57975,12 @@ "version": "2.0.0", "dev": true }, + "set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", @@ -59556,6 +59927,16 @@ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "url-parse-lax": { "version": "3.0.0", "dev": true, diff --git a/package.json b/package.json index a12ec2c1e6d..eb85f593807 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,14 @@ "prepare": "husky install", "-- DEV BUILD SCRIPTS": "-----------------------------------------------------------------------------------------------", "build-ddocs": "mkdir -p build/ddocs && cp -r ddocs/* build/ddocs/ && node scripts/build/cli setDdocsVersion && node scripts/build/cli setBuildInfo && node ./scripts/build/ddoc-compile.js primary && mkdir -p api/build/ddocs && cp build/ddocs/*.json api/build/ddocs/", - "build-dev": "./scripts/build/build-prepare.sh && npm run build-webapp-dev && ./scripts/build/copy-static-files.sh", - "build-dev-watch": "npm run build-dev && cd webapp && npm run build -- --configuration=development --watch=true & node ./scripts/build/watch.js", + "build-dev": "./scripts/build/build-prepare.sh && npm run --prefix shared-libs/cht-datasource build && npm run build-webapp-dev && ./scripts/build/copy-static-files.sh", + "build-dev-watch": "npm run build-dev && (npm run --prefix shared-libs/cht-datasource build-watch & npm run --prefix webapp build-watch & node ./scripts/build/watch.js)", "build-documentation": "./scripts/build/build-documentation.sh", "build-webapp-dev": "cd webapp && npm run build -- --configuration=development && npm run compile", "build-cht-form": "./scripts/build/build-prepare.sh && cd webapp && npm run build:cht-form", "copy-api-resources": "cp -r api/src/public/* api/build/static/", - "dev-api": "./scripts/build/copy-static-files.sh && TZ=UTC nodemon --inspect=0.0.0.0:9229 --ignore 'api/build/static' --ignore 'api/build/public' --watch api --watch 'shared-libs/**/src/**' api/server.js -- --allow-cors", - "dev-sentinel": "TZ=UTC nodemon --inspect=0.0.0.0:9228 --watch sentinel --watch 'shared-libs/**/src/**' sentinel/server.js", + "dev-api": "./scripts/build/copy-static-files.sh && (npm run --prefix shared-libs/cht-datasource build-watch & npm run --prefix api run-watch)", + "dev-sentinel": "npm run --prefix shared-libs/cht-datasource build-watch & npm run --prefix sentinel run-watch", "local-images": "export VERSION=$(node ./scripts/build/get-version.js) && ./scripts/build/build-service-images.sh && node scripts/build/cli localDockerComposeFiles", "update-service-worker": "node scripts/build/cli updateServiceWorker", "-- DEV TEST SCRIPTS": "-----------------------------------------------------------------------------------------------", @@ -80,6 +80,7 @@ "@commitlint/config-conventional": "^19.2.2", "@faker-js/faker": "^8.0.2", "@medic/eslint-config": "^1.1.0", + "@tsconfig/node20": "^20.1.4", "@types/chai": "^4.3.6", "@types/chai-as-promised": "^7.1.6", "@types/jquery": "^3.5.19", @@ -119,12 +120,14 @@ "eslint-plugin-compat": "^4.2.0", "eslint-plugin-couchdb": "^0.2.0", "eslint-plugin-jasmine": "^4.1.3", + "eslint-plugin-jsdoc": "^48.2.5", "eslint-plugin-json": "^3.1.0", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.1.1", "eurodigit": "^3.1.3", "express": "^4.19.2", + "fetch-cookie": "^3.0.1", "flat": "^6.0.0", "gaze": "^1.1.3", "husky": "^8.0.0", diff --git a/sentinel/.eslintrc b/sentinel/.eslintrc index 7e536c8bf48..a71108b32bb 100644 --- a/sentinel/.eslintrc +++ b/sentinel/.eslintrc @@ -13,7 +13,7 @@ { "allowModules": [ "@medic/bulk-docs-utils", - "@medic/cht-script-api", + "@medic/cht-datasource", "@medic/contact-types-utils", "@medic/contacts", "@medic/couch-request", diff --git a/sentinel/package-lock.json b/sentinel/package-lock.json index d3f0d97bacf..ff65e474b62 100644 --- a/sentinel/package-lock.json +++ b/sentinel/package-lock.json @@ -39,7 +39,7 @@ "npm": ">=10.2.4" } }, - "../shared-libs/cht-script-api": { + "../shared-libs/cht-datasource": { "version": "1.0.0", "extraneous": true, "license": "Apache-2.0" diff --git a/sentinel/package.json b/sentinel/package.json index 27d85cbd6c0..1ef969a5293 100644 --- a/sentinel/package.json +++ b/sentinel/package.json @@ -7,6 +7,9 @@ "node": ">=20.11.0", "npm": ">=10.2.4" }, + "scripts": { + "run-watch": "TZ=UTC nodemon --inspect=0.0.0.0:9228 --watch server.js --watch 'src/**' --watch '../shared-libs/**/src/**' --watch '../shared-libs/**/dist/**' server.js" + }, "dependencies": { "@medic/logger": "file:../shared-libs/logger", "async": "^3.2.4", diff --git a/sentinel/src/lib/purging.js b/sentinel/src/lib/purging.js index 82d8b2fccfc..8e5a9b46acb 100644 --- a/sentinel/src/lib/purging.js +++ b/sentinel/src/lib/purging.js @@ -1,7 +1,7 @@ const config = require('../config'); const registrationUtils = require('@medic/registration-utils'); const serverSidePurgeUtils = require('@medic/purging-utils'); -const chtScriptApi = require('@medic/cht-script-api'); +const cht = require('@medic/cht-datasource'); const logger = require('@medic/logger'); const { performance } = require('perf_hooks'); const db = require('../db'); @@ -15,6 +15,8 @@ const VIEW_LIMIT = 100 * 1000; const MAX_BATCH_SIZE = 20 * 1000; const MIN_BATCH_SIZE = 5 * 1000; const MAX_BATCH_SIZE_REACHED = 'max_size_reached'; + +const dataContext = cht.getLocalDataContext(config, db); let contactsBatchSize = MAX_CONTACT_BATCH_SIZE; let skippedContacts = []; @@ -346,7 +348,7 @@ const getDocsToPurge = (purgeFn, groups, roles) => { group.contact, group.reports, group.messages, - chtScriptApi, + cht.getDatasource(dataContext), permissionSettings ); if (!validPurgeResults(idsToPurge)) { @@ -454,13 +456,14 @@ const purgeUnallocatedRecords = async (roles, purgeFn) => { const getIdsToPurge = (rolesHashes, rows) => { const toPurge = {}; + const datasource = cht.getDatasource(dataContext); rows.forEach(row => { const doc = row.doc; rolesHashes.forEach(hash => { toPurge[hash] = toPurge[hash] || {}; const purgeIds = doc.form ? - purgeFn({ roles: roles[hash] }, {}, [doc], [], chtScriptApi, permissionSettings) : - purgeFn({ roles: roles[hash] }, {}, [], [doc], chtScriptApi, permissionSettings); + purgeFn({ roles: roles[hash] }, {}, [doc], [], datasource, permissionSettings) : + purgeFn({ roles: roles[hash] }, {}, [], [doc], datasource, permissionSettings); if (!validPurgeResults(purgeIds)) { return; diff --git a/sentinel/tests/unit/lib/purging.spec.js b/sentinel/tests/unit/lib/purging.spec.js index b8b31b7ad9c..006e406d73e 100644 --- a/sentinel/tests/unit/lib/purging.spec.js +++ b/sentinel/tests/unit/lib/purging.spec.js @@ -11,7 +11,7 @@ const registrationUtils = require('@medic/registration-utils'); const config = require('../../../src/config'); const purgingUtils = require('@medic/purging-utils'); const db = require('../../../src/db'); -const chtScriptApi = require('@medic/cht-script-api'); +const chtDatasource = require('@medic/cht-datasource'); let service; let clock; @@ -2532,13 +2532,14 @@ describe('ServerSidePurge', () => { const purgeDbChanges = sinon.stub().resolves({ results: [] }); sinon.stub(db, 'get').returns({ changes: purgeDbChanges, bulkDocs: sinon.stub() }); sinon.stub(config, 'get').returns({ can_export_messages: [ 1 ]}); - sinon.stub(chtScriptApi.v1, 'hasPermissions'); + const mockDatasource = { v1: { hasPermissions: sinon.stub() } }; + sinon.stub(chtDatasource, 'getDatasource').returns(mockDatasource); return service.__get__('batchedContactsPurge')(roles, purgeFunction).then(() => { - chai.expect(chtScriptApi.v1.hasPermissions.args[0]).to.deep.equal( + chai.expect(mockDatasource.v1.hasPermissions.args[0]).to.deep.equal( [ 'can_export_messages', [ 1, 2, 3 ], { can_export_messages: [ 1 ] } ] ); - chai.expect(chtScriptApi.v1.hasPermissions.args[1]).to.deep.equal( + chai.expect(mockDatasource.v1.hasPermissions.args[1]).to.deep.equal( [ 'can_export_messages', [ 4, 5, 6 ], { can_export_messages: [ 1 ] } ] ); }); @@ -2558,13 +2559,14 @@ describe('ServerSidePurge', () => { const purgeDbChanges = sinon.stub().resolves({ results: [] }); sinon.stub(db, 'get').returns({ changes: purgeDbChanges, bulkDocs: sinon.stub() }); sinon.stub(config, 'get').returns({ can_export_messages: [ 1 ]}); - sinon.stub(chtScriptApi.v1, 'hasAnyPermission'); + const mockDatasource = { v1: { hasAnyPermission: sinon.stub() } }; + sinon.stub(chtDatasource, 'getDatasource').returns(mockDatasource); return service.__get__('batchedContactsPurge')(roles, purgeFunction).then(() => { - chai.expect(chtScriptApi.v1.hasAnyPermission.args[0]).to.deep.equal( + chai.expect(mockDatasource.v1.hasAnyPermission.args[0]).to.deep.equal( [ ['can_export_messages', 'can_edit'], [ 1, 2, 3 ], { can_export_messages: [ 1 ] } ] ); - chai.expect(chtScriptApi.v1.hasAnyPermission.args[1]).to.deep.equal( + chai.expect(mockDatasource.v1.hasAnyPermission.args[1]).to.deep.equal( [ ['can_export_messages', 'can_edit'], [ 4, 5, 6 ], { can_export_messages: [ 1 ] } ] ); }); diff --git a/shared-libs/cht-datasource/.eslintrc.js b/shared-libs/cht-datasource/.eslintrc.js new file mode 100644 index 00000000000..2fb0e04ddb8 --- /dev/null +++ b/shared-libs/cht-datasource/.eslintrc.js @@ -0,0 +1,48 @@ +module.exports = { + overrides: [ + { + files: ['*.ts'], + extends: [ + // https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/strict-type-checked.ts + 'plugin:@typescript-eslint/strict-type-checked', + // https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/stylistic-type-checked.ts + 'plugin:@typescript-eslint/stylistic-type-checked', + 'plugin:jsdoc/recommended-typescript-error' + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'jsdoc'], + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname + }, + settings: { + jsdoc: { + contexts: [ + 'VariableDeclaration', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'TSEnumDeclaration', + 'TSMethodSignature' + ] + } + }, + rules: { + ['@typescript-eslint/explicit-module-boundary-types']: ['error', { allowedNames: ['getDatasource'] }], + ['@typescript-eslint/no-confusing-void-expression']: ['error', { ignoreArrowShorthand: true }], + ['@typescript-eslint/no-empty-interface']: ['error', { allowSingleExtends: true }], + ['@typescript-eslint/no-namespace']: 'off', + ['jsdoc/require-jsdoc']: ['error', { + require: { + ArrowFunctionExpression: true, + ClassDeclaration: true, + ClassExpression: true, + FunctionDeclaration: true, + FunctionExpression: true, + MethodDefinition: true, + }, + publicOnly: true, + }] + } + } + ] +}; diff --git a/shared-libs/cht-datasource/.mocharc.js b/shared-libs/cht-datasource/.mocharc.js new file mode 100644 index 00000000000..a13ab2feaf0 --- /dev/null +++ b/shared-libs/cht-datasource/.mocharc.js @@ -0,0 +1,7 @@ +const chaiAsPromised = require('chai-as-promised'); +const chai = require('chai'); +chai.use(chaiAsPromised); + +module.exports = { + require: 'ts-node/register' +}; diff --git a/shared-libs/cht-datasource/README.md b/shared-libs/cht-datasource/README.md new file mode 100644 index 00000000000..bc35948cf51 --- /dev/null +++ b/shared-libs/cht-datasource/README.md @@ -0,0 +1,30 @@ +# CHT Datasource + +The CHT Datasource library is intended to be agnostic and simple. It provides a versioned API from feature modules. + +See the TSDoc in [the code](./src/index.ts) for more information about using the API. + +## Development + +Functionality in cht-datasource is provided via two implementations. The [`local` adapter](./src/local) leverages the provided PouchDB instances for data interaction. This is intended for usage in cases where offline functionality is required (like webapp for offline users) or direct access to the Couch database is guaranteed (like api and sentinel). The [`remote` adapter](./src/remote) functions by proxying requests directly to the api server via HTTP. This is intended for usage in cases where connectivity to the api server is guaranteed (like in admin or webapp for online users). + +### Building cht-datasource + +The transpiled JavaScript code is generated in the [`dist` directory](./dist). The library is automatically built when running `npm ci` (either within the `cht-datasource` directory or from the root level). To manually build the library, run `npm run build`. To automatically re-build the library when any of the source files change, run `npm run build-watch`. + +The root level `build-dev-watch`, `dev-api`, and `dev-sentinel` scripts will automatically watch for changes in the cht-datasource code and rebuild the library. + +### Adding a new API + +When adding a new API to cht-datasource (whether it is a new concept or just a new interaction with an existing concept), the implementation must be completed at four levels: + +1) Implement the interaction in the [`local`](./src/local) and [`remote`](./src/remote) adapters. +2) Expose a unified interface for the interaction from the relevant top-level [concept module](./src). +3) Expose the new concept interaction by adding it to the datasource returned from the [index.ts](./src/index.ts). +4) Implement the necessary endpoint(s) in [api](../../api) to support the new interaction (these are the endpoints called by the remote adapter code). + +### Updating functionality + +Only passive changes should be made to the versioned public API's exposed by cht-datasource. Besides their usage in cht-core, these API's are available to custom configuration code for things like purging, tasks, targets, etc. If a non-passive change is needed, it should be made on a new version of the API. + +The previous version of the functionality should be marked as `@deprecated` and, where possible, all usages in the cht-core code should be updated to use the new API. diff --git a/shared-libs/cht-script-api/package-lock.json b/shared-libs/cht-datasource/package-lock.json similarity index 68% rename from shared-libs/cht-script-api/package-lock.json rename to shared-libs/cht-datasource/package-lock.json index b0c6f726b16..2a4be92f4f5 100644 --- a/shared-libs/cht-script-api/package-lock.json +++ b/shared-libs/cht-datasource/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@medic/cht-script-api", + "name": "@medic/cht-datasource", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@medic/cht-script-api", + "name": "@medic/cht-datasource", "version": "1.0.0", "license": "Apache-2.0" } diff --git a/shared-libs/cht-datasource/package.json b/shared-libs/cht-datasource/package.json new file mode 100644 index 00000000000..151080a2a7a --- /dev/null +++ b/shared-libs/cht-datasource/package.json @@ -0,0 +1,19 @@ +{ + "name": "@medic/cht-datasource", + "version": "1.0.0", + "description": "Provides an API for the CHT data model", + "main": "dist/index.js", + "files": [ "/dist" ], + "scripts": { + "postinstall": "rm -rf dist && npm run build", + "build": "tsc -p tsconfig.build.json", + "build-watch": "tsc --watch -p tsconfig.build.json", + "test": "nyc --nycrcPath='../nyc.config.js' mocha \"test/**/*\"" + }, + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/logger": "file:../logger" + } +} diff --git a/shared-libs/cht-script-api/src/auth.js b/shared-libs/cht-datasource/src/auth.js similarity index 87% rename from shared-libs/cht-script-api/src/auth.js rename to shared-libs/cht-datasource/src/auth.js index 69253e9c0a3..99cefa06b9c 100644 --- a/shared-libs/cht-script-api/src/auth.js +++ b/shared-libs/cht-datasource/src/auth.js @@ -65,6 +65,22 @@ const verifyParameters = (permissions, userRoles, chtPermissionsSettings) => { return true; }; +const normalizePermissions = (permissions) => { + if (permissions && typeof permissions === 'string') { + return [permissions]; + } + return permissions; +}; + +const checkAdminPermissions = (disallowedGroupList, permissions, userRoles) => { + if (disallowedGroupList.every(permissions => permissions.length)) { + debug('Disallowed permission(s) found for admin', permissions, userRoles); + return false; + } + // Admin has the permissions automatically. + return true; +}; + /** * Verify if the user's role has the permission(s). * @param permissions {string | string[]} Permission(s) to verify @@ -73,9 +89,7 @@ const verifyParameters = (permissions, userRoles, chtPermissionsSettings) => { * @return {boolean} */ const hasPermissions = (permissions, userRoles, chtPermissionsSettings) => { - if (permissions && typeof permissions === 'string') { - permissions = [ permissions ]; - } + permissions = normalizePermissions(permissions); if (!verifyParameters(permissions, userRoles, chtPermissionsSettings)) { return false; @@ -84,12 +98,7 @@ const hasPermissions = (permissions, userRoles, chtPermissionsSettings) => { const { allowed, disallowed } = groupPermissions(permissions); if (isAdmin(userRoles)) { - if (disallowed.length) { - debug('Disallowed permission(s) found for admin', permissions, userRoles); - return false; - } - // Admin has the permissions automatically. - return true; + return checkAdminPermissions([disallowed], permissions, userRoles); } const hasDisallowed = !checkUserHasPermissions(disallowed, userRoles, chtPermissionsSettings, false); @@ -135,12 +144,7 @@ const hasAnyPermission = (permissionsGroupList, userRoles, chtPermissionsSetting }); if (isAdmin(userRoles)) { - if (disallowedGroupList.every(permissions => permissions.length)) { - debug('Disallowed permission(s) found for admin', permissionsGroupList, userRoles); - return false; - } - // Admin has the permissions automatically. - return true; + return checkAdminPermissions(disallowedGroupList, permissionsGroupList, userRoles); } const hasAnyPermissionGroup = permissionsGroupList.some((permissions, i) => { diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts new file mode 100644 index 00000000000..8ecf51471b0 --- /dev/null +++ b/shared-libs/cht-datasource/src/index.ts @@ -0,0 +1,63 @@ +/** + * CHT datasource. + * + * This module provides a simple API for interacting with CHT data. To get started, obtain a {@link DataContext}. Then + * use the context to perform data operations. There are two different usage modes available for performing the same + * operations. + * @example Get Data Context: + * import { getRemoteDataContext, getLocalDataContext } from '@medic/cht-datasource'; + * + * const dataContext = isOnlineOnly + * ? getRemoteDataContext(...) + * : getLocalDataContext(...); + * @example Declarative usage mode: + * import { Person, Qualifier } from '@medic/cht-datasource'; + * + * const getPerson = Person.v1.get(dataContext); + * // Or + * const getPerson = dataContext.get(Person.v1.get); + * + * const myUuid = 'my-uuid'; + * const myPerson = await getPerson(Qualifier.byUuid(uuid)); + * @example Imperative usage mode: + * import { getDatasource } from '@medic/cht-datasource'; + * + * const datasource = getDatasource(dataContext); + * const myUuid = 'my-uuid'; + * const myPerson = await datasource.v1.person.getByUuid(myUuid); + */ +import { hasAnyPermission, hasPermissions } from './auth'; +import { assertDataContext, DataContext } from './libs/data-context'; +import * as Person from './person'; +import * as Qualifier from './qualifier'; + +export { Nullable, NonEmptyArray } from './libs/core'; +export { DataContext } from './libs/data-context'; +export { getLocalDataContext } from './local'; +export { getRemoteDataContext } from './remote'; +export * as Person from './person'; +export * as Qualifier from './qualifier'; + +/** + * Returns the source for CHT data. + * @param ctx the current data context + * @returns the CHT datasource API + * @throws Error if the provided context is invalid + */ +export const getDatasource = (ctx: DataContext) => { + assertDataContext(ctx); + return { + v1: { + hasPermissions, + hasAnyPermission, + person: { + /** + * Returns a person by their UUID. + * @param uuid the UUID of the person to retrieve + * @returns the person or `null` if no person is found for the UUID + */ + getByUuid: (uuid: string) => ctx.bind(Person.v1.get)(Qualifier.byUuid(uuid)), + } + } + }; +}; diff --git a/shared-libs/cht-datasource/src/libs/contact.ts b/shared-libs/cht-datasource/src/libs/contact.ts new file mode 100644 index 00000000000..8e9a3c2b8b8 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/contact.ts @@ -0,0 +1,16 @@ +import { Doc } from './doc'; +import { DataObject } from './core'; + +interface NormalizedParent extends DataObject { + readonly _id: string; + readonly parent?: NormalizedParent; +} + +/** @internal */ +export interface Contact extends Doc { + readonly contact_type?: string; + readonly name?: string; + readonly parent?: NormalizedParent; + readonly reported_date?: Date; + readonly type: string; +} diff --git a/shared-libs/cht-datasource/src/libs/core.ts b/shared-libs/cht-datasource/src/libs/core.ts new file mode 100644 index 00000000000..1540897d909 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -0,0 +1,44 @@ +import { DataContext } from './data-context'; + +/** + * A value that could be `null`. + */ +export type Nullable = T | null; + +/** + * An array that is guaranteed to have at least one element. + */ +export type NonEmptyArray = [T, ...T[]]; + +type DataPrimitive = string | number | boolean | Date | null | undefined; +interface DataArray extends Readonly<(DataPrimitive | DataArray | DataObject)[]> { } + +/** @internal */ +export interface DataObject extends Readonly> { } + +/** @internal */ +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +/** @internal */ +export const isRecord = (value: unknown): value is Record => { + return value !== null && typeof value === 'object'; +}; + +/** @internal */ +export const hasField = (value: Record, field: { name: string, type: string }): boolean => { + const valueField = value[field.name]; + return typeof valueField === field.type; +}; + +/** @internal */ +export const hasFields = ( + value: Record, + fields: NonEmptyArray<{ name: string, type: string }> +): boolean => fields.every(field => hasField(value, field)); + +/** @internal */ +export abstract class AbstractDataContext implements DataContext { + readonly bind = (fn: (ctx: DataContext) => T): T => fn(this); +} diff --git a/shared-libs/cht-datasource/src/libs/data-context.ts b/shared-libs/cht-datasource/src/libs/data-context.ts new file mode 100644 index 00000000000..d58c017c780 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/data-context.ts @@ -0,0 +1,41 @@ +import { hasField, isRecord } from './core'; +import { isLocalDataContext, LocalDataContext } from '../local/libs/data-context'; +import { assertRemoteDataContext, isRemoteDataContext, RemoteDataContext } from '../remote/libs/data-context'; + +/** + * Context for interacting with the data. This may represent a local data context where data can be accessed even while + * offline. Or it may represent a remote data context where all data operations are performed against a remote CHT + * instance. + */ +export interface DataContext { + /** + * Executes the provided function with this data context as the argument. + * @param fn the function to execute + * @returns the result of the function + */ + bind: (fn: (ctx: DataContext) => T) => T +} + +const isDataContext = (context: unknown): context is DataContext => { + return isRecord(context) && hasField(context, { name: 'bind', type: 'function' }); +}; + +/** @internal */ +export const assertDataContext: (context: unknown) => asserts context is DataContext = (context: unknown) => { + if (!isDataContext(context) || !(isLocalDataContext(context) || isRemoteDataContext(context))) { + throw new Error(`Invalid data context [${JSON.stringify(context)}].`); + } +}; + +/** @internal */ +export const adapt = ( + context: DataContext, + local: (c: LocalDataContext) => T, + remote: (c: RemoteDataContext) => T +): T => { + if (isLocalDataContext(context)) { + return local(context); + } + assertRemoteDataContext(context); + return remote(context); +}; diff --git a/shared-libs/cht-datasource/src/libs/doc.ts b/shared-libs/cht-datasource/src/libs/doc.ts new file mode 100644 index 00000000000..d3a8f3f9964 --- /dev/null +++ b/shared-libs/cht-datasource/src/libs/doc.ts @@ -0,0 +1,17 @@ +import { DataObject, hasFields, isRecord } from './core'; + +/** + * A document from the database. + */ +export interface Doc extends DataObject { + readonly _id: string; + readonly _rev: string; +} + +/** @internal */ +export const isDoc = (value: unknown): value is Doc => { + return isRecord(value) && hasFields(value, [ + { name: '_id', type: 'string' }, + { name: '_rev', type: 'string' } + ]); +}; diff --git a/shared-libs/cht-datasource/src/local/index.ts b/shared-libs/cht-datasource/src/local/index.ts new file mode 100644 index 00000000000..cb595d45476 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/index.ts @@ -0,0 +1,2 @@ +export * as Person from './person'; +export { getLocalDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/local/libs/data-context.ts b/shared-libs/cht-datasource/src/local/libs/data-context.ts new file mode 100644 index 00000000000..50f0193dcce --- /dev/null +++ b/shared-libs/cht-datasource/src/local/libs/data-context.ts @@ -0,0 +1,58 @@ +import { Doc } from '../../libs/doc'; +import { AbstractDataContext, hasField, isRecord } from '../../libs/core'; +import { DataContext } from '../../libs/data-context'; + +/** + * {@link PouchDB.Database}s to be used as the local data source. + */ +export type SourceDatabases = Readonly<{ medic: PouchDB.Database }>; + +/** + * Service providing access to the app settings. These settings must be guaranteed to remain current for as long as the + * service is used. Settings data returned from future calls to service methods should reflect the current state of the + * system's settings at the time and not just the state of the settings when the service was first created. + */ +export type SettingsService = Readonly<{ getAll: () => Doc }>; + +/** @internal */ +export class LocalDataContext extends AbstractDataContext { + /** @internal */ + constructor( + readonly medicDb: PouchDB.Database, + readonly settings: SettingsService + ) { + super(); + } +} + +const assertSettingsService: (settings: unknown) => asserts settings is SettingsService = (settings: unknown) => { + if (!isRecord(settings) || !hasField(settings, { name: 'getAll', type: 'function' })) { + throw new Error(`Invalid settings service [${JSON.stringify(settings)}].`); + } +}; + +const assertSourceDatabases: (sourceDatabases: unknown) => asserts sourceDatabases is SourceDatabases = + (sourceDatabases: unknown) => { + if (!isRecord(sourceDatabases) || !hasField(sourceDatabases, { name: 'medic', type: 'object' })) { + throw new Error(`Invalid source databases [${JSON.stringify(sourceDatabases)}].`); + } + }; + +/** @internal */ +export const isLocalDataContext = (context: DataContext): context is LocalDataContext => { + return 'settings' in context && 'medicDb' in context; +}; + +/** + * Returns the data context for accessing data via the provided local sources This functionality is intended for use + * cases requiring offline functionality. For all other use cases, use {@link getRemoteDataContext}. + * @param settings service providing access to the app settings + * @param sourceDatabases the PouchDB databases to use as the local datasource + * @returns the local data context + * @throws Error if the provided settings or source databases are invalid + */ +export const getLocalDataContext = (settings: SettingsService, sourceDatabases: SourceDatabases): DataContext => { + assertSettingsService(settings); + assertSourceDatabases(sourceDatabases); + return new LocalDataContext(sourceDatabases.medic, settings); +}; diff --git a/shared-libs/cht-datasource/src/local/libs/doc.ts b/shared-libs/cht-datasource/src/local/libs/doc.ts new file mode 100644 index 00000000000..e1eead24604 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/libs/doc.ts @@ -0,0 +1,16 @@ +import logger from '@medic/logger'; +import { Nullable } from '../../libs/core'; +import { Doc, isDoc } from '../../libs/doc'; + +/** @internal */ +export const getDocById = (db: PouchDB.Database) => async (uuid: string): Promise> => db + .get(uuid) + .then(doc => isDoc(doc) ? doc : null) + .catch((err: unknown) => { + if ((err as PouchDB.Core.Error).status === 404) { + return null; + } + + logger.error(`Failed to fetch doc with id [${uuid}]`, err); + throw err; + }); diff --git a/shared-libs/cht-datasource/src/local/person.ts b/shared-libs/cht-datasource/src/local/person.ts new file mode 100644 index 00000000000..69c65a4b796 --- /dev/null +++ b/shared-libs/cht-datasource/src/local/person.ts @@ -0,0 +1,26 @@ +import { Doc } from '../libs/doc'; +import contactTypeUtils from '@medic/contact-types-utils'; +import { Nullable } from '../libs/core'; +import { UuidQualifier } from '../qualifier'; +import * as Person from '../person'; +import { getDocById } from './libs/doc'; +import { LocalDataContext, SettingsService } from './libs/data-context'; + +export namespace v1 { + /** @internal */ + const isPersonWithSettings = (settings: SettingsService) => (doc: Doc): doc is Person.v1.Person => contactTypeUtils + .isPerson(settings.getAll(), doc); + + /** @internal */ + export const get = ({ medicDb, settings }: LocalDataContext) => { + const getMedicDocById = getDocById(medicDb); + const isPerson = isPersonWithSettings(settings); + return async (identifier: UuidQualifier): Promise> => { + const doc = await getMedicDocById(identifier.uuid); + if (!doc || !isPerson(doc)) { + return null; + } + return doc; + }; + }; +} diff --git a/shared-libs/cht-datasource/src/person.ts b/shared-libs/cht-datasource/src/person.ts new file mode 100644 index 00000000000..028afcdd649 --- /dev/null +++ b/shared-libs/cht-datasource/src/person.ts @@ -0,0 +1,40 @@ +import { Nullable } from './libs/core'; +import { isUuidQualifier, UuidQualifier } from './qualifier'; +import { adapt, assertDataContext, DataContext } from './libs/data-context'; +import { Contact } from './libs/contact'; +import * as Remote from './remote'; +import * as Local from './local'; + +export namespace v1 { + /** + * Immutable data about a person contact. + */ + export interface Person extends Contact { + readonly date_of_birth?: Date; + readonly phone?: string; + readonly patient_id?: string; + readonly sex?: string; + } + + /** @internal */ + const assertPersonQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => { + if (!isUuidQualifier(qualifier)) { + throw new Error(`Invalid identifier [${JSON.stringify(qualifier)}].`); + } + }; + + /** + * Returns a person for the given qualifier. + * @param context the current data context + * @returns the person or `null` if no person is found for the qualifier + * @throws Error if the provided context or qualifier is invalid + */ + export const get = (context: DataContext) => { + assertDataContext(context); + const getPerson = adapt(context, Local.Person.v1.get, Remote.Person.v1.get); + return async (qualifier: UuidQualifier): Promise> => { + assertPersonQualifier(qualifier); + return getPerson(qualifier); + }; + }; +} diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts new file mode 100644 index 00000000000..5b37a18edf5 --- /dev/null +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -0,0 +1,29 @@ +import { isString, hasField, isRecord } from './libs/core'; + +/** + * A qualifier that identifies an entity by its UUID. + */ +export type UuidQualifier = Readonly<{ uuid: string }>; + +/** + * Builds a qualifier that identifies an entity by its UUID. + * @param uuid the UUID of the entity + * @returns the qualifier + * @throws Error if the UUID is invalid + */ +export const byUuid = (uuid: string): UuidQualifier => { + if (!isString(uuid) || uuid.length === 0) { + throw new Error(`Invalid UUID [${JSON.stringify(uuid)}].`); + } + return { uuid }; +}; + +/** + * Returns `true` if the given qualifier is a {@link UuidQualifier}, otherwise `false`. + * @param identifier the identifier to check + * @returns `true` if the given identifier is a {@link UuidQualifier}, otherwise + * `false` + */ +export const isUuidQualifier = (identifier: unknown): identifier is UuidQualifier => { + return isRecord(identifier) && hasField(identifier, { name: 'uuid', type: 'string' }); +}; diff --git a/shared-libs/cht-datasource/src/remote/index.ts b/shared-libs/cht-datasource/src/remote/index.ts new file mode 100644 index 00000000000..0adb95eb2fa --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/index.ts @@ -0,0 +1,2 @@ +export * as Person from './person'; +export { getRemoteDataContext } from './libs/data-context'; diff --git a/shared-libs/cht-datasource/src/remote/libs/data-context.ts b/shared-libs/cht-datasource/src/remote/libs/data-context.ts new file mode 100644 index 00000000000..02b5969dba4 --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/libs/data-context.ts @@ -0,0 +1,59 @@ +import logger from '@medic/logger'; +import { DataContext } from '../../libs/data-context'; +import { AbstractDataContext, isString, Nullable } from '../../libs/core'; + +/** @internal */ +export class RemoteDataContext extends AbstractDataContext { + /** @internal */ + constructor(readonly url: string) { + super(); + } +} + +/** @internal */ +export const isRemoteDataContext = (context: DataContext): context is RemoteDataContext => 'url' in context; + +/** @internal */ +export const assertRemoteDataContext: (context: DataContext) => asserts context is RemoteDataContext = ( + context: DataContext +) => { + if (!isRemoteDataContext(context)) { + throw new Error(`Invalid remote data context [${JSON.stringify(context)}].`); + } +}; + +/** + * Returns the data context based on a remote CHT API server. This function should not be used when offline + * functionality is required. + * @param url the URL of the remote CHT API server. If not provided, requests will be made relative to the current + * location. + * @returns the data context + */ +export const getRemoteDataContext = (url = ''): DataContext => { + if (!isString(url)) { + throw new Error(`Invalid URL [${JSON.stringify(url)}].`); + } + + return new RemoteDataContext(url); +}; + +/** @internal */ +export const get = ( + context: RemoteDataContext, + path = '' +) => async (resource: string): Promise> => { + try { + const response = await fetch(`${context.url}/${path}${resource}`); + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(response.statusText); + } + + return (await response.json()) as T; + } catch (error) { + logger.error(`Failed to fetch ${resource} from ${context.url}`, error); + throw error; + } +}; diff --git a/shared-libs/cht-datasource/src/remote/person.ts b/shared-libs/cht-datasource/src/remote/person.ts new file mode 100644 index 00000000000..72a5c664e76 --- /dev/null +++ b/shared-libs/cht-datasource/src/remote/person.ts @@ -0,0 +1,12 @@ +import { Nullable } from '../libs/core'; +import { UuidQualifier } from '../qualifier'; +import * as Person from '../person'; +import { get as GET, RemoteDataContext } from './libs/data-context'; + +export namespace v1 { + /** @internal */ + export const get = (remoteContext: RemoteDataContext) => { + const getPerson = GET(remoteContext, 'api/v1/person/'); + return async (identifier: UuidQualifier): Promise> => getPerson(identifier.uuid); + }; +} diff --git a/shared-libs/cht-script-api/test/auth.spec.js b/shared-libs/cht-datasource/test/auth.spec.js similarity index 100% rename from shared-libs/cht-script-api/test/auth.spec.js rename to shared-libs/cht-datasource/test/auth.spec.js diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts new file mode 100644 index 00000000000..5a3041c96e7 --- /dev/null +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -0,0 +1,73 @@ +import { expect } from 'chai'; +import * as Index from '../src'; +import { hasAnyPermission, hasPermissions } from '../src/auth'; +import * as Person from '../src/person'; +import * as Qualifier from '../src/qualifier'; +import sinon, { SinonStub } from 'sinon'; +import * as Context from '../src/libs/data-context'; +import { DataContext } from '../src'; + +describe('CHT Script API - getDatasource', () => { + let dataContext: DataContext; + let dataContextBind: SinonStub; + let assertDataContext: SinonStub; + let datasource: ReturnType; + + beforeEach(() => { + dataContextBind = sinon.stub(); + dataContext = { bind: dataContextBind }; + assertDataContext = sinon.stub(Context, 'assertDataContext'); + datasource = Index.getDatasource(dataContext); + }); + + afterEach(() => sinon.restore()); + + it('contains expected keys', () => { + expect(datasource).to.have.all.keys([ 'v1' ]); + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + expect(() => Index.getDatasource(dataContext)).to.throw('Invalid data context [null].'); + }); + + describe('v1', () => { + let v1: typeof datasource.v1; + + beforeEach(() => v1 = datasource.v1); + + it('contains expected keys', () => expect(v1).to.have.all.keys([ + 'hasPermissions', 'hasAnyPermission', 'person' + ])); + + it('permission', () => { + expect(v1.hasPermissions).to.equal(hasPermissions); + expect(v1.hasAnyPermission).to.equal(hasAnyPermission); + }); + + describe('person', () => { + let person: typeof v1.person; + + beforeEach(() => person = v1.person); + + it('contains expected keys', () => { + expect(person).to.have.all.keys(['getByUuid']); + }); + + it('getByUuid', async () => { + const expectedPerson = {}; + const personGet = sinon.stub().resolves(expectedPerson); + dataContextBind.returns(personGet); + const qualifier = { uuid: 'my-persons-uuid' }; + const byUuid = sinon.stub(Qualifier, 'byUuid').returns(qualifier); + + const returnedPerson = await person.getByUuid(qualifier.uuid); + + expect(returnedPerson).to.equal(expectedPerson); + expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true; + expect(personGet.calledOnceWithExactly(qualifier)).to.be.true; + expect(byUuid.calledOnceWithExactly(qualifier.uuid)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/core.spec.ts b/shared-libs/cht-datasource/test/libs/core.spec.ts new file mode 100644 index 00000000000..eb3ed7d769e --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/core.spec.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import { AbstractDataContext, hasField, hasFields, isRecord, isString, NonEmptyArray } from '../../src/libs/core'; +import sinon from 'sinon'; + +describe('core lib', () => { + afterEach(() => sinon.restore()); + + describe('isString', () => { + [ + [null, false], + ['', true], + [{}, false], + [undefined, false], + [1, false], + ['hello', true] + ].forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isString(value)).to.equal(expected); + }); + }); + }); + + describe('isRecord', () => { + [ + [null, false], + ['', false], + [{}, true], + [undefined, false], + [1, false], + ['hello', false] + ].forEach(([value, expected]) => { + it(`evaluates ${JSON.stringify(value)}`, () => { + expect(isRecord(value)).to.equal(expected); + }); + }); + }); + + describe('hasField', () => { + ([ + [{}, { name: 'uuid', type: 'string' }, false], + [{ uuid: 'uuid' }, { name: 'uuid', type: 'string' }, true], + [{ uuid: 'uuid' }, { name: 'uuid', type: 'number' }, false], + [{ uuid: 'uuid', other: 1 }, { name: 'uuid', type: 'string' }, true], + [{ uuid: 'uuid', other: 1 }, { name: 'other', type: 'string' }, false], + [{ uuid: 'uuid', other: 1 }, { name: 'other', type: 'number' }, true], + [{ getUuid: () => 'uuid' }, { name: 'getUuid', type: 'function' }, true], + ] as [Record, { name: string, type: string }, boolean][]).forEach(([record, field, expected]) => { + it(`evaluates ${JSON.stringify(record)} with ${JSON.stringify(field)}`, () => { + expect(hasField(record, field)).to.equal(expected); + }); + }); + }); + + describe('hasFields', () => { + ([ + [{}, [{ name: 'uuid', type: 'string' }], false], + [{ uuid: 'uuid' }, [{ name: 'uuid', type: 'string' }], true], + [{ getUuid: () => 'uuid' }, [{ name: 'getUuid', type: 'function' }, { name: 'uuid', type: 'string' }], false], + [ + { getUuid: () => 'uuid', uuid: 'uuid' }, + [{ name: 'getUuid', type: 'function' }, { name: 'uuid', type: 'string' }], + true + ], + ] as [Record, NonEmptyArray<{ name: string, type: string }>, boolean][]).forEach( + ([record, fields, expected]) => { + it(`evaluates ${JSON.stringify(record)} with ${JSON.stringify(fields)}`, () => { + expect(hasFields(record, fields)).to.equal(expected); + }); + } + ); + }); + + describe('AbstractDataContext', () => { + class TestDataContext extends AbstractDataContext { } + + it('bind', () => { + const ctx = new TestDataContext(); + const testFn = sinon.stub().returns('test'); + + const result = ctx.bind(testFn); + + expect(result).to.equal('test'); + expect(testFn.calledOnceWithExactly(ctx)).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/libs/data-context.spec.ts new file mode 100644 index 00000000000..82ba05ba0da --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/data-context.spec.ts @@ -0,0 +1,121 @@ +import { expect } from 'chai'; +import { adapt, assertDataContext } from '../../src/libs/data-context'; +import * as LocalContext from '../../src/local/libs/data-context'; +import * as RemoteContext from '../../src/remote/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import { DataContext } from '../../dist'; + + +describe('context lib', () => { + const context = { bind: sinon.stub() } as DataContext; + let isLocalDataContext: SinonStub; + let isRemoteDataContext: SinonStub; + let assertRemoteDataContext: SinonStub; + + beforeEach(() => { + isLocalDataContext = sinon.stub(LocalContext, 'isLocalDataContext'); + isRemoteDataContext = sinon.stub(RemoteContext, 'isRemoteDataContext'); + assertRemoteDataContext = sinon.stub(RemoteContext, 'assertRemoteDataContext'); + }); + + afterEach(() => sinon.restore()); + + describe('assertDataContext', () => { + + it('allows a remote data context', () => { + isRemoteDataContext.returns(true); + isLocalDataContext.returns(false); + + expect(() => assertDataContext(context)).to.not.throw(); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(isRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + }); + + it('allows a local data context', () => { + isRemoteDataContext.returns(false); + isLocalDataContext.returns(true); + + expect(() => assertDataContext(context)).to.not.throw(); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(isRemoteDataContext.notCalled).to.be.true; + }); + + it(`throws an error if the data context is not remote or local`, () => { + isRemoteDataContext.returns(false); + isLocalDataContext.returns(false); + + expect(() => assertDataContext(context)) + .to + .throw(`Invalid data context [${JSON.stringify(context)}].`); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(isRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + }); + + [ + null, + 1, + 'hello', + {} + ].forEach((context) => { + it(`throws an error if the data context is invalid [${JSON.stringify(context)}]`, () => { + expect(() => assertDataContext(context)).to.throw(`Invalid data context [${JSON.stringify(context)}].`); + + expect(isLocalDataContext.notCalled).to.be.true; + expect(isRemoteDataContext.notCalled).to.be.true; + }); + }); + }); + + describe('adapt', () => { + const resource = { hello: 'world' } as const; + let local: SinonStub; + let remote: SinonStub; + + beforeEach(() => { + local = sinon.stub(); + remote = sinon.stub(); + }); + + it('adapts a local data context', () => { + isLocalDataContext.returns(true); + local.returns(resource); + + const result = adapt(context, local, remote); + + expect(result).to.equal(resource); + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(local.calledOnceWithExactly(context)).to.be.true; + expect(assertRemoteDataContext.notCalled).to.be.true; + expect(remote.notCalled).to.be.true; + }); + + it('adapts a remote data context', () => { + isLocalDataContext.returns(false); + remote.returns(resource); + + const result = adapt(context, local, remote); + + expect(result).to.equal(resource); + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(assertRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + expect(local.notCalled).to.be.true; + expect(remote.calledOnceWithExactly(context)).to.be.true; + }); + + it('throws an error if the data context is not remote or local', () => { + isLocalDataContext.returns(false); + const error = new Error('Invalid data context'); + assertRemoteDataContext.throws(error); + + expect(() => adapt(context, local, remote)).to.throw(error); + + expect(isLocalDataContext.calledOnceWithExactly(context)).to.be.true; + expect(local.notCalled).to.be.true; + expect(assertRemoteDataContext.calledOnceWithExactly(context)).to.be.true; + expect(remote.notCalled).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/libs/doc.spec.ts b/shared-libs/cht-datasource/test/libs/doc.spec.ts new file mode 100644 index 00000000000..557d018d803 --- /dev/null +++ b/shared-libs/cht-datasource/test/libs/doc.spec.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai'; +import { isDoc } from '../../src/libs/doc'; + +describe('doc lib', () => { + describe('isDoc', () => { + [ + [null, false], + [{}, false], + [{ _id: 'id' }, false], + [{ _rev: 'rev' }, false], + [{ _id: 'id', _rev: 'rev' }, true], + [{ _id: 'id', _rev: 'rev', other: 'other' }, true] + ].forEach(([doc, expected]) => { + it(`evaluates ${JSON.stringify(doc)}`, () => { + expect(isDoc(doc)).to.equal(expected); + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/local/libs/data-context.spec.ts new file mode 100644 index 00000000000..659081fb73a --- /dev/null +++ b/shared-libs/cht-datasource/test/local/libs/data-context.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import { + getLocalDataContext, + isLocalDataContext, + SettingsService, + SourceDatabases +} from '../../../src/local/libs/data-context'; +import { DataContext } from '../../../src'; + +describe('local context lib', () => { + describe('isLocalDataContext', () => { + ([ + [{ medicDb: {}, settings: {} }, true], + [{ medicDb: {}, settings: {}, hello: 'world' }, true], + [{ medicDb: {} }, false], + [{ settings: {} }, false], + [{}, false] + ] as [DataContext, boolean][]).forEach(([context, expected]) => { + it(`evaluates ${JSON.stringify(context)}`, () => { + expect(isLocalDataContext(context)).to.equal(expected); + }); + }); + }); + + describe('getLocalDataContext', () => { + const settingsService = { getAll: () => ({}) } as SettingsService; + const sourceDatabases = { medic: {} } as SourceDatabases; + + ([ + null, + {}, + { getAll: 'not a function' }, + 'hello' + ] as unknown as SettingsService[]).forEach((settingsService) => { + it('throws an error if the settings service is invalid', () => { + expect(() => getLocalDataContext(settingsService, sourceDatabases)) + .to.throw(`Invalid settings service [${JSON.stringify(settingsService)}].`); + }); + }); + + ([ + null, + {}, + { medic: () => 'a function' }, + 'hello' + ] as unknown as SourceDatabases[]).forEach((sourceDatabases) => { + it('throws an error if the source databases are invalid', () => { + expect(() => getLocalDataContext(settingsService, sourceDatabases)) + .to.throw(`Invalid source databases [${JSON.stringify(sourceDatabases)}].`); + }); + }); + + it('returns the local data context', () => { + const dataContext = getLocalDataContext(settingsService, sourceDatabases); + expect(dataContext).to.deep.include({ medicDb: sourceDatabases.medic, settings: settingsService }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts new file mode 100644 index 00000000000..2423938297b --- /dev/null +++ b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts @@ -0,0 +1,73 @@ +import * as Doc from '../../../src/libs/doc'; +import sinon, { SinonStub } from 'sinon'; +import logger from '@medic/logger'; +import { getDocById } from '../../../src/local/libs/doc'; +import { expect } from 'chai'; + +describe('local doc lib', () => { + let dbGet: SinonStub; + let db: PouchDB.Database; + let isDoc: SinonStub; + let error: SinonStub; + + beforeEach(() => { + dbGet = sinon.stub(); + db = { get: dbGet } as unknown as PouchDB.Database; + isDoc = sinon.stub(Doc, 'isDoc'); + error = sinon.stub(logger, 'error'); + }); + + afterEach(() => sinon.restore()); + + describe('getDocById', () => { + it('returns a doc by id', async () => { + const uuid = 'uuid'; + const doc = { type: 'doc' }; + dbGet.resolves(doc); + isDoc.returns(true); + + const result = await getDocById(db)(uuid); + + expect(result).to.equal(doc); + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.calledOnceWithExactly(doc)).to.be.true; + }); + + it('returns null if the result is not a doc', async () => { + const uuid = 'uuid'; + const doc = { type: 'not-doc' }; + dbGet.resolves(doc); + isDoc.returns(false); + + const result = await getDocById(db)(uuid); + + expect(result).to.be.null; + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.calledOnceWithExactly(doc)).to.be.true; + }); + + it('returns null if the doc is not found', async () => { + const uuid = 'uuid'; + dbGet.rejects({ status: 404 }); + + const result = await getDocById(db)(uuid); + + expect(result).to.be.null; + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.notCalled).to.be.true; + expect(error.notCalled).to.be.true; + }); + + it('throws an error if an unexpected error occurs', async () => { + const uuid = 'uuid'; + const err = new Error('unexpected error'); + dbGet.rejects(err); + + await expect(getDocById(db)(uuid)).to.be.rejectedWith(err); + + expect(dbGet.calledOnceWithExactly(uuid)).to.be.true; + expect(isDoc.notCalled).to.be.true; + expect(error.calledOnceWithExactly(`Failed to fetch doc with id [${uuid}]`, err)).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/local/person.spec.ts b/shared-libs/cht-datasource/test/local/person.spec.ts new file mode 100644 index 00000000000..2876b0f76df --- /dev/null +++ b/shared-libs/cht-datasource/test/local/person.spec.ts @@ -0,0 +1,78 @@ +import sinon, { SinonStub } from 'sinon'; +import contactTypeUtils from '@medic/contact-types-utils'; +import { Doc } from '../../src/libs/doc'; +import * as Person from '../../src/local/person'; +import * as LocalDoc from '../../src/local/libs/doc'; +import { expect } from 'chai'; +import { LocalDataContext } from '../../src/local/libs/data-context'; + +describe('local person', () => { + let localContext: LocalDataContext; + let settingsGetAll: SinonStub; + + beforeEach(() => { + settingsGetAll = sinon.stub(); + localContext = { + medicDb: {} as PouchDB.Database, + settings: { getAll: settingsGetAll } + } as unknown as LocalDataContext; + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const identifier = { uuid: 'uuid' } as const; + const settings = { hello: 'world' } as const; + let getDocByIdOuter: SinonStub; + let getDocByIdInner: SinonStub; + let isPerson: SinonStub; + + beforeEach(() => { + getDocByIdInner = sinon.stub(); + getDocByIdOuter = sinon.stub(LocalDoc, 'getDocById').returns(getDocByIdInner); + isPerson = sinon.stub(contactTypeUtils, 'isPerson'); + }); + + it('returns a person by UUID', async () => { + const doc = { type: 'person' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isPerson.returns(true); + + const result = await Person.v1.get(localContext)(identifier); + + expect(result).to.equal(doc); + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, doc)).to.be.true; + }); + + it('returns null if the identified doc is not a person', async () => { + const doc = { type: 'not-person' }; + getDocByIdInner.resolves(doc); + settingsGetAll.returns(settings); + isPerson.returns(false); + + const result = await Person.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(isPerson.calledOnceWithExactly(settings, doc)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + getDocByIdInner.resolves(null); + + const result = await Person.v1.get(localContext)(identifier); + + expect(result).to.be.null; + expect(getDocByIdOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocByIdInner.calledOnceWithExactly(identifier.uuid)).to.be.true; + expect(settingsGetAll.notCalled).to.be.true; + expect(isPerson.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/person.spec.ts b/shared-libs/cht-datasource/test/person.spec.ts new file mode 100644 index 00000000000..ae29056dbda --- /dev/null +++ b/shared-libs/cht-datasource/test/person.spec.ts @@ -0,0 +1,68 @@ +import * as Person from '../src/person'; +import * as Local from '../src/local'; +import * as Remote from '../src/remote'; +import * as Qualifier from '../src/qualifier'; +import * as Context from '../src/libs/data-context'; +import sinon, { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import { DataContext } from '../src'; + +describe('person', () => { + const dataContext = { } as DataContext; + let assertDataContext: SinonStub; + let getPerson: SinonStub; + let adapt: SinonStub; + let isUuidQualifier: SinonStub; + + beforeEach(() => { + assertDataContext = sinon.stub(Context, 'assertDataContext'); + getPerson = sinon.stub(); + adapt = sinon.stub(Context, 'adapt').returns(getPerson); + isUuidQualifier = sinon.stub(Qualifier, 'isUuidQualifier'); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const person = { _id: 'my-person' } as Person.v1.Person; + const qualifier = { uuid: person._id } as const; + + it('retrieves the person for the given qualifier from the data context', async () => { + isUuidQualifier.returns(true); + getPerson.resolves(person); + + const result = await Person.v1.get(dataContext)(qualifier); + + expect(result).to.equal(person); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.get, Remote.Person.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPerson.calledOnceWithExactly(qualifier)).to.be.true; + }); + + it('throws an error if the qualifier is invalid', async () => { + isUuidQualifier.returns(false); + + await expect(Person.v1.get(dataContext)(qualifier)) + .to.be.rejectedWith(`Invalid identifier [${JSON.stringify(qualifier)}].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.get, Remote.Person.v1.get)).to.be.true; + expect(isUuidQualifier.calledOnceWithExactly(qualifier)).to.be.true; + expect(getPerson.notCalled).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Person.v1.get(dataContext)).to.throw(`Invalid data context [null].`); + + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(adapt.notCalled).to.be.true; + expect(isUuidQualifier.notCalled).to.be.true; + expect(getPerson.notCalled).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts new file mode 100644 index 00000000000..0b9fcb3b5b3 --- /dev/null +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -0,0 +1,34 @@ +import { byUuid, isUuidQualifier } from '../src/qualifier'; +import { expect } from 'chai'; + +describe('qualifier', () => { + describe('byUuid', () => { + it('builds a qualifier that identifies an entity by its UUID', () => { + expect(byUuid('uuid')).to.deep.equal({ uuid: 'uuid' }); + }); + + [ + null, + '', + { }, + ].forEach(uuid => { + it(`throws an error for ${JSON.stringify(uuid)}`, () => { + expect(() => byUuid(uuid as string)).to.throw(`Invalid UUID [${JSON.stringify(uuid)}].`); + }); + }); + }); + + describe('isUuidQualifier', () => { + [ + [ null, false ], + [ 'uuid', false ], + [ { uuid: { } }, false ], + [ { uuid: 'uuid' }, true ], + [ { uuid: 'uuid', other: 'other' }, true ] + ].forEach(([ identifier, expected ]) => { + it(`evaluates ${JSON.stringify(identifier)}`, () => { + expect(isUuidQualifier(identifier)).to.equal(expected); + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts new file mode 100644 index 00000000000..ee39c93ef6b --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/libs/data-context.spec.ts @@ -0,0 +1,159 @@ +import { expect } from 'chai'; +import logger from '@medic/logger'; +import sinon, { SinonStub } from 'sinon'; +import { + assertRemoteDataContext, + get, + getRemoteDataContext, + isRemoteDataContext, + RemoteDataContext +} from '../../../src/remote/libs/data-context'; +import { DataContext } from '../../../src'; + +describe('remote context lib', () => { + const context = { url: 'hello.world' } as RemoteDataContext; + let fetchResponse: { ok: boolean, status: number, statusText: string, json: SinonStub }; + let fetchStub: SinonStub; + let loggerError: SinonStub; + + beforeEach(() => { + fetchResponse = { + ok: true, + status: 200, + statusText: 'OK', + json: sinon.stub().resolves() + }; + fetchStub = sinon.stub(global, 'fetch').resolves(fetchResponse as unknown as Response); + loggerError = sinon.stub(logger, 'error'); + }); + + afterEach(() => sinon.restore()); + + describe('isRemoteDataContext', () => { + ([ + [{ url: 'hello.world' }, true], + [{ hello: 'world' }, false], + [{ }, false], + ] as [DataContext, boolean][]).forEach(([context, expected]) => { + it(`evaluates ${JSON.stringify(context)}`, () => { + expect(isRemoteDataContext(context)).to.equal(expected); + }); + }); + }); + + describe('assertRemoteDataContext', () => { + it('asserts a remote data context', () => { + const context = getRemoteDataContext('hello.world'); + + expect(() => assertRemoteDataContext(context)).to.not.throw(); + }); + + ([ + { hello: 'world' }, + { }, + ] as DataContext[]).forEach(context => { + it(`throws an error for ${JSON.stringify(context)}`, () => { + expect(() => assertRemoteDataContext(context)) + .to.throw(`Invalid remote data context [${JSON.stringify(context)}].`); + }); + }); + }); + + describe('getRemoteDataContext', () => { + + [ + '', + 'hello.world', + undefined + ].forEach(url => { + it(`returns a remote data context for URL: ${JSON.stringify(url)}`, () => { + const context = getRemoteDataContext(url); + + expect(isRemoteDataContext(context)).to.be.true; + expect(context).to.deep.include({ url: url ?? '' }); + }); + }); + + + [ + null, + 0, + {}, + [], + ].forEach(url => { + it(`throws an error for an invalid URL: ${JSON.stringify(url)}`, () => { + expect(() => getRemoteDataContext(url as string)) + .to.throw(`Invalid URL [${JSON.stringify(url)}].`); + }); + }); + }); + + describe('get', () => { + it('fetches a resource with a path', async () => { + const path = 'path/'; + const resourceName = 'resource'; + const resource = { hello: 'world' }; + fetchResponse.json.resolves(resource); + + const response = await get(context, path)(resourceName); + + expect(response).to.equal(resource); + expect(fetchStub.calledOnceWithExactly(`${context.url}/${path}${resourceName}`)).to.be.true; + expect(fetchResponse.json.calledOnceWithExactly()).to.be.true; + }); + + it('fetches a resource without a path', async () => { + const resourceName = 'path/resource'; + const resource = { hello: 'world' }; + fetchResponse.json.resolves(resource); + + const response = await get(context)(resourceName); + + expect(response).to.equal(resource); + expect(fetchStub.calledOnceWithExactly(`${context.url}/${resourceName}`)).to.be.true; + expect(fetchResponse.json.calledOnceWithExactly()).to.be.true; + }); + + it('returns null if the resource is not found', async () => { + const resourceName = 'path/resource'; + fetchResponse.ok = false; + fetchResponse.status = 404; + + const response = await get(context)(resourceName); + + expect(response).to.be.null; + expect(fetchStub.calledOnceWithExactly(`${context.url}/${resourceName}`)).to.be.true; + expect(fetchResponse.json.notCalled).to.be.true; + }); + + it('throws an error if the resource fetch rejects', async () => { + const resourceName = 'path/resource'; + const expectedError = new Error('unexpected error'); + fetchStub.rejects(expectedError); + + await expect(get(context)(resourceName)).to.be.rejectedWith(expectedError); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${resourceName}`)).to.be.true; + expect(loggerError.calledOnceWithExactly(`Failed to fetch ${resourceName} from ${context.url}`, expectedError)) + .to.be.true; + expect(fetchResponse.json.notCalled).to.be.true; + }); + + it('throws an error if the resource fetch resolves an error status', async () => { + const resourceName = 'path/resource'; + fetchResponse.ok = false; + fetchResponse.status = 501; + fetchResponse.statusText = 'Not Implemented'; + + await expect(get(context)(resourceName)).to.be.rejectedWith(fetchResponse.statusText); + + expect(fetchStub.calledOnceWithExactly(`${context.url}/${resourceName}`)).to.be.true; + expect(loggerError.calledOnce).to.be.true; + expect(loggerError.args[0]).to.deep.equal([ + `Failed to fetch ${resourceName} from ${context.url}`, + new Error(fetchResponse.statusText) + ]); + expect(fetchResponse.json.notCalled).to.be.true; + }); + }); +}); diff --git a/shared-libs/cht-datasource/test/remote/person.spec.ts b/shared-libs/cht-datasource/test/remote/person.spec.ts new file mode 100644 index 00000000000..61bf17c58e0 --- /dev/null +++ b/shared-libs/cht-datasource/test/remote/person.spec.ts @@ -0,0 +1,45 @@ +import sinon, { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import * as Person from '../../src/remote/person'; +import * as RemoteEnv from '../../src/remote/libs/data-context'; +import { RemoteDataContext } from '../../src/remote/libs/data-context'; + +describe('remote person', () => { + const remoteContext = {} as RemoteDataContext; + let remoteInnerGet: SinonStub; + let remoteOuterGet: SinonStub; + + beforeEach(() => { + remoteInnerGet = sinon.stub(); + remoteOuterGet = sinon.stub(RemoteEnv, 'get').returns(remoteInnerGet); + }); + + afterEach(() => sinon.restore()); + + describe('v1', () => { + describe('get', () => { + const identifier = { uuid: 'uuid' } as const; + + it('returns a person by UUID', async () => { + const doc = { type: 'person' }; + remoteInnerGet.resolves(doc); + + const result = await Person.v1.get(remoteContext)(identifier); + + expect(result).to.equal(doc); + expect(remoteOuterGet.calledOnceWithExactly(remoteContext, 'api/v1/person/')).to.be.true; + expect(remoteInnerGet.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + + it('returns null if the identified doc is not found', async () => { + remoteInnerGet.resolves(null); + + const result = await Person.v1.get(remoteContext)(identifier); + + expect(result).to.be.null; + expect(remoteOuterGet.calledOnceWithExactly(remoteContext, 'api/v1/person/')).to.be.true; + expect(remoteInnerGet.calledOnceWithExactly(identifier.uuid)).to.be.true; + }); + }); + }); +}); diff --git a/shared-libs/cht-datasource/tsconfig.build.json b/shared-libs/cht-datasource/tsconfig.build.json new file mode 100644 index 00000000000..eb8a3385e2d --- /dev/null +++ b/shared-libs/cht-datasource/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + // Flatten output in the dist directory so `src` is not included in the path + "compilerOptions": { "rootDir": "./src" }, + // Do not include the test files + "include": ["src/**/*.ts"], +} diff --git a/shared-libs/cht-datasource/tsconfig.json b/shared-libs/cht-datasource/tsconfig.json new file mode 100644 index 00000000000..d0340621ff1 --- /dev/null +++ b/shared-libs/cht-datasource/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "composite": true, + "declarationMap": true, + "outDir": "dist", + "sourceMap": true, + "tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo", + }, + "files": [ + "src/auth.js" + ], + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/shared-libs/cht-script-api/README.md b/shared-libs/cht-script-api/README.md deleted file mode 100644 index d66eb9e6949..00000000000 --- a/shared-libs/cht-script-api/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# CHT Script API - -The CHT Script API library is intended to be agnostic and simple. It provides a versioned API from feature modules. - -## API v1 - -The API v1 is defined as follows: - -| Function | Arguments | Description | -| -------- | --------- | ----------- | -| hasPermissions | String or array of permission name(s).
Array of user roles.
Object of configured permissions in CHT-Core's settings. | Returns true if the user has the permission(s), otherwise returns false | -| hasAnyPermission | Array of groups of permission name(s).
Array of user roles.
Object of configured permissions in CHT-Core's settings. | Returns true if the user has all the permissions of any of the provided groups, otherwise returns false | diff --git a/shared-libs/cht-script-api/package.json b/shared-libs/cht-script-api/package.json deleted file mode 100644 index 0e7eb33c477..00000000000 --- a/shared-libs/cht-script-api/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@medic/cht-script-api", - "version": "1.0.0", - "description": "Provides an API for CHT Scripts", - "main": "src/index.js", - "scripts": { - "test": "nyc --nycrcPath='../nyc.config.js' mocha ./test" - }, - "author": "", - "license": "Apache-2.0" -} diff --git a/shared-libs/cht-script-api/src/index.js b/shared-libs/cht-script-api/src/index.js deleted file mode 100644 index c7557c23a75..00000000000 --- a/shared-libs/cht-script-api/src/index.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * CHT Script API - Index - * Builds and exports a versioned API from feature modules. - * Whenever possible keep this file clean by defining new features in modules. - */ -const auth = require('./auth'); - -/** - * Verify if the user's role has the permission(s). - * @param permissions {string | string[]} Permission(s) to verify - * @param userRoles {string[]} Array of user roles. - * @param chtPermissionsSettings {object} Object of configured permissions in CHT-Core's settings. - * @return {boolean} - */ -const hasPermissions = (permissions, userRoles, chtPermissionsSettings) => { - return auth.hasPermissions(permissions, userRoles, chtPermissionsSettings); -}; - -/** - * Verify if the user's role has all the permissions of any of the provided groups. - * @param permissionsGroupList {string[][]} Array of groups of permissions due to the complexity of permission grouping - * @param userRoles {string[]} Array of user roles. - * @param chtPermissionsSettings {object} Object of configured permissions in CHT-Core's settings. - * @return {boolean} - */ -const hasAnyPermission = (permissionsGroupList, userRoles, chtPermissionsSettings) => { - return auth.hasAnyPermission(permissionsGroupList, userRoles, chtPermissionsSettings); -}; - -module.exports = { - v1: { - hasPermissions, - hasAnyPermission - } -}; diff --git a/shared-libs/cht-script-api/test/index.spec.js b/shared-libs/cht-script-api/test/index.spec.js deleted file mode 100644 index 7bc1288c242..00000000000 --- a/shared-libs/cht-script-api/test/index.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -const expect = require('chai').expect; -const sinon = require('sinon'); -const chtScriptApi = require('../src/index'); -const auth = require('../src/auth'); - -describe('CHT Script API - index', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return versioned api and set functions', () => { - expect(chtScriptApi).to.have.all.keys([ 'v1' ]); - expect(chtScriptApi.v1).to.have.all.keys([ 'hasPermissions', 'hasAnyPermission' ]); - expect(chtScriptApi.v1.hasPermissions).to.be.a('function'); - expect(chtScriptApi.v1.hasAnyPermission).to.be.a('function'); - }); - - it('should call auth.hasPermissions', () => { - const authHasPermissions = sinon.stub(auth, 'hasPermissions').returns(true); - const userRoles = [ 'chw' ]; - const chtPermissionsSettings = { - can_backup_facilities: [ 'chw', 'national_admin' ], - can_export_messages: [ 'national_admin', 'chw', 'analytics' ] - }; - const permissions = [ 'can_backup_facilities', 'can_export_messages' ]; - - const result = chtScriptApi.v1.hasPermissions(permissions, userRoles, chtPermissionsSettings); - - expect(result).to.be.true; - expect(authHasPermissions.callCount).to.equal(1); - expect(authHasPermissions.args[0]).to.deep.equal([ permissions, userRoles, chtPermissionsSettings ]); - }); - - it('should call auth.hasAnyPermission', () => { - const authHasAnyPermission = sinon.stub(auth, 'hasAnyPermission').returns(true); - const userRoles = [ 'district_admin' ]; - const chtPermissionsSettings = { - can_backup_facilities: [ 'national_admin', 'district_admin' ], - can_export_messages: [ 'national_admin', 'district_admin', 'analytics' ], - can_add_people: [ 'national_admin', 'district_admin' ], - can_add_places: [ 'national_admin', 'district_admin' ], - can_roll_over: [ 'national_admin', 'district_admin' ], - }; - const permissions = [ - [ 'can_backup_facilities' ], - [ 'can_export_messages', 'can_roll_over' ], - [ 'can_add_people', 'can_add_places' ], - ]; - - const result = chtScriptApi.v1.hasAnyPermission(permissions, userRoles, chtPermissionsSettings); - - expect(result).to.be.true; - expect(authHasAnyPermission.callCount).to.equal(1); - expect(authHasAnyPermission.args[0]).to.deep.equal([ permissions, userRoles, chtPermissionsSettings ]); - }); -}); diff --git a/shared-libs/contact-types-utils/src/index.d.ts b/shared-libs/contact-types-utils/src/index.d.ts new file mode 100644 index 00000000000..1eb73feebcf --- /dev/null +++ b/shared-libs/contact-types-utils/src/index.d.ts @@ -0,0 +1,16 @@ +export function getTypeId(doc: Record): string | undefined; +export function getTypeById(config: Record, typeId: string): Record | null; +export function isPersonType(type: Record): boolean; +export function isPlaceType(type: Record): boolean; +export function hasParents(type: Record): boolean; +export function isParentOf(parentType: string | Record, childType: Record): boolean; +export function getLeafPlaceTypes(config: Record): Record[]; +export function getContactType(config: Record, contact: Record): Record | undefined; +export function isPerson(config: Record, contact: Record): boolean; +export function isPlace(config: Record, contact: Record): boolean; +export function isHardcodedType(type: string): boolean; +export declare const HARDCODED_TYPES: string[]; +export function getContactTypes(config?: Record): Record[]; +export function getChildren(config?: Record, parentType?: string | Record): Record[]; +export function getPlaceTypes(config?: Record): Record[]; +export function getPersonTypes(config?: Record): Record[]; diff --git a/shared-libs/logger/src/index.d.ts b/shared-libs/logger/src/index.d.ts new file mode 100644 index 00000000000..249da4ffe5a --- /dev/null +++ b/shared-libs/logger/src/index.d.ts @@ -0,0 +1 @@ +export * as default from 'winston'; diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js new file mode 100644 index 00000000000..89b6c5c1b7b --- /dev/null +++ b/tests/integration/api/controllers/person.spec.js @@ -0,0 +1,62 @@ +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const { getRemoteDataContext, Person, Qualifier } = require('@medic/cht-datasource'); +const { expect } = require('chai'); +const userFactory = require('@factories/cht/users/users'); + +describe('Person API', () => { + const places = utils.deepFreeze(placeFactory.generateHierarchy()); + const patient = utils.deepFreeze(personFactory.build({ + parent: { + _id: places.get('clinic')._id, + parent: { + _id: places.get('health_center')._id, + parent: { + _id: places.get('district_hospital')._id + } + }, + }, + phone: '1234567890', + reported_date: '2024-05-24T18:40:34.694Z', + role: 'patient', + short_name: 'Mary' + })); + const userNoPerms = utils.deepFreeze(userFactory.build({ + place: places.get('clinic')._id, + roles: ['no_perms'] + })); + const dataContext = getRemoteDataContext(utils.getOrigin()); + + before(async () => { + await utils.saveDocs([...places.values(), patient]); + await utils.createUsers([userNoPerms]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([userNoPerms]); + }); + + describe('GET /api/v1/person/:uuid', async () => { + const getPerson = Person.v1.get(dataContext); + + it('returns the person matching the provided UUID', async () => { + const person = await getPerson(Qualifier.byUuid(patient._id)); + expect(person).excluding('_rev').to.deep.equal(patient); + }); + + it('returns null when no user is found for the UUID', async () => { + const person = await getPerson(Qualifier.byUuid('invalid-uuid')); + expect(person).to.be.null; + }); + + it('throws error when user does not have can_view_contacts permission', async () => { + const opts = { + path: `/api/v1/person/${patient._id}`, + auth: { username: userNoPerms.username, password: userNoPerms.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); +}); diff --git a/tests/utils/index.js b/tests/utils/index.js index 0718ec1978c..14804812a33 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -63,6 +63,9 @@ const MINIMUM_BROWSER_VERSION = '90'; const KUBECTL_CONTEXT = `-n ${PROJECT_NAME} --context k3d-${PROJECT_NAME}`; const cookieJar = rpn.jar(); +// Cookies from the jar will be included on Node `fetch` calls +global.fetch = require('fetch-cookie').default(global.fetch, cookieJar); + const makeTempDir = (prefix) => fs.mkdtempSync(path.join(path.join(os.tmpdir(), prefix || 'ci-'))); const env = { ...process.env, diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 7f7583f4914..e261bd00683 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -76,8 +76,8 @@ "moment": "^2.29.1" } }, - "../shared-libs/cht-script-api": { - "name": "@medic/cht-script-api", + "../shared-libs/cht-datasource": { + "name": "@medic/cht-datasource", "version": "1.0.0", "extraneous": true, "license": "Apache-2.0" diff --git a/webapp/package.json b/webapp/package.json index c2c65d70525..079b5e48d0c 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -19,6 +19,7 @@ "unit:cht-form": "ng test cht-form", "unit": "UNIT_TEST_ENV=1 ng test webapp", "build": "ng build", + "build-watch": "npm run build -- --configuration=development --watch=true", "build:cht-form": "ng build cht-form", "compile": "ngc" }, diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index 3ccfb53cd77..a84bd314e29 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -39,7 +39,7 @@ import { TranslationDocsMatcherProvider } from '@mm-providers/translation-docs-m import { TranslateLocaleService } from '@mm-services/translate-locale.service'; import { TelemetryService } from '@mm-services/telemetry.service'; import { TransitionsService } from '@mm-services/transitions.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TranslateService } from '@mm-services/translate.service'; import { AnalyticsModulesService } from '@mm-services/analytics-modules.service'; import { AnalyticsActions } from '@mm-actions/analytics'; @@ -131,7 +131,7 @@ export class AppComponent implements OnInit, AfterViewInit { private performanceService:PerformanceService, private transitionsService:TransitionsService, private ngZone:NgZone, - private chtScriptApiService: CHTScriptApiService, + private chtDatasourceService: CHTDatasourceService, private analyticsModulesService: AnalyticsModulesService, private trainingCardsService: TrainingCardsService, private matIconRegistry: MatIconRegistry, @@ -279,7 +279,7 @@ export class AppComponent implements OnInit, AfterViewInit { // initialisation tasks that can occur after the UI has been rendered this.setupPromise = Promise.resolve() - .then(() => this.chtScriptApiService.isInitialized()) + .then(() => this.chtDatasourceService.isInitialized()) .then(() => this.checkPrivacyPolicy()) .then(() => (this.initialisationComplete = true)) .then(() => this.initRulesEngine()) diff --git a/webapp/src/ts/modals/edit-report/edit-report.component.ts b/webapp/src/ts/modals/edit-report/edit-report.component.ts index 0dc1c377109..a39448e99da 100644 --- a/webapp/src/ts/modals/edit-report/edit-report.component.ts +++ b/webapp/src/ts/modals/edit-report/edit-report.component.ts @@ -42,12 +42,12 @@ export class EditReportComponent implements AfterViewInit { return this.contactTypesService .getPersonTypes() .then(types => { - types = types.map(type => type.id); + const typeIds = types.map(type => type.id); const options = { allowNew: false, initialValue: this.report?.contact?._id || this.report?.from, }; - return this.select2SearchService.init(this.getSelectElement(), types, options); + return this.select2SearchService.init(this.getSelectElement(), typeIds, options); }) .catch(err => console.error('Error initialising select2', err)); } diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 9610c01b29e..ad7823bfcc1 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -167,7 +167,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { throw new Error('Unknown form'); } - const titleKey = contact ? contactType.edit_key : contactType.create_key; + const titleKey = (contact ? contactType.edit_key : contactType.create_key) as string; this.setTitle(titleKey); const formInstance = await this.renderForm(formId, titleKey); this.setEnketoContact(formInstance); diff --git a/webapp/src/ts/services/auth.service.ts b/webapp/src/ts/services/auth.service.ts index df181ee8b80..e746691a82c 100644 --- a/webapp/src/ts/services/auth.service.ts +++ b/webapp/src/ts/services/auth.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { SessionService } from '@mm-services/session.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' @@ -10,7 +10,7 @@ export class AuthService { constructor( private session: SessionService, - private chtScriptApiService: CHTScriptApiService + private chtDatasourceService: CHTDatasourceService ) { } /** @@ -21,8 +21,8 @@ export class AuthService { * @param permissions {string | string[]} */ has(permissions?: string | string[]): Promise { - return this.chtScriptApiService - .getApi() + return this.chtDatasourceService + .get() .then(chtApi => { const userCtx = this.session.userCtx(); @@ -53,8 +53,8 @@ export class AuthService { return this.has(permissionsGroupList); } - return this.chtScriptApiService - .getApi() + return this.chtDatasourceService + .get() .then(chtApi => { const userCtx = this.session.userCtx(); diff --git a/webapp/src/ts/services/cht-script-api.service.ts b/webapp/src/ts/services/cht-datasource.service.ts similarity index 69% rename from webapp/src/ts/services/cht-script-api.service.ts rename to webapp/src/ts/services/cht-datasource.service.ts index 25c5c7275fe..6b7f7134e4f 100644 --- a/webapp/src/ts/services/cht-script-api.service.ts +++ b/webapp/src/ts/services/cht-datasource.service.ts @@ -1,18 +1,20 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import * as chtScriptApiFactory from '@medic/cht-script-api'; +import { DataContext, getDatasource, getLocalDataContext, getRemoteDataContext } from '@medic/cht-datasource'; import { SettingsService } from '@mm-services/settings.service'; import { ChangesService } from '@mm-services/changes.service'; import { SessionService } from '@mm-services/session.service'; +import { DbService } from '@mm-services/db.service'; import { lastValueFrom } from 'rxjs'; @Injectable({ providedIn: 'root' }) -export class CHTScriptApiService { +export class CHTDatasourceService { private userCtx; + private dataContext!: DataContext; private settings; private initialized; private extensionLibs = {}; @@ -21,7 +23,8 @@ export class CHTScriptApiService { private http: HttpClient, private sessionService: SessionService, private settingsService: SettingsService, - private changesService: ChangesService + private changesService: ChangesService, + private dbService: DbService ) { } isInitialized() { @@ -35,14 +38,25 @@ export class CHTScriptApiService { private async init() { this.watchChanges(); this.userCtx = this.sessionService.userCtx(); - await Promise.all([ this.getSettings(), this.loadScripts() ]); + await Promise.all([this.getSettings(), this.loadScripts()]); + this.dataContext = await this.getDataContext(); + } + + private async getDataContext() { + if (this.sessionService.isOnlineOnly(this.userCtx)) { + return getRemoteDataContext(); + } + + const settingsService = { getAll: () => this.settings }; + const sourceDatabases = { medic: await this.dbService.get() }; + return getLocalDataContext(settingsService, sourceDatabases); } private async loadScripts() { try { - const request = this.http.get('/extension-libs', { responseType: 'json' }); + const request = this.http.get('/extension-libs', { responseType: 'json' }); const extensionLibs = await lastValueFrom(request); - if (extensionLibs && extensionLibs.length) { + if (extensionLibs?.length) { return Promise.all(extensionLibs.map(name => this.loadScript(name))); } } catch (e) { @@ -83,19 +97,22 @@ export class CHTScriptApiService { return user?.roles || this.userCtx?.roles; } - async getApi() { + async get() { await this.isInitialized(); + const dataSource = getDatasource(this.dataContext); return { + ...dataSource, v1: { + ...dataSource.v1, hasPermissions: (permissions, user?, chtSettings?) => { const userRoles = this.getRolesFromUser(user); const chtPermissionsSettings = this.getChtPermissionsFromSettings(chtSettings); - return chtScriptApiFactory.v1.hasPermissions(permissions, userRoles, chtPermissionsSettings); + return dataSource.v1.hasPermissions(permissions, userRoles, chtPermissionsSettings); }, hasAnyPermission: (permissionsGroupList, user?, chtSettings?) => { const userRoles = this.getRolesFromUser(user); const chtPermissionsSettings = this.getChtPermissionsFromSettings(chtSettings); - return chtScriptApiFactory.v1.hasAnyPermission(permissionsGroupList, userRoles, chtPermissionsSettings); + return dataSource.v1.hasAnyPermission(permissionsGroupList, userRoles, chtPermissionsSettings); }, getExtensionLib: (id) => { return this.extensionLibs[id]; diff --git a/webapp/src/ts/services/contact-summary.service.ts b/webapp/src/ts/services/contact-summary.service.ts index be8ca5d8b78..cd9612f2cad 100644 --- a/webapp/src/ts/services/contact-summary.service.ts +++ b/webapp/src/ts/services/contact-summary.service.ts @@ -4,7 +4,7 @@ import { SettingsService } from '@mm-services/settings.service'; import { PipesService } from '@mm-services/pipes.service'; import { UHCSettingsService } from '@mm-services/uhc-settings.service'; import { UHCStatsService } from '@mm-services/uhc-stats.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; /** * Service for generating summary information based on a given @@ -26,7 +26,7 @@ export class ContactSummaryService { private ngZone:NgZone, private uhcSettingsService:UHCSettingsService, private uhcStatsService:UHCStatsService, - private chtScriptApiService:CHTScriptApiService + private chtDatasourceService:CHTDatasourceService ) { } private getGeneratorFunction() { @@ -97,7 +97,7 @@ export class ContactSummaryService { uhcInterval: this.uhcStatsService.getUHCInterval(this.visitCountSettings) }; - const chtScriptApi = await this.chtScriptApiService.getApi(); + const chtScriptApi = await this.chtDatasourceService.get(); try { const summary = generatorFunction(contact, reports || [], lineage || [], uhcStats, chtScriptApi, targetDoc); diff --git a/webapp/src/ts/services/contacts.service.ts b/webapp/src/ts/services/contacts.service.ts index f1a2f065614..959ea197032 100644 --- a/webapp/src/ts/services/contacts.service.ts +++ b/webapp/src/ts/services/contacts.service.ts @@ -25,7 +25,7 @@ export class ContactsService { .then(types => { const cacheByType = {}; types.forEach(type => { - cacheByType[type.id] = this.cacheService.register({ + cacheByType[type.id as string] = this.cacheService.register({ get: (callback) => { return this.dbService .get() diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index a10d09c3e68..658580b3df6 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -18,7 +18,7 @@ import { ContactSummaryService } from '@mm-services/contact-summary.service'; import { TranslateService } from '@mm-services/translate.service'; import { TransitionsService } from '@mm-services/transitions.service'; import { GlobalActions } from '@mm-actions/global'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; import { EnketoFormContext, EnketoService } from '@mm-services/enketo.service'; import { UserSettingsService } from '@mm-services/user-settings.service'; @@ -54,7 +54,7 @@ export class FormService { private transitionsService: TransitionsService, private translateService: TranslateService, private ngZone: NgZone, - private chtScriptApiService: CHTScriptApiService, + private chtDatasourceService: CHTDatasourceService, private enketoService: EnketoService ) { this.inited = this.init(); @@ -73,7 +73,7 @@ export class FormService { } return Promise.all([ this.zScoreService.getScoreUtil(), - this.chtScriptApiService.getApi() + this.chtDatasourceService.get() ]) .then(([zscoreUtil, api]) => { medicXpathExtensions.init(zscoreUtil, toBik_text, moment, api); diff --git a/webapp/src/ts/services/place-hierarchy.service.ts b/webapp/src/ts/services/place-hierarchy.service.ts index 94625cb45ff..03084d3c587 100644 --- a/webapp/src/ts/services/place-hierarchy.service.ts +++ b/webapp/src/ts/services/place-hierarchy.service.ts @@ -81,7 +81,7 @@ export class PlaceHierarchyService { const ids: any[] = []; types.forEach(type => { if (type.parents) { - ids.push(...type.parents); + ids.push(...(type.parents as unknown[])); } }); return ids; diff --git a/webapp/src/ts/services/rules-engine.service.ts b/webapp/src/ts/services/rules-engine.service.ts index 5b8cb802edc..e0f4ca4cb16 100644 --- a/webapp/src/ts/services/rules-engine.service.ts +++ b/webapp/src/ts/services/rules-engine.service.ts @@ -18,7 +18,7 @@ import { ContactTypesService } from '@mm-services/contact-types.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { DbService } from '@mm-services/db.service'; import { CalendarIntervalService } from '@mm-services/calendar-interval.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TranslateService } from '@mm-services/translate.service'; import { PerformanceService } from '@mm-services/performance.service'; @@ -73,7 +73,7 @@ export class RulesEngineService implements OnDestroy { private rulesEngineCoreFactoryService:RulesEngineCoreFactoryService, private calendarIntervalService:CalendarIntervalService, private ngZone:NgZone, - private chtScriptApiService:CHTScriptApiService + private chtDatasourceService:CHTDatasourceService ) { this.initialized = this.initialize(); this.rulesEngineCore = this.rulesEngineCoreFactoryService.get(); @@ -104,7 +104,7 @@ export class RulesEngineService implements OnDestroy { this.settingsService.get(), this.userContactService.get(), this.userSettingsService.get(), - this.chtScriptApiService.getApi() + this.chtDatasourceService.get() ]) .then(([settingsDoc, userContactDoc, userSettingsDoc, chtScriptApi]) => { const rulesEngineContext = this.getRulesEngineContext( diff --git a/webapp/tests/karma/ts/app.component.spec.ts b/webapp/tests/karma/ts/app.component.spec.ts index 9c3c471af8f..781dd688d3e 100644 --- a/webapp/tests/karma/ts/app.component.spec.ts +++ b/webapp/tests/karma/ts/app.component.spec.ts @@ -40,7 +40,7 @@ import { TranslateLocaleService } from '@mm-services/translate-locale.service'; import { BrowserDetectorService } from '@mm-services/browser-detector.service'; import { TelemetryService } from '@mm-services/telemetry.service'; import { TransitionsService } from '@mm-services/transitions.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { AnalyticsActions } from '@mm-actions/analytics'; import { AnalyticsModulesService } from '@mm-services/analytics-modules.service'; import { Selectors } from '@mm-selectors/index'; @@ -80,7 +80,7 @@ describe('AppComponent', () => { let translateLocaleService; let telemetryService; let transitionsService; - let chtScriptApiService; + let chtDatasourceService; let analyticsModulesService; let trainingCardsService; // End Services @@ -119,7 +119,7 @@ describe('AppComponent', () => { translateService = { instant: sinon.stub().returnsArg(0) }; modalService = { show: sinon.stub().resolves() }; browserDetectorService = { isUsingOutdatedBrowser: sinon.stub().returns(false) }; - chtScriptApiService = { isInitialized: sinon.stub() }; + chtDatasourceService = { isInitialized: sinon.stub() }; analyticsModulesService = { get: sinon.stub() }; databaseConnectionMonitorService = { listenForDatabaseClosed: sinon.stub().returns(of()) @@ -213,7 +213,7 @@ describe('AppComponent', () => { { provide: TranslateLocaleService, useValue: translateLocaleService }, { provide: TelemetryService, useValue: telemetryService }, { provide: TransitionsService, useValue: transitionsService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: AnalyticsModulesService, useValue: analyticsModulesService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: Router, useValue: router }, @@ -250,7 +250,7 @@ describe('AppComponent', () => { // init rules engine expect(rulesEngineService.isEnabled.callCount).to.equal(1); // init CHTScriptApiService - expect(chtScriptApiService.isInitialized.callCount).to.equal(1); + expect(chtDatasourceService.isInitialized.callCount).to.equal(1); // init unread count expect(unreadRecordsService.init.callCount).to.equal(1); expect(unreadRecordsService.init.args[0][0]).to.be.a('Function'); @@ -643,7 +643,7 @@ describe('AppComponent', () => { })); it('should redirect to the error page when there is an exception', fakeAsync(async () => { - chtScriptApiService.isInitialized.throws({ error: 'some error'}); + chtDatasourceService.isInitialized.throws({ error: 'some error'}); await getComponent(); tick(); diff --git a/webapp/tests/karma/ts/services/auth.service.spec.ts b/webapp/tests/karma/ts/services/auth.service.spec.ts index 37800cc00b8..289394398ed 100644 --- a/webapp/tests/karma/ts/services/auth.service.spec.ts +++ b/webapp/tests/karma/ts/services/auth.service.spec.ts @@ -8,13 +8,14 @@ import { SessionService } from '@mm-services/session.service'; import { SettingsService } from '@mm-services/settings.service'; import { AuthService } from '@mm-services/auth.service'; import { ChangesService } from '@mm-services/changes.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; +import { DbService } from '@mm-services/db.service'; describe('Auth Service', () => { let service:AuthService; let sessionService; let settingsService; - let chtScriptApiService; + let chtDatasourceService; let changesService; let http; @@ -29,12 +30,13 @@ describe('Auth Service', () => { { provide: SessionService, useValue: sessionService }, { provide: SettingsService, useValue: settingsService }, { provide: ChangesService, useValue: changesService }, + { provide: DbService, useValue: { get: sinon.stub().resolves({}) } }, { provide: HttpClient, useValue: http }, ] }); service = TestBed.inject(AuthService); - chtScriptApiService = TestBed.inject(CHTScriptApiService); + chtDatasourceService = TestBed.inject(CHTDatasourceService); }); afterEach(() => { @@ -45,7 +47,7 @@ describe('Auth Service', () => { it('should return false when no settings', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves(null); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has('can_edit'); @@ -55,7 +57,7 @@ describe('Auth Service', () => { it('should return false when no permissions configured', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves({}); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has('can_edit'); @@ -65,7 +67,7 @@ describe('Auth Service', () => { it('should return false when no session', async () => { sessionService.userCtx.returns(null); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(); @@ -75,7 +77,7 @@ describe('Auth Service', () => { it('should return false when user has no role', async () => { sessionService.userCtx.returns({}); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(); @@ -85,7 +87,7 @@ describe('Auth Service', () => { it('should return true when user is db admin', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: { can_edit: ['chw'] } }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities']); @@ -95,7 +97,7 @@ describe('Auth Service', () => { it('should return false when settings errors', async () => { sessionService.userCtx.returns({ roles: ['district_admin'] }); settingsService.get.rejects('boom'); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities']); @@ -114,7 +116,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['']); @@ -123,7 +125,7 @@ describe('Auth Service', () => { it('should throw error when server is offline', async () => { settingsService.get.rejects({ status: 503 }); - chtScriptApiService.init(); + chtDatasourceService.init(); try { await service.has(['']); expect.fail(); @@ -149,7 +151,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['xyz']); @@ -168,7 +170,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!xyz']); @@ -189,7 +191,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has('can_backup_facilities'); @@ -208,7 +210,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities', 'can_export_messages']); @@ -227,7 +229,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['can_backup_facilities', 'can_export_messages']); @@ -237,7 +239,7 @@ describe('Auth Service', () => { it('should return false when admin and !permission', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!can_backup_facilities']); @@ -256,7 +258,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!can_backup_facilities', '!can_export_messages']); @@ -275,7 +277,7 @@ describe('Auth Service', () => { ], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.has(['!can_backup_facilities', 'can_export_messages']); @@ -287,7 +289,7 @@ describe('Auth Service', () => { it('should return false when no settings', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves(null); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['can_edit'], ['can_configure']]); @@ -297,7 +299,7 @@ describe('Auth Service', () => { it('should return false when no settings and no permissions configured', async () => { sessionService.userCtx.returns({ roles: ['chw'] }); settingsService.get.resolves({}); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['can_edit'], ['can_configure']]); @@ -307,7 +309,7 @@ describe('Auth Service', () => { it('should return false when no session', async () => { sessionService.userCtx.returns(null); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any(); @@ -317,7 +319,7 @@ describe('Auth Service', () => { it('should return false when user has no role', async () => { sessionService.userCtx.returns({}); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any(); @@ -327,7 +329,7 @@ describe('Auth Service', () => { it('should return true when admin and no disallowed permissions', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: { can_edit: [ 'chw' ] } }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['can_backup_facilities'], ['can_export_messages'], ['somepermission']]); @@ -337,7 +339,7 @@ describe('Auth Service', () => { it('should return true when admin and some disallowed permissions', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: { can_edit: [ 'chw' ] } }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['!can_backup_facilities'], ['!can_export_messages'], ['somepermission']]); @@ -347,7 +349,7 @@ describe('Auth Service', () => { it('should return false when admin and all disallowed permissions', async () => { sessionService.userCtx.returns({ roles: ['_admin'] }); settingsService.get.resolves({ permissions: {} }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([['!can_backup_facilities'], ['!can_export_messages'], ['!somepermission']]); @@ -369,7 +371,7 @@ describe('Auth Service', () => { can_roll_over: ['national_admin', 'district_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const permissions = [ ['can_backup_facilities'], ['can_export_messages', 'can_roll_over'], @@ -389,7 +391,7 @@ describe('Auth Service', () => { can_backup_people: ['national_admin', 'district_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const permissions = [ ['can_backup_facilities', 'can_backup_people'], ['can_export_messages', 'can_roll_over'], @@ -409,7 +411,7 @@ describe('Auth Service', () => { can_backup_people: ['national_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const permissions = [ ['can_backup_facilities', 'can_backup_people'], ['can_export_messages', 'can_roll_over'], @@ -437,7 +439,7 @@ describe('Auth Service', () => { random3: ['national_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([ ['can_backup_facilities', '!random1'], @@ -460,14 +462,14 @@ describe('Auth Service', () => { random3: ['national_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([ ['can_backup_facilities', '!can_add_people'], ['can_export_messages', '!random2'], ['can_backup_people', '!can_add_places'] ]); - chtScriptApiService.init(); + chtDatasourceService.init(); expect(result).to.be.true; }); @@ -484,7 +486,7 @@ describe('Auth Service', () => { random3: ['national_admin', 'district_admin'], }, }); - chtScriptApiService.init(); + chtDatasourceService.init(); const result = await service.any([ ['can_backup_facilities', '!random1'], diff --git a/webapp/tests/karma/ts/services/cht-script-api.service.spec.ts b/webapp/tests/karma/ts/services/cht-datasource.service.spec.ts similarity index 80% rename from webapp/tests/karma/ts/services/cht-script-api.service.spec.ts rename to webapp/tests/karma/ts/services/cht-datasource.service.spec.ts index 70be34e778b..2a094dfac45 100644 --- a/webapp/tests/karma/ts/services/cht-script-api.service.spec.ts +++ b/webapp/tests/karma/ts/services/cht-datasource.service.spec.ts @@ -4,22 +4,25 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { SettingsService } from '@mm-services/settings.service'; import { ChangesService } from '@mm-services/changes.service'; import { SessionService } from '@mm-services/session.service'; +import { DbService } from '@mm-services/db.service'; describe('CHTScriptApiService service', () => { - let service: CHTScriptApiService; + let service: CHTDatasourceService; let sessionService; let settingsService; let changesService; + let dbService; let http; beforeEach(() => { - sessionService = { userCtx: sinon.stub() }; + sessionService = { userCtx: sinon.stub(), isOnlineOnly: sinon.stub() }; settingsService = { get: sinon.stub() }; changesService = { subscribe: sinon.stub().returns({ unsubscribe: sinon.stub() }) }; + dbService = { get: sinon.stub().resolves({}) }; http = { get: sinon.stub().returns(of([])) }; TestBed.configureTestingModule({ @@ -27,11 +30,12 @@ describe('CHTScriptApiService service', () => { { provide: SessionService, useValue: sessionService }, { provide: SettingsService, useValue: settingsService }, { provide: ChangesService, useValue: changesService }, + { provide: DbService, useValue: dbService }, { provide: HttpClient, useValue: http }, ] }); - service = TestBed.inject(CHTScriptApiService); + service = TestBed.inject(CHTDatasourceService); }); afterEach(() => { @@ -40,9 +44,11 @@ describe('CHTScriptApiService service', () => { describe('init', () => { - it('should initialise service', async () => { + it('should initialise service for offline user', async () => { settingsService.get.resolves(); - sessionService.userCtx.returns(); + const userCtx = { hello: 'world' }; + sessionService.userCtx.returns(userCtx); + sessionService.isOnlineOnly.returns(false); await service.isInitialized(); @@ -51,19 +57,39 @@ describe('CHTScriptApiService service', () => { expect(changesService.subscribe.args[0][0].filter).to.be.a('function'); expect(changesService.subscribe.args[0][0].callback).to.be.a('function'); expect(settingsService.get.callCount).to.equal(1); + expect(sessionService.isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + expect(dbService.get.calledOnceWithExactly()).to.be.true; + }); + + it('should initialise service for online user', async () => { + settingsService.get.resolves(); + const userCtx = { hello: 'world' }; + sessionService.userCtx.returns(userCtx); + sessionService.isOnlineOnly.returns(true); + + await service.isInitialized(); + + expect(changesService.subscribe.callCount).to.equal(1); + expect(changesService.subscribe.args[0][0].key).to.equal('cht-script-api-settings-changes'); + expect(changesService.subscribe.args[0][0].filter).to.be.a('function'); + expect(changesService.subscribe.args[0][0].callback).to.be.a('function'); + expect(settingsService.get.callCount).to.equal(1); + expect(sessionService.isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true; + expect(dbService.get.notCalled).to.be.true; }); it('should return versioned api', async () => { settingsService.get.resolves(); await service.isInitialized(); - const result = await service.getApi(); + const result = await service.get(); - expect(result).to.have.all.keys([ 'v1' ]); - expect(result.v1).to.have.all.keys([ 'hasPermissions', 'hasAnyPermission', 'getExtensionLib' ]); + expect(result).to.contain.keys([ 'v1' ]); + expect(result.v1).to.contain.keys([ 'hasPermissions', 'hasAnyPermission', 'getExtensionLib', 'person' ]); expect(result.v1.hasPermissions).to.be.a('function'); expect(result.v1.hasAnyPermission).to.be.a('function'); expect(result.v1.getExtensionLib).to.be.a('function'); + expect(result.v1.person).to.be.a('object'); }); it('should initialize extension libs', async () => { @@ -78,7 +104,7 @@ describe('CHTScriptApiService service', () => { expect(http.get.args[1][0]).to.equal('/extension-libs/bar.js'); expect(http.get.args[2][0]).to.equal('/extension-libs/foo.js'); - const result = await service.getApi(); + const result = await service.get(); const foo = result.v1.getExtensionLib('foo.js'); expect(foo).to.be.a('function'); @@ -105,7 +131,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor', 'gateway' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_edit'); @@ -121,7 +147,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor', 'gateway' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_create_people'); @@ -138,7 +164,7 @@ describe('CHTScriptApiService service', () => { sessionService.userCtx.returns({ roles: [ 'nurse' ] }); await service.isInitialized(); const changesCallback = changesService.subscribe.args[0][0].callback; - const api = await service.getApi(); + const api = await service.get(); const permissionNotFound = api.v1.hasPermissions('can_create_people'); @@ -170,7 +196,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ '_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_create_people'); @@ -186,7 +212,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasPermissions('can_configure'); @@ -207,7 +233,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'district_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([ [ 'can_backup_facilities' ], @@ -227,7 +253,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'district_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([ [ 'can_backup_facilities', 'can_backup_people' ], @@ -248,7 +274,7 @@ describe('CHTScriptApiService service', () => { sessionService.userCtx.returns({ roles: [ 'nurse' ] }); await service.isInitialized(); const changesCallback = changesService.subscribe.args[0][0].callback; - const api = await service.getApi(); + const api = await service.get(); const permissionNotFound = api.v1.hasAnyPermission([[ 'can_create_people' ], [ '!can_edit' ]]); @@ -280,7 +306,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ '_admin' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([[ 'can_create_people' ], [ 'can_edit', 'can_configure' ]]); @@ -298,7 +324,7 @@ describe('CHTScriptApiService service', () => { }); sessionService.userCtx.returns({ roles: [ 'chw_supervisor' ] }); await service.isInitialized(); - const api = await service.getApi(); + const api = await service.get(); const result = api.v1.hasAnyPermission([[ 'can_configure', 'can_create_people' ], [ 'can_backup_facilities' ] ]); diff --git a/webapp/tests/karma/ts/services/contact-summary.service.spec.ts b/webapp/tests/karma/ts/services/contact-summary.service.spec.ts index a0794b99230..e74cb03a89a 100644 --- a/webapp/tests/karma/ts/services/contact-summary.service.spec.ts +++ b/webapp/tests/karma/ts/services/contact-summary.service.spec.ts @@ -8,14 +8,14 @@ import { PipesService } from '@mm-services/pipes.service'; import { SettingsService } from '@mm-services/settings.service'; import { FeedbackService } from '@mm-services/feedback.service'; import { UHCStatsService } from '@mm-services/uhc-stats.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('ContactSummary service', () => { let service; let Settings; let feedbackService; let uhcStatsService; - let chtScriptApiService; + let chtDatasourceService; let chtScriptApi; beforeEach(() => { @@ -31,8 +31,8 @@ describe('ContactSummary service', () => { hasAnyPermission: sinon.stub() } }; - chtScriptApiService = { - getApi: sinon.stub().returns(chtScriptApi) + chtDatasourceService = { + get: sinon.stub().returns(chtScriptApi) }; const pipesTransform = (name, value) => { @@ -48,7 +48,7 @@ describe('ContactSummary service', () => { { provide: PipesService, useValue: { transform: pipesTransform } }, { provide: FeedbackService, useValue: feedbackService }, { provide: UHCStatsService, useValue: uhcStatsService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService } + { provide: CHTDatasourceService, useValue: chtDatasourceService } ] }); service = TestBed.inject(ContactSummaryService); diff --git a/webapp/tests/karma/ts/services/contact-types.service.spec.ts b/webapp/tests/karma/ts/services/contact-types.service.spec.ts index da170878545..62d2d9b74c8 100644 --- a/webapp/tests/karma/ts/services/contact-types.service.spec.ts +++ b/webapp/tests/karma/ts/services/contact-types.service.spec.ts @@ -55,7 +55,7 @@ describe('ContactTypes service', () => { ]; Settings.resolves({ contact_types: types }); return service.get('something').then(type => { - expect(type.id).to.equal('something'); + expect(type?.id).to.equal('something'); }); }); }); diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index c1f29db935b..d8e52a2cd32 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -27,7 +27,7 @@ import { TranslateService } from '@mm-services/translate.service'; import { GlobalActions } from '@mm-actions/global'; import { FeedbackService } from '@mm-services/feedback.service'; import * as medicXpathExtensions from '../../../../src/js/enketo/medic-xpath-extensions'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; import { TrainingCardsService } from '@mm-services/training-cards.service'; import { EnketoService, EnketoFormContext } from '@mm-services/enketo.service'; import { cloneDeep } from 'lodash-es'; @@ -81,7 +81,7 @@ describe('Form service', () => { let xmlFormGetWithAttachment; let zScoreService; let zScoreUtil; - let chtScriptApiService; + let chtDatasourceService; let chtScriptApi; let globalActions; let trainingCardsService; @@ -144,7 +144,7 @@ describe('Form service', () => { zScoreUtil = sinon.stub(); zScoreService = { getScoreUtil: sinon.stub().resolves(zScoreUtil) }; chtScriptApi = sinon.stub(); - chtScriptApiService = { getApi: sinon.stub().resolves(chtScriptApi) }; + chtDatasourceService = { get: sinon.stub().resolves(chtScriptApi) }; globalActions = { setSnackbarContent: sinon.stub(GlobalActions.prototype, 'setSnackbarContent') }; setLastChangedDoc = sinon.stub(ServicesActions.prototype, 'setLastChangedDoc'); trainingCardsService = { @@ -178,7 +178,7 @@ describe('Form service', () => { { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: XmlFormsService, useValue: xmlFormsService }, { provide: ZScoreService, useValue: zScoreService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: TransitionsService, useValue: transitionsService }, { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, @@ -205,7 +205,7 @@ describe('Form service', () => { await service.init(); expect(zScoreService.getScoreUtil.callCount).to.equal(1); - expect(chtScriptApiService.getApi.callCount).to.equal(1); + expect(chtDatasourceService.get.callCount).to.equal(1); expect(medicXpathExtensions.init.callCount).to.equal(1); expect(medicXpathExtensions.init.args[0]).to.deep.equal([zScoreUtil, toBik_text, moment, chtScriptApi]); }); @@ -1320,7 +1320,7 @@ describe('Form service', () => { { provide: AttachmentService, useValue: { add: AddAttachment, remove: removeAttachment } }, { provide: XmlFormsService, useValue: xmlFormsService }, { provide: ZScoreService, useValue: zScoreService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService }, + { provide: CHTDatasourceService, useValue: chtDatasourceService }, { provide: TransitionsService, useValue: transitionsService }, { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, diff --git a/webapp/tests/karma/ts/services/rules-engine.service.spec.ts b/webapp/tests/karma/ts/services/rules-engine.service.spec.ts index dfb36250f56..ae696e18d5a 100644 --- a/webapp/tests/karma/ts/services/rules-engine.service.spec.ts +++ b/webapp/tests/karma/ts/services/rules-engine.service.spec.ts @@ -17,7 +17,7 @@ import { ContactTypesService } from '@mm-services/contact-types.service'; import { TranslateFromService } from '@mm-services/translate-from.service'; import { RulesEngineCoreFactoryService, RulesEngineService } from '@mm-services/rules-engine.service'; import { PipesService } from '@mm-services/pipes.service'; -import { CHTScriptApiService } from '@mm-services/cht-script-api.service'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('RulesEngineService', () => { let service: RulesEngineService; @@ -32,7 +32,7 @@ describe('RulesEngineService', () => { let translateFromService; let rulesEngineCoreStubs; let pipesService; - let chtScriptApiService; + let chtDatasourceService; let performanceService; let stopPerformanceTrackStub; let clock; @@ -115,7 +115,7 @@ describe('RulesEngineService', () => { pipesMap: new Map(), getPipeNameVsIsPureMap: PipesService.prototype.getPipeNameVsIsPureMap }; - chtScriptApiService = { getApi: sinon.stub().returns(chtScriptApi) }; + chtDatasourceService = { get: sinon.stub().returns(chtScriptApi) }; stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; @@ -176,7 +176,7 @@ describe('RulesEngineService', () => { { provide: TranslateFromService, useValue: translateFromService }, { provide: RulesEngineCoreFactoryService, useValue: rulesEngineCoreFactory }, { provide: PipesService, useValue: pipesService }, - { provide: CHTScriptApiService, useValue: chtScriptApiService } + { provide: CHTDatasourceService, useValue: chtDatasourceService } ] }); }); diff --git a/webapp/tsconfig.base.json b/webapp/tsconfig.base.json index df1885dd36f..b41cb20ee68 100755 --- a/webapp/tsconfig.base.json +++ b/webapp/tsconfig.base.json @@ -11,6 +11,7 @@ "importHelpers": true, "target": "es2020", "module": "es2020", + "skipLibCheck": true, "lib": [ "es2016", "dom"