From 45f89453d310a75ca9ed4b76a3ac00c667e2ca18 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Thu, 18 Jun 2026 12:42:52 +0300 Subject: [PATCH 1/5] feat(#10637): doc summaries in datasource --- admin/src/js/services/get-data-records.js | 14 +- .../src/js/services/get-subject-summaries.js | 2 +- admin/src/js/services/get-summaries.js | 134 +--- .../src/js/services/hydrate-contact-names.js | 2 +- admin/src/js/services/message-queue.js | 9 +- admin/src/js/services/search.js | 4 +- .../unit/services/get-data-records.spec.js | 42 +- .../services/get-subject-summaries.spec.js | 2 +- .../tests/unit/services/get-summaries.spec.js | 260 ++------ .../services/hydrate-contact-names.spec.js | 2 +- .../tests/unit/services/message-queue.spec.js | 55 +- admin/tests/unit/services/search.spec.js | 2 +- api/src/controllers/contact.js | 51 ++ api/src/controllers/report.js | 53 +- api/src/routing.js | 2 + api/tests/mocha/controllers/contact.spec.js | 23 + api/tests/mocha/controllers/report.spec.js | 23 + .../medic/views/doc_summaries_by_id/map.js | 95 --- package-lock.json | 14 + shared-libs/cht-datasource/package.json | 1 + shared-libs/cht-datasource/src/contact.ts | 116 +++- .../src/libs/parameter-validators.ts | 11 + .../cht-datasource/src/local/contact.ts | 17 +- .../cht-datasource/src/local/report.ts | 14 +- shared-libs/cht-datasource/src/qualifier.ts | 32 + .../cht-datasource/src/remote/contact.ts | 13 +- .../cht-datasource/src/remote/report.ts | 11 +- shared-libs/cht-datasource/src/report.ts | 121 +++- .../cht-datasource/test/contact.spec.ts | 193 ++++++ .../test/libs/parameter-validators.spec.ts | 40 +- .../cht-datasource/test/local/contact.spec.ts | 52 ++ .../cht-datasource/test/local/report.spec.ts | 54 ++ .../cht-datasource/test/qualifier.spec.ts | 45 +- .../test/remote/contact.spec.ts | 46 +- .../cht-datasource/test/remote/report.spec.ts | 35 +- .../cht-datasource/test/report.spec.ts | 193 ++++++ shared-libs/summaries/package.json | 15 + shared-libs/summaries/src/index.d.ts | 52 ++ shared-libs/summaries/src/index.js | 119 ++++ shared-libs/summaries/test/index.spec.js | 257 ++++++++ .../reports/reports-subject.wdio-spec.js | 2 +- .../ts/modules/contacts/contacts.component.ts | 2 +- .../ts/services/get-data-records.service.ts | 18 +- .../services/get-subject-summaries.service.ts | 2 +- .../src/ts/services/get-summaries.service.ts | 140 +---- .../services/hydrate-contact-names.service.ts | 2 +- .../ts/services/message-contact.service.ts | 2 +- .../report-view-model-generator.service.ts | 2 +- webapp/src/ts/services/search.service.ts | 8 +- .../ts/services/target-aggregates.service.ts | 6 +- .../src/ts/services/user-settings.service.ts | 2 +- .../contacts/contacts.component.spec.ts | 10 +- ...ntact-view-model-generator.service.spec.ts | 3 +- .../services/get-data-records.service.spec.ts | 68 ++- .../get-subject-summaries.service.spec.ts | 2 +- .../ts/services/get-summaries.service.spec.ts | 402 ++++-------- .../hydrate-contact-names.service.spec.ts | 2 +- .../services/message-contact.service.spec.ts | 16 +- ...eport-view-model-generator.service.spec.ts | 8 +- .../karma/ts/services/search.service.spec.ts | 2 +- .../target-aggregates.service.spec.ts | 109 ++-- .../ts/services/user-settings.service.spec.ts | 2 +- .../unit/views/doc_summaries_by_id.spec.js | 576 ------------------ 63 files changed, 2024 insertions(+), 1588 deletions(-) delete mode 100644 ddocs/medic-db/medic/views/doc_summaries_by_id/map.js create mode 100644 shared-libs/summaries/package.json create mode 100644 shared-libs/summaries/src/index.d.ts create mode 100644 shared-libs/summaries/src/index.js create mode 100644 shared-libs/summaries/test/index.spec.js delete mode 100644 webapp/tests/mocha/unit/views/doc_summaries_by_id.spec.js diff --git a/admin/src/js/services/get-data-records.js b/admin/src/js/services/get-data-records.js index 060e23417f4..2849f13a755 100644 --- a/admin/src/js/services/get-data-records.js +++ b/admin/src/js/services/get-data-records.js @@ -36,8 +36,9 @@ angular.module('inboxServices').factory('GetDataRecords', }); }; - const getSummaries = function(ids, options) { - return GetSummaries(ids) + const getSummaries = function(ids, type, options) { + const summariesPromise = type === 'report' ? GetSummaries.getReports(ids) : GetSummaries.getContacts(ids); + return summariesPromise .then(summaries => { const promiseToSummary = options.hydrateContactNames ? HydrateContactNames(summaries) : Promise.resolve(summaries); @@ -45,7 +46,7 @@ angular.module('inboxServices').factory('GetDataRecords', }); }; - return function(ids, options) { + const getRecords = function(ids, type, options) { const opts = Object.assign({ hydrateContactNames: false, include_docs: false }, options); if (!ids) { @@ -58,7 +59,7 @@ angular.module('inboxServices').factory('GetDataRecords', if (!ids.length) { return $q.resolve([]); } - const getFn = opts.include_docs ? getDocs : ids => getSummaries(ids, opts); + const getFn = opts.include_docs ? getDocs : idList => getSummaries(idList, type, opts); return getFn(ids) .then(function(response) { if (!arrayGiven) { @@ -67,4 +68,9 @@ angular.module('inboxServices').factory('GetDataRecords', return response; }); }; + + return { + getContacts: (ids, options) => getRecords(ids, 'contact', options), + getReports: (ids, options) => getRecords(ids, 'report', options), + }; }); diff --git a/admin/src/js/services/get-subject-summaries.js b/admin/src/js/services/get-subject-summaries.js index c48647b086a..372cb27db0a 100644 --- a/admin/src/js/services/get-subject-summaries.js +++ b/admin/src/js/services/get-subject-summaries.js @@ -67,7 +67,7 @@ angular.module('inboxServices').factory('GetSubjectSummaries', return $q.resolve(summaries); } - return GetSummaries([...new Set(ids)]) + return GetSummaries.getContacts([...new Set(ids)]) .then(function(response) { return replaceIdsWithNames(summaries, response); }); diff --git a/admin/src/js/services/get-summaries.js b/admin/src/js/services/get-summaries.js index 378ea1e46bf..5d36ed7baf4 100644 --- a/admin/src/js/services/get-summaries.js +++ b/admin/src/js/services/get-summaries.js @@ -1,131 +1,31 @@ -const constants = require('@medic/constants'); -const DOC_TYPES = constants.DOC_TYPES; +const cht = require('@medic/cht-datasource'); angular.module('inboxServices').factory('GetSummaries', function( - $q, - ContactTypes, - DB, - Session + DataContext ) { 'use strict'; 'ngInject'; - const SUBJECT_FIELDS = [ 'patient_id', 'patient_name', 'place_id' ]; - - const getLineage = contact => { - const parts = []; - while (contact) { - if (contact._id) { - parts.push(contact._id); - } - contact = contact.parent; - } - return parts; - }; - - const isMissingSubjectError = error => { - return error.code === 'sys.missing_fields' && - error.fields && - error.fields.some(field => SUBJECT_FIELDS.includes(field)); - }; - - const getSubject = doc => { - const subject = {}; - const reference = doc.patient_id || - (doc.fields && doc.fields.patient_id) || - doc.place_id; - const patientName = doc.fields && doc.fields.patient_name; - if (patientName) { - subject.name = patientName; - } - - if (reference) { - subject.value = reference; - subject.type = 'reference'; - } else if (doc.fields && doc.fields.place_id) { - subject.value = doc.fields.place_id; - subject.type = 'id'; - } else if (patientName) { - subject.value = patientName; - subject.type = 'name'; - } else if (doc.errors) { - if (doc.errors.some(error => isMissingSubjectError(error))) { - subject.type = 'unknown'; + const collect = (generator) => { + const summaries = []; + const iterate = () => generator.next().then(result => { + if (result.done) { + return summaries; } - } - - return subject; - }; - - // WARNING: This is a copy of the medic/doc_summaries_by_id view - // with some minor modifications and needs to be kept in sync until - // this workaround is no longer needed. - // https://github.com/medic/medic/issues/4666 - const summarise = doc => { - if (!doc) { - // happens when the doc with the requested id wasn't found in the DB - return; - } - - if (doc.type === DOC_TYPES.DATA_RECORD && doc.form) { // report - return { - _id: doc._id, - _rev: doc._rev, - from: doc.from || doc.sent_by, - phone: doc.contact && doc.contact.phone, - form: doc.form, - read: doc.read, - valid: !doc.errors || !doc.errors.length, - verified: doc.verified, - reported_date: doc.reported_date, - contact: doc.contact && doc.contact._id, - lineage: getLineage(doc.contact && doc.contact.parent), - subject: getSubject(doc), - case_id: doc.case_id || (doc.fields && doc.fields.case_id) - }; - } - if (ContactTypes.includes(doc)) { // contact - return { - _id: doc._id, - _rev: doc._rev, - name: doc.name || doc.phone, - phone: doc.phone, - type: doc.type, - contact_type: doc.contact_type, - contact: doc.contact && doc.contact._id, - lineage: getLineage(doc.parent), - date_of_death: doc.date_of_death, - muted: doc.muted - }; - } - }; - - const getRemote = ids => { - return DB().query('medic/doc_summaries_by_id', { keys: ids }).then(response => { - return response.rows.map(row => { - row.value._id = row.id; - return row.value; - }); - }); - }; - - const getLocal = ids => { - return DB().allDocs({ keys: ids, include_docs: true }).then(response => { - return response.rows - .map(row => summarise(row.doc)) - .filter(summary => summary); + summaries.push(result.value); + return iterate(); }); + return iterate(); }; - return ids => { - if (!ids || !ids.length) { - return $q.resolve([]); - } - if (Session.isOnlineOnly()) { - return getRemote(ids); - } - return getLocal(ids); + return { + getContacts: ids => DataContext.then( + dataContext => collect(dataContext.bind(cht.Contact.v1.getSummaries)(cht.Qualifier.byIds(ids))) + ), + getReports: ids => DataContext.then( + dataContext => collect(dataContext.bind(cht.Report.v1.getSummaries)(cht.Qualifier.byIds(ids))) + ), }; }); diff --git a/admin/src/js/services/hydrate-contact-names.js b/admin/src/js/services/hydrate-contact-names.js index aec6ddf10e3..5ee65a11c9f 100644 --- a/admin/src/js/services/hydrate-contact-names.js +++ b/admin/src/js/services/hydrate-contact-names.js @@ -61,7 +61,7 @@ angular.module('inboxServices').factory('HydrateContactNames', return $q.resolve(summaries); } - return GetSummaries(ids) + return GetSummaries.getContacts(ids) .then(function(response) { summaries = getMutedState(summaries, response); return replaceContactIdsWithNames(summaries, response); diff --git a/admin/src/js/services/message-queue.js b/admin/src/js/services/message-queue.js index e900aefdec4..dd4b2c37353 100644 --- a/admin/src/js/services/message-queue.js +++ b/admin/src/js/services/message-queue.js @@ -26,6 +26,7 @@ angular.module('services').factory('MessageQueue', $q, $translate, DB, + GetSummaries, Languages, MessageQueueUtils, Settings @@ -39,11 +40,9 @@ angular.module('services').factory('MessageQueue', return; } - const summary = summaries.rows.find(function(summary) { - return summary.value && summary.value.phone === message.sms.to; + return summaries.find(function(summary) { + return summary && summary.phone === message.sms.to; }); - - return summary && summary.value; }; const findIdByKey = (contactsByReference, key) => { @@ -101,7 +100,7 @@ angular.module('services').factory('MessageQueue', return row.id; }); - return DB({ remote: true }).query('medic/doc_summaries_by_id', { keys: ids }); + return GetSummaries.getContacts(ids); }) .then(function(summaries) { messages.forEach(function(message) { diff --git a/admin/src/js/services/search.js b/admin/src/js/services/search.js index a2f993842fe..89ec4920adb 100644 --- a/admin/src/js/services/search.js +++ b/admin/src/js/services/search.js @@ -60,7 +60,9 @@ const Search = require('@medic/search'); } return _search(type, filters, options) .then(function(searchResults) { - return GetDataRecords(searchResults.docIds, options); + return type === 'reports' ? + GetDataRecords.getReports(searchResults.docIds, options) : + GetDataRecords.getContacts(searchResults.docIds, options); }) .then(function(results) { _currentQuery = {}; diff --git a/admin/tests/unit/services/get-data-records.spec.js b/admin/tests/unit/services/get-data-records.spec.js index d151e2d0394..fc66887968e 100644 --- a/admin/tests/unit/services/get-data-records.spec.js +++ b/admin/tests/unit/services/get-data-records.spec.js @@ -5,39 +5,41 @@ describe('GetDataRecords service', () => { let service; let allDocs; let GetSummaries; + let GetReportSummaries; let HydrateContactNames; beforeEach(() => { allDocs = sinon.stub(); GetSummaries = sinon.stub(); + GetReportSummaries = sinon.stub(); HydrateContactNames = sinon.stub(); module('adminApp'); module($provide => { $provide.factory('DB', KarmaUtils.mockDB({ allDocs: allDocs })); $provide.value('$q', Q); // bypass $q so we don't have to digest $provide.value('HydrateContactNames', HydrateContactNames); - $provide.value('GetSummaries', GetSummaries); + $provide.value('GetSummaries', { getContacts: GetSummaries, getReports: GetReportSummaries }); }); inject($injector => service = $injector.get('GetDataRecords')); }); afterEach(() => { - KarmaUtils.restore(allDocs, GetSummaries, HydrateContactNames); + KarmaUtils.restore(allDocs, GetSummaries, GetReportSummaries, HydrateContactNames); }); it('returns empty array when given no ids', () => { - return service().then(actual => chai.expect(actual).to.deep.equal([])); + return service.getContacts().then(actual => chai.expect(actual).to.deep.equal([])); }); it('returns empty array when given empty array', () => { - return service([]).then(actual => chai.expect(actual).to.deep.equal([])); + return service.getContacts([]).then(actual => chai.expect(actual).to.deep.equal([])); }); describe('summaries', () => { it('db errors', () => { GetSummaries.returns(Promise.reject('missing')); - return service('5') + return service.getContacts('5') .then(() => { throw new Error('expected error to be thrown'); }) @@ -47,7 +49,7 @@ describe('GetDataRecords service', () => { it('no result', () => { GetSummaries.returns(Promise.resolve(null)); HydrateContactNames.returns(Promise.resolve([ ])); - return service('5').then(actual => { + return service.getContacts('5').then(actual => { chai.expect(actual).to.equal(null); chai.expect(GetSummaries.callCount).to.equal(1); chai.expect(GetSummaries.args[0][0]).to.deep.equal(['5']); @@ -71,7 +73,7 @@ describe('GetDataRecords service', () => { } ])); HydrateContactNames.returns(Promise.resolve([ expected ])); - return service('5', { hydrateContactNames: true }).then(actual => { + return service.getContacts('5', { hydrateContactNames: true }).then(actual => { chai.expect(actual).to.deep.equal(expected); chai.expect(GetSummaries.callCount).to.equal(1); chai.expect(allDocs.callCount).to.equal(0); @@ -97,7 +99,7 @@ describe('GetDataRecords service', () => { { _id: '7', name: 'seven' } ])); HydrateContactNames.returns(Promise.resolve(expected)); - return service([ '5', '6', '7' ], { hydrateContactNames: true }).then(actual => { + return service.getContacts([ '5', '6', '7' ], { hydrateContactNames: true }).then(actual => { chai.expect(actual).to.deep.equal(expected); chai.expect(GetSummaries.callCount).to.equal(1); chai.expect(GetSummaries.args[0][0]).to.deep.equal([ '5', '6', '7' ]); @@ -107,11 +109,27 @@ describe('GetDataRecords service', () => { }); + describe('reports', () => { + + it('only loads report summaries', () => { + const summaries = [{ _id: '5', name: 'five' }]; + GetReportSummaries.returns(Promise.resolve(summaries)); + return service.getReports([ '5' ]).then(actual => { + chai.expect(actual).to.deep.equal(summaries); + chai.expect(GetReportSummaries.callCount).to.equal(1); + chai.expect(GetReportSummaries.args[0][0]).to.deep.equal(['5']); + chai.expect(GetSummaries.callCount).to.equal(0); + chai.expect(allDocs.callCount).to.equal(0); + }); + }); + + }); + describe('details', () => { it('db errors', () => { allDocs.returns(Promise.reject('missing')); - return service('5', { include_docs: true }) + return service.getContacts('5', { include_docs: true }) .then(() => { throw new Error('expected error to be thrown'); }) @@ -120,7 +138,7 @@ describe('GetDataRecords service', () => { it('no result', () => { allDocs.returns(Promise.resolve({ rows: [] })); - return service('5', { include_docs: true }).then(actual => { + return service.getContacts('5', { include_docs: true }).then(actual => { chai.expect(actual).to.equal(null); chai.expect(allDocs.callCount).to.equal(1); chai.expect(allDocs.args[0][0]).to.deep.equal({ keys: [ '5' ], include_docs: true }); @@ -132,7 +150,7 @@ describe('GetDataRecords service', () => { allDocs.returns(Promise.resolve({ rows: [ { doc: { _id: '5', name: 'five' } } ] })); - return service('5', { include_docs: true }).then(actual => { + return service.getContacts('5', { include_docs: true }).then(actual => { chai.expect(actual).to.deep.equal({ _id: '5', name: 'five' }); chai.expect(allDocs.callCount).to.equal(1); chai.expect(allDocs.args[0][0]).to.deep.equal({ keys: [ '5' ], include_docs: true }); @@ -146,7 +164,7 @@ describe('GetDataRecords service', () => { { doc: { _id: '6', name: 'six' } }, { doc: { _id: '7', name: 'seven' } } ] })); - return service([ '5', '6', '7' ], { include_docs: true }).then(actual => { + return service.getContacts([ '5', '6', '7' ], { include_docs: true }).then(actual => { chai.expect(actual).to.deep.equal([ { _id: '5', name: 'five' }, { _id: '6', name: 'six' }, diff --git a/admin/tests/unit/services/get-subject-summaries.spec.js b/admin/tests/unit/services/get-subject-summaries.spec.js index 5c54421909b..443c079c14b 100644 --- a/admin/tests/unit/services/get-subject-summaries.spec.js +++ b/admin/tests/unit/services/get-subject-summaries.spec.js @@ -26,7 +26,7 @@ describe('GetSubjectSummaries service', () => { module($provide => { $provide.factory('DB', KarmaUtils.mockDB({ query: query })); $provide.value('$q', Q); // bypass $q so we don't have to digest - $provide.value('GetSummaries', GetSummaries); + $provide.value('GetSummaries', { getContacts: GetSummaries }); $provide.value('LineageModelGenerator', LineageModelGenerator); }); inject($injector => service = $injector.get('GetSubjectSummaries')); diff --git a/admin/tests/unit/services/get-summaries.spec.js b/admin/tests/unit/services/get-summaries.spec.js index 48c87339509..3eb1dae648b 100644 --- a/admin/tests/unit/services/get-summaries.spec.js +++ b/admin/tests/unit/services/get-summaries.spec.js @@ -1,251 +1,65 @@ -const { DOC_TYPES } = require('@medic/constants'); - describe('GetSummaries service', () => { 'use strict'; let service; - let query; - let allDocs; - let includes; - let isOnlineOnly; + let bind; + let summariesFn; + + const fakeGenerator = (values) => (async function* () { + yield* values; + })(); beforeEach(() => { - query = sinon.stub(); - allDocs = sinon.stub(); - includes = sinon.stub(); - isOnlineOnly = sinon.stub(); + summariesFn = sinon.stub(); + bind = sinon.stub().returns(summariesFn); + + const dataContext = { bind }; module('adminApp'); module($provide => { - $provide.factory('DB', KarmaUtils.mockDB({ query, allDocs })); - $provide.value('ContactTypes', { includes }); - $provide.value('Session', { isOnlineOnly }); - $provide.value('$q', Q); // bypass $q so we don't have to digest + $provide.value('DataContext', Promise.resolve(dataContext)); }); inject($injector => service = $injector.get('GetSummaries')); }); - it('returns empty array when given no ids', () => { - return service().then(actual => { - chai.expect(actual).to.deep.equal([]); - }); - }); - - it('returns empty array when given empty array', () => { - return service([]).then(actual => { - chai.expect(actual).to.deep.equal([]); - }); - }); - - describe('online users', () => { - - beforeEach(() => isOnlineOnly.returns(true)); + describe('getContacts', () => { + it('loads summaries for the given ids', () => { + const contactSummaries = [{ _id: 'a', name: 'james' }]; + summariesFn.returns(fakeGenerator(contactSummaries)); - it('queries the view and returns', () => { - query.resolves({ rows: [ - { - id: 'a', - value: { reported_date: 1 } - }, - { - id: 'b', - value: { reported_date: 2 } - }, - ] }); - return service([ 'a', 'b' ]).then(actual => { - chai.expect(query.callCount).to.equal(1); - chai.expect(query.args[0][0]).to.equal('medic/doc_summaries_by_id'); - chai.expect(query.args[0][1]).to.deep.equal({ keys: [ 'a', 'b' ] }); - chai.expect(allDocs.callCount).to.equal(0); - chai.expect(actual).to.deep.equal([ - { - _id: 'a', - reported_date: 1 - }, - { - _id: 'b', - reported_date: 2 - }, - ]); + return service.getContacts([ 'a', 'b' ]).then(actual => { + chai.expect(bind.callCount).to.equal(1); + chai.expect(summariesFn.calledOnceWithExactly({ ids: [ 'a', 'b' ] })).to.equal(true); + chai.expect(actual).to.deep.equal(contactSummaries); }); }); - }); - describe('restricted users', () => { - - beforeEach(() => isOnlineOnly.returns(false)); - - it('queries allDocs and summarises reports', () => { - allDocs.resolves({ rows: [ - { doc: { - _id: 'a', - _rev: '1', - type: DOC_TYPES.DATA_RECORD, - form: 'delivery', - from: '+123', - contact: { - _id: 'c', - phone: '+456', - parent: { - _id: 'd', - parent: { - _id: 'e' - } - } - }, - verified: true, - reported_date: 100, - fields: { - patient_name: 'jeff', - patient_id: 'f' - } - } }, - { doc: { - _id: 'b', - _rev: '2', - type: DOC_TYPES.DATA_RECORD, - form: 'registration', - sent_by: '+321', - errors: [ { code: 'sys.missing_fields', fields: [ 'patient_id' ] } ], - reported_date: 200 - } }, - ] }); - return service([ 'a', 'b' ]).then(actual => { - chai.expect(query.callCount).to.equal(0); - chai.expect(allDocs.callCount).to.equal(1); - chai.expect(allDocs.args[0][0]).to.deep.equal({ keys: [ 'a', 'b' ], include_docs: true }); - chai.expect(actual).to.deep.equal([ - { - _id: 'a', - _rev: '1', - from: '+123', - phone: '+456', - form: 'delivery', - read: undefined, - valid: true, - verified: true, - reported_date: 100, - contact: 'c', - lineage: [ 'd', 'e' ], - subject: { - name: 'jeff', - value: 'f', - type: 'reference' - }, - case_id: undefined - }, - { - _id: 'b', - _rev: '2', - from: '+321', - phone: undefined, - form: 'registration', - read: undefined, - valid: false, - verified: undefined, - reported_date: 200, - contact: undefined, - lineage: [], - subject: { - type: 'unknown' - }, - case_id: undefined - } - ]); - }); - }); + describe('getReports', () => { + it('loads summaries for the given ids', () => { + const reportSummaries = [{ _id: 'b', form: 'delivery' }]; + summariesFn.returns(fakeGenerator(reportSummaries)); - it('queries allDocs and summarises contacts', () => { - includes.returns(true); - allDocs.resolves({ rows: [ - { doc: { - _id: 'a', - _rev: '1', - type: 'person', - name: 'james', - phone: '+456', - contact: { - _id: 'c', - phone: '+456', - parent: { - _id: 'd', - parent: { - _id: 'e' - } - } - }, - date_of_death: 999 - } }, - { doc: { - _id: 'b', - _rev: '2', - type: 'contact', - contact_type: 'patient', - phone: '+123', - parent: { - _id: 'f', - parent: { - _id: 'g' - } - }, - muted: true - } }, - ] }); - return service([ 'a', 'b' ]).then(actual => { - chai.expect(query.callCount).to.equal(0); - chai.expect(allDocs.callCount).to.equal(1); - chai.expect(allDocs.args[0][0]).to.deep.equal({ keys: [ 'a', 'b' ], include_docs: true }); - chai.expect(actual).to.deep.equal([ - { - _id: 'a', - _rev: '1', - name: 'james', - phone: '+456', - type: 'person', - contact_type: undefined, - contact: 'c', - lineage: [], - date_of_death: 999, - muted: undefined - }, - { - _id: 'b', - _rev: '2', - name: '+123', - phone: '+123', - type: 'contact', - contact_type: 'patient', - contact: undefined, - lineage: [ 'f', 'g' ], - date_of_death: undefined, - muted: true - } - ]); + return service.getReports([ 'a', 'b' ]).then(actual => { + chai.expect(bind.callCount).to.equal(1); + chai.expect(summariesFn.calledOnceWithExactly({ ids: [ 'a', 'b' ] })).to.equal(true); + chai.expect(actual).to.deep.equal(reportSummaries); }); }); + }); - it('queries allDocs and ignores other types', () => { - includes.returns(false); - allDocs.resolves({ rows: [ - { doc: { - type: 'form' - } } - ] }); - return service([ 'a', 'b' ]).then(actual => { - chai.expect(actual).to.deep.equal([]); - }); - }); + it('binds a different datasource function for contacts than for reports', () => { + summariesFn.callsFake(() => fakeGenerator([])); - it('queries allDocs and ignores docs that are missing', () => { - includes.returns(false); - allDocs.resolves({ rows: [ { key: 'a', error: 'not_found' } ] }); - return service([ 'a' ]).then(actual => { - chai.expect(actual).to.deep.equal([]); + return service.getContacts([ 'a' ]) + .then(() => service.getReports([ 'b' ])) + .then(() => { + chai.expect(bind.callCount).to.equal(2); + chai.expect(bind.firstCall.args[0]).to.be.a('function'); + chai.expect(bind.secondCall.args[0]).to.be.a('function'); + chai.expect(bind.firstCall.args[0]).to.not.equal(bind.secondCall.args[0]); }); - }); - }); }); diff --git a/admin/tests/unit/services/hydrate-contact-names.spec.js b/admin/tests/unit/services/hydrate-contact-names.spec.js index df572608637..288ce675140 100644 --- a/admin/tests/unit/services/hydrate-contact-names.spec.js +++ b/admin/tests/unit/services/hydrate-contact-names.spec.js @@ -10,7 +10,7 @@ describe('HydrateContactNames service', () => { module('adminApp'); module($provide => { $provide.value('$q', Q); // bypass $q so we don't have to digest - $provide.value('GetSummaries', GetSummaries); + $provide.value('GetSummaries', { getContacts: GetSummaries }); }); inject($injector => service = $injector.get('HydrateContactNames')); }); diff --git a/admin/tests/unit/services/message-queue.spec.js b/admin/tests/unit/services/message-queue.spec.js index 5f890306af0..93dc955bff2 100644 --- a/admin/tests/unit/services/message-queue.spec.js +++ b/admin/tests/unit/services/message-queue.spec.js @@ -9,6 +9,7 @@ describe('MessageQueue service', function() { let Languages; let utils; let query; + let GetSummaries; let translate; let clock; @@ -16,6 +17,7 @@ describe('MessageQueue service', function() { Settings = sinon.stub(); Languages = sinon.stub(); query = sinon.stub(); + GetSummaries = sinon.stub(); translate = sinon.stub(); translate.instant = sinon.stub(); translate.storageKey = sinon.stub(); @@ -43,6 +45,7 @@ describe('MessageQueue service', function() { $provide.value('Settings', Settings); $provide.value('Languages', Languages); $provide.value('MessageQueueUtils', utils); + $provide.value('GetSummaries', { getContacts: GetSummaries }); $provide.factory('DB', KarmaUtils.mockDB({ query: query })); }); @@ -289,9 +292,9 @@ describe('MessageQueue service', function() { rows: [{ id: 'contact1', value: 'contact1', key: 'phone1' }] }); - query.withArgs('medic/doc_summaries_by_id').resolves({ - rows: [{ id: 'contact1', value: { name: 'James', phone: 'phone1' } }] - }); + GetSummaries + .withArgs(['contact1']) + .resolves([{ _id: 'contact1', type: 'person', name: 'James', phone: 'phone1' }]); translate.instant.withArgs('task1').returns('task 1 translation'); @@ -303,17 +306,15 @@ describe('MessageQueue service', function() { return service.query('due').then(result => { chai.expect(result.total).to.equal(2); - chai.expect(query.callCount).to.equal(4); + chai.expect(query.callCount).to.equal(3); chai.expect(query.args[2]).to.deep.equal([ 'medic-client/contacts_by_phone', { keys: ['phone1', 'phone2'] } ]); - chai.expect(query.args[3]).to.deep.equal([ - 'medic/doc_summaries_by_id', - { keys: ['contact1'] } - ]); + chai.expect(GetSummaries.callCount).to.equal(1); + chai.expect(GetSummaries.args[0][0]).to.deep.equal(['contact1']); chai.expect(translate.instant.callCount).to.equal(1); chai.expect(translate.instant.args[0]).to.deep.equal([ 'task1', { group: 1 } ]); @@ -412,17 +413,13 @@ describe('MessageQueue service', function() { .withArgs('medic-client/contacts_by_phone') .resolves({ rows: [ { value: 'contact1', key: 'phone1' }, { value: 'contact2', key: 'phone2' } ] }); - query - .withArgs('medic/doc_summaries_by_id') - .resolves({ - rows: [ - { value: { id: 'contact1', name: 'contact one', phone: 'phone1' } }, - { value: { id: 'contact2', name: 'contact two', phone: 'phone2' } }, - ] - }); + GetSummaries.resolves([ + { _id: 'contact1', type: 'person', name: 'contact one', phone: 'phone1' }, + { _id: 'contact2', type: 'person', name: 'contact two', phone: 'phone2' }, + ]); return service.query('due').then(result => { - chai.expect(query.callCount).to.equal(4); + chai.expect(query.callCount).to.equal(3); chai.expect(query.args[2]).to.deep.equal([ 'medic-client/contacts_by_phone', { keys: [ 'phone1', 'phone2' ]} ]); @@ -590,13 +587,11 @@ describe('MessageQueue service', function() { query .withArgs('medic-client/contacts_by_phone') .resolves({ rows: [{ key: 'recipient_id', value: 'recipient' }]}); - query - .withArgs('medic/doc_summaries_by_id') - .resolves({ rows: [{ key: 'recipient_id', value: { phone: 'recipient' }}]}); + GetSummaries.resolves([{ _id: 'recipient_id', type: 'person', phone: 'recipient' }]); return service.query('tab').then(result => { chai.expect(result.messages.length).to.equal(15); - chai.expect(query.callCount).to.equal(6); + chai.expect(query.callCount).to.equal(5); chai.expect(query.args[2]).to.deep.equal([ 'medic-client/contacts_by_reference', { @@ -761,13 +756,11 @@ describe('MessageQueue service', function() { { key: 'recipient2', id: 'recipient2_id' }, { key: 'recipient3', id: 'recipient3_id' } ]}); - query - .withArgs('medic/doc_summaries_by_id') - .resolves({ rows: [ - { key: 'recipient1_id', value: { phone: 'recipient1', name: 'recipient 1' }}, - { key: 'recipient2_id', value: { phone: 'recipient2', name: 'recipient 2' }}, - { key: 'recipient3_id', value: { phone: 'recipient3', name: 'recipient 3' }} - ]}); + GetSummaries.resolves([ + { _id: 'recipient1_id', type: 'person', phone: 'recipient1', name: 'recipient 1' }, + { _id: 'recipient2_id', type: 'person', phone: 'recipient2', name: 'recipient 2' }, + { _id: 'recipient3_id', type: 'person', phone: 'recipient3', name: 'recipient 3' }, + ]); return service.query('tab').then((result) => { chai.expect(utils.registrations.isValidRegistration.callCount).to.equal(13); @@ -1079,9 +1072,9 @@ describe('MessageQueue service', function() { query .withArgs('medic-client/contacts_by_phone') .resolves({ rows: [{ key: 'recipient1', id: 'recipien_id' }]}); - query - .withArgs('medic/doc_summaries_by_id') - .resolves({ rows: [{ key: 'recipient_id', value: { phone: 'recipient1', name: 'recipient' }}]}); + GetSummaries.resolves([ + { _id: 'recipien_id', type: 'person', phone: 'recipient1', name: 'recipient' }, + ]); return service.query('tab').then((result) => { chai.expect(utils.registrations.isValidRegistration.callCount).to.equal(4); diff --git a/admin/tests/unit/services/search.spec.js b/admin/tests/unit/services/search.spec.js index 80440d672b0..e0a23552c1e 100644 --- a/admin/tests/unit/services/search.spec.js +++ b/admin/tests/unit/services/search.spec.js @@ -40,7 +40,7 @@ describe('Search service', function() { module(function ($provide) { $provide.value('$q', Q); // bypass $q so we don't have to digest $provide.value('DB', sinon.stub().returns(db)); - $provide.value('GetDataRecords', GetDataRecords); + $provide.value('GetDataRecords', { getContacts: GetDataRecords, getReports: GetDataRecords }); $provide.value('SearchFactory', function() { return searchStub; }); diff --git a/api/src/controllers/contact.js b/api/src/controllers/contact.js index 2a1d6d07dba..e826b36806f 100644 --- a/api/src/controllers/contact.js +++ b/api/src/controllers/contact.js @@ -6,6 +6,7 @@ const serverUtils = require('../server-utils'); const getContact = ctx.bind(Contact.v1.get); const getContactWithLineage = ctx.bind(Contact.v1.getWithLineage); const getContactIds = ctx.bind(Contact.v1.getUuidsPage); +const getContactSummaries = ctx.bind(Contact.v1.getSummaries); /** * @openapi @@ -132,5 +133,55 @@ module.exports = { const docs = await getContactIds(qualifier, req.query.cursor, req.query.limit); return res.json(docs); }), + + /** + * @openapi + * /api/v1/contact/summary: + * post: + * summary: Get contact summaries by id + * operationId: v1ContactSummaryPost + * description: > + * Returns compact summary records for the contacts identified by the provided ids. Ids that do not + * identify an existing contact are silently omitted from the result. + * tags: [Contact] + * x-since: 5.3.0 + * x-permissions: + * hasAll: [can_view_contacts] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * ids: + * type: array + * items: + * type: string + * required: [ids] + * responses: + * '200': + * description: An array of contact summaries + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/v1.ContactSummary' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ + getSummaries: serverUtils.doOrError(async (req, res) => { + await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); + const summaries = []; + for await (const summary of getContactSummaries(Qualifier.byIds(req.body?.ids))) { + summaries.push(summary); + } + return res.json(summaries); + }), }, }; diff --git a/api/src/controllers/report.js b/api/src/controllers/report.js index e820648e90f..80eb8d6a6c2 100644 --- a/api/src/controllers/report.js +++ b/api/src/controllers/report.js @@ -6,6 +6,7 @@ const auth = require('../auth'); const getReport = ctx.bind(Report.v1.get); const getReportWithLineage = ctx.bind(Report.v1.getWithLineage); const getReportIds = ctx.bind(Report.v1.getUuidsPage); +const getReportSummaries = ctx.bind(Report.v1.getSummaries); const create = ctx.bind(Report.v1.create); const update = ctx.bind(Report.v1.update); @@ -26,7 +27,7 @@ module.exports = { * description: > * Returns a report record. Optionally includes the full contact, patient, and/or place lineage. * tags: [Report] - * x-since: 4.18.0 + * x-since: 5.3.0 * x-permissions: * hasAll: [can_view_reports] * parameters: @@ -118,6 +119,56 @@ module.exports = { return res.json(docs); }), + /** + * @openapi + * /api/v1/report/summary: + * post: + * summary: Get report summaries by id + * operationId: v1ReportSummaryPost + * description: > + * Returns compact summary records for the reports identified by the provided ids. Ids that do not + * identify an existing report are silently omitted from the result. + * tags: [Report] + * x-since: 5.3.0 + * x-permissions: + * hasAll: [can_view_reports] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * ids: + * type: array + * items: + * type: string + * required: [ids] + * responses: + * '200': + * description: An array of report summaries + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/v1.ReportSummary' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ + getSummaries: serverUtils.doOrError(async (req, res) => { + await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_reports'] }); + const summaries = []; + for await (const summary of getReportSummaries(Qualifier.byIds(req.body?.ids))) { + summaries.push(summary); + } + return res.json(summaries); + }), + /** * @openapi * /api/v1/report: diff --git a/api/src/routing.js b/api/src/routing.js index 370f0f2cf3a..e47f38f0629 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -738,9 +738,11 @@ app.postJson('/api/v1/person', person.v1.create); app.putJson('/api/v1/person/:uuid', person.v1.update); app.get('/api/v1/contact/uuid', contact.v1.getUuids); +app.postJson('/api/v1/contact/summary', contact.v1.getSummaries); app.get('/api/v1/contact/:uuid', contact.v1.get); app.get('/api/v1/report/uuid', report.v1.getUuids); +app.postJson('/api/v1/report/summary', report.v1.getSummaries); app.get('/api/v1/report/:uuid', report.v1.get); app.postJson('/api/v1/report', report.v1.create); app.putJson('/api/v1/report/:uuid', report.v1.update); diff --git a/api/tests/mocha/controllers/contact.spec.js b/api/tests/mocha/controllers/contact.spec.js index 47bd33027de..1f5c64a35bc 100644 --- a/api/tests/mocha/controllers/contact.spec.js +++ b/api/tests/mocha/controllers/contact.spec.js @@ -10,6 +10,7 @@ describe('Contact Controller', () => { const contactGet = sandbox.stub(); const contactGetWithLineage = sandbox.stub(); const contactGetUuidsPage = sandbox.stub(); + const contactGetSummaries = sandbox.stub(); let assertPermissions; let serverUtilsError; @@ -22,6 +23,7 @@ describe('Contact Controller', () => { bind.withArgs(Contact.v1.get).returns(contactGet); bind.withArgs(Contact.v1.getWithLineage).returns(contactGetWithLineage); bind.withArgs(Contact.v1.getUuidsPage).returns(contactGetUuidsPage); + bind.withArgs(Contact.v1.getSummaries).returns(contactGetSummaries); controller = require('../../../src/controllers/contact'); }); @@ -253,5 +255,26 @@ describe('Contact Controller', () => { expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true; }); }); + + describe('getSummaries', () => { + it('returns summaries for the provided ids', async () => { + const ids = ['a', 'b']; + const summaries = [{ _id: 'a' }, { _id: 'b' }]; + req = { body: { ids } }; + contactGetSummaries.returns((async function* () { + yield* summaries; + })()); + + await controller.v1.getSummaries(req, res); + + expect(assertPermissions.calledOnceWithExactly( + req, + { isOnline: true, hasAll: ['can_view_contacts'] } + )).to.be.true; + expect(contactGetSummaries.calledOnceWithExactly({ ids })).to.be.true; + expect(res.json.calledOnceWithExactly(summaries)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + }); }); }); diff --git a/api/tests/mocha/controllers/report.spec.js b/api/tests/mocha/controllers/report.spec.js index de2bde36a71..ec4c30c8237 100644 --- a/api/tests/mocha/controllers/report.spec.js +++ b/api/tests/mocha/controllers/report.spec.js @@ -11,6 +11,7 @@ describe('Report Controller Tests', () => { const reportGet = sandbox.stub(); const reportGetWithLineage = sandbox.stub(); const reportGetIdsPage = sandbox.stub(); + const reportGetSummaries = sandbox.stub(); const createReport = sandbox.stub(); const updateReport = sandbox.stub(); @@ -25,6 +26,7 @@ describe('Report Controller Tests', () => { bind.withArgs(Report.v1.get).returns(reportGet); bind.withArgs(Report.v1.getWithLineage).returns(reportGetWithLineage); bind.withArgs(Report.v1.getUuidsPage).returns(reportGetIdsPage); + bind.withArgs(Report.v1.getSummaries).returns(reportGetSummaries); bind.withArgs(Report.v1.create).returns(createReport); bind.withArgs(Report.v1.update).returns(updateReport); controller = require('../../../src/controllers/report'); @@ -179,6 +181,27 @@ describe('Report Controller Tests', () => { }); }); + describe('getSummaries', () => { + it('returns summaries for the provided ids', async () => { + const ids = ['a', 'b']; + const summaries = [{ _id: 'a' }, { _id: 'b' }]; + req = { body: { ids } }; + reportGetSummaries.returns((async function* () { + yield* summaries; + })()); + + await controller.v1.getSummaries(req, res); + + expect(assertPermissions.calledOnceWithExactly( + req, + { isOnline: true, hasAll: ['can_view_reports'] } + )).to.be.true; + expect(reportGetSummaries.calledOnceWithExactly({ ids })).to.be.true; + expect(res.json.calledOnceWithExactly(summaries)).to.be.true; + expect(serverUtilsError.notCalled).to.be.true; + }); + }); + describe('create', () => { it('returns a report doc on valid report input', async () => { const input = { diff --git a/ddocs/medic-db/medic/views/doc_summaries_by_id/map.js b/ddocs/medic-db/medic/views/doc_summaries_by_id/map.js deleted file mode 100644 index a20a9020bdb..00000000000 --- a/ddocs/medic-db/medic/views/doc_summaries_by_id/map.js +++ /dev/null @@ -1,95 +0,0 @@ -// WARNING: This is a copy of the GetSummaries service -// with some minor modifications and needs to be kept in sync until -// this workaround is no longer needed. -// https://github.com/medic/medic/issues/4666 - -function(doc) { - var getLineage = function(contact) { - var parts = []; - while (contact) { - if (contact._id) { - parts.push(contact._id); - } - contact = contact.parent; - } - return parts; - }; - - var isMissingSubjectError = function(error) { - if (error.code !== 'sys.missing_fields' || !error.fields) { - return false; - } - - if (error.fields.indexOf('patient_id') !== -1 || - error.fields.indexOf('patient_uuid') !== -1 || - error.fields.indexOf('patient_name') !== -1 || - error.fields.indexOf('place_id') !== -1) { - return true; - } - - return false; - }; - - var getSubject = function(doc) { - var subject = {}; - var reference = doc.patient_id || - (doc.fields && doc.fields.patient_id) || - (doc.fields && doc.fields.patient_uuid) || - doc.place_id || - (doc.fields && doc.fields.place_id); - - var patientName = doc.fields && doc.fields.patient_name; - if (patientName) { - subject.name = patientName; - } - - if (reference) { - subject.value = reference; - subject.type = 'reference'; - } else if (patientName) { - subject.value = patientName; - subject.type = 'name'; - } else if (doc.errors) { - doc.errors.forEach(function(error) { - if (isMissingSubjectError(error)) { - subject.type = 'unknown'; - } - }); - } - - return subject; - }; - - if (doc.type === 'data_record' && doc.form) { // report - emit(doc._id, { - _rev: doc._rev, - from: doc.from || doc.sent_by, - phone: doc.contact && doc.contact.phone, - form: doc.form, - read: doc.read, - valid: !doc.errors || !doc.errors.length, - verified: doc.verified, - reported_date: doc.reported_date, - contact: doc.contact && doc.contact._id, - lineage: getLineage(doc.contact && doc.contact.parent), - subject: getSubject(doc), - case_id: doc.case_id || (doc.fields && doc.fields.case_id) - }); - } else if (doc.type === 'contact' || - doc.type === 'clinic' || - doc.type === 'district_hospital' || - doc.type === 'health_center' || - doc.type === 'person') { // contact - emit(doc._id, { - _rev: doc._rev, - name: doc.name || doc.phone, - phone: doc.phone, - type: doc.type, - contact_type: doc.contact_type, - contact: doc.contact && doc.contact._id, - lineage: getLineage(doc.parent), - date_of_death: doc.date_of_death, - muted: doc.muted - }); - } -} diff --git a/package-lock.json b/package-lock.json index 88972dcdcf2..02979cf7e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5844,6 +5844,10 @@ "resolved": "shared-libs/settings", "link": true }, + "node_modules/@medic/summaries": { + "resolved": "shared-libs/summaries", + "link": true + }, "node_modules/@medic/task-utils": { "resolved": "shared-libs/task-utils", "link": true @@ -44288,6 +44292,7 @@ "dependencies": { "@medic/constants": "file:../constants", "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/summaries": "file:../summaries", "@medic/logger": "file:../logger" } }, @@ -44432,6 +44437,15 @@ "@medic/couch-request": "file:../couch-request" } }, + "shared-libs/summaries": { + "name": "@medic/summaries", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@medic/constants": "file:../constants", + "@medic/contact-types-utils": "file:../contact-types-utils" + } + }, "shared-libs/task-utils": { "name": "@medic/task-utils", "version": "1.0.0", diff --git a/shared-libs/cht-datasource/package.json b/shared-libs/cht-datasource/package.json index 11cae47c888..387ca4a5418 100644 --- a/shared-libs/cht-datasource/package.json +++ b/shared-libs/cht-datasource/package.json @@ -17,6 +17,7 @@ "license": "Apache-2.0", "dependencies": { "@medic/contact-types-utils": "file:../contact-types-utils", + "@medic/summaries": "file:../summaries", "@medic/logger": "file:../logger", "@medic/constants": "file:../constants" } diff --git a/shared-libs/cht-datasource/src/contact.ts b/shared-libs/cht-datasource/src/contact.ts index 2faf5f017a9..daf8753acd2 100644 --- a/shared-libs/cht-datasource/src/contact.ts +++ b/shared-libs/cht-datasource/src/contact.ts @@ -1,3 +1,4 @@ +import { ContactSummary as LibContactSummary } from '@medic/summaries'; import { getPagedGenerator, NormalizedParent, Nullable, @@ -8,21 +9,25 @@ import { byContactType, byFreetext, byUuid, + byIds, ContactTypeQualifier, FreetextQualifier, - UuidQualifier + UuidQualifier, + IdsQualifier } from './qualifier'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; import { LocalDataContext } from './local/libs/data-context'; +import { validateCursor } from './local/libs/core'; import { RemoteDataContext } from './remote/libs/data-context'; import * as Local from './local'; import * as Remote from './remote'; -import { DEFAULT_IDS_PAGE_LIMIT } from './libs/constants'; +import { DEFAULT_DOCS_PAGE_LIMIT, DEFAULT_IDS_PAGE_LIMIT } from './libs/constants'; import { assertContactTypeFreetextQualifier, assertCursor, assertLimit, assertUuidQualifier, + assertIdsQualifier, } from './libs/parameter-validators'; import { Doc } from './libs/doc'; @@ -45,6 +50,11 @@ export namespace v1 { readonly parent?: ContactWithLineage | NormalizedParent; } + /** + * A compact summary of a contact record. + */ + export type ContactSummary = LibContactSummary; + const getContact = ( localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, @@ -82,6 +92,75 @@ export namespace v1 { */ export const getWithLineage = getContact(Local.Contact.v1.getWithLineage, Remote.Contact.v1.getWithLineage); + /** + * Returns a function for retrieving a paged array of contact summaries from the given data context. + * @param context the current data context + * @returns a function for retrieving a paged array of contact summaries + * @throws Error if a data context is not provided + * @see {@link getSummaries} which provides the same data, but without having to manually account for paging + */ + export const getSummariesPage = (context: DataContext): typeof curriedFn => { + assertDataContext(context); + const fn = adapt(context, Local.Contact.v1.getSummaries, Remote.Contact.v1.getSummaries); + + /** + * Returns a page of summary records for the contacts identified by the given qualifier. Any identifiers that do + * not identify an existing contact are silently omitted from the result. + * @param qualifier the identifiers of the contacts to summarise + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of summaries to return. Default is 100. + * @returns a page of contact summaries for the provided specification + * @throws InvalidArgumentError if the qualifier does not contain an array of non-empty identifier strings + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + const curriedFn = async ( + qualifier: IdsQualifier, + cursor: Nullable = null, + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT + ): Promise> => { + assertCursor(cursor); + assertLimit(limit); + assertIdsQualifier(qualifier); + + const skip = validateCursor(cursor); + const numberLimit = Number(limit); + const data = await fn({ ids: qualifier.ids.slice(skip, skip + numberLimit) }); + const nextSkip = skip + numberLimit; + return { + data, + cursor: nextSkip < qualifier.ids.length ? `${nextSkip}` : null, + }; + }; + return curriedFn; + }; + + /** + * Returns a function for getting a generator that fetches contact summaries from the given data context. + * @param context the current data context + * @returns a function for getting a generator that fetches contact summaries + * @throws Error if a data context is not provided + */ + export const getSummaries = (context: DataContext): typeof curriedGen => { + assertDataContext(context); + const getPage = context.bind(v1.getSummariesPage); + + /** + * Returns a generator for fetching summary records for the contacts identified by the given qualifier. Any + * identifiers that do not identify an existing contact are silently omitted from the result. + * @param qualifier the identifiers of the contacts to summarise + * @returns a generator for fetching all the matching contact summaries + * @throws InvalidArgumentError if the qualifier does not contain an array of non-empty identifier strings + */ + const curriedGen = (qualifier: IdsQualifier): AsyncGenerator => { + assertIdsQualifier(qualifier); + + return getPagedGenerator(getPage, qualifier); + }; + return curriedGen; + }; + /** * Returns a function for retrieving a paged array of contact identifiers from the given data context. * @param context the current data context @@ -148,6 +227,33 @@ export namespace v1 { * Operations for working with contacts. */ export interface Datasource { + /** + * Returns a generator for fetching summary records for the given contact identifiers. + * @param ids the identifiers of the contacts to summarise + * @returns a generator for fetching all the matching contact summaries. Identifiers that do not identify an + * existing contact are silently omitted from the result. + * @throws InvalidArgumentError if `ids` is not an array of non-empty strings + */ + getSummaries: (ids: string[]) => AsyncGenerator; + + /** + * Returns a paged array of summary records for the given contact identifiers. + * @param ids the identifiers of the contacts to summarise + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of summaries to return. Default is 100. + * @returns a page of contact summaries. Identifiers that do not identify an existing contact are silently + * omitted from the result. + * @throws InvalidArgumentError if `ids` is not an array of non-empty strings + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + getSummariesPage: ( + ids: string[], + cursor?: Nullable, + limit?: number | `${number}` + ) => Promise>; + /** * Returns a contact by their UUID. * @param uuid the UUID of the contact to retrieve @@ -252,6 +358,12 @@ export namespace v1 { /** @internal */ export const getDatasource = (ctx: DataContext): Datasource => { return { + getSummaries: (ids) => ctx.bind(v1.getSummaries)(byIds(ids)), + getSummariesPage: ( + ids, + cursor = null, + limit = DEFAULT_DOCS_PAGE_LIMIT + ) => ctx.bind(v1.getSummariesPage)(byIds(ids), cursor, limit), getByUuid: (uuid) => ctx.bind(v1.get)(byUuid(uuid)), getByUuidWithLineage: (uuid) => ctx.bind(v1.getWithLineage)(byUuid(uuid)), getUuidsPageByTypeFreetext: ( diff --git a/shared-libs/cht-datasource/src/libs/parameter-validators.ts b/shared-libs/cht-datasource/src/libs/parameter-validators.ts index 096c84e68ec..89c90184434 100644 --- a/shared-libs/cht-datasource/src/libs/parameter-validators.ts +++ b/shared-libs/cht-datasource/src/libs/parameter-validators.ts @@ -5,7 +5,9 @@ import { isContactTypeQualifier, isFreetextQualifier, isUuidQualifier, + isIdsQualifier, UuidQualifier, + IdsQualifier, } from '../qualifier'; import { assertDataObject, @@ -140,6 +142,15 @@ export const assertUuidQualifier: (qualifier: unknown) => asserts qualifier is U } }; +/** @internal */ +export const assertIdsQualifier: ( + qualifier: unknown +) => asserts qualifier is IdsQualifier = (qualifier: unknown) => { + if (!isIdsQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid identifiers [${JSON.stringify(qualifier)}].`); + } +}; + /** @ignore */ export const isContactType = (value: ContactTypeQualifier | FreetextQualifier): value is ContactTypeQualifier => { return 'contactType' in value; diff --git a/shared-libs/cht-datasource/src/local/contact.ts b/shared-libs/cht-datasource/src/local/contact.ts index e888b48ac40..844eed1b47f 100644 --- a/shared-libs/cht-datasource/src/local/contact.ts +++ b/shared-libs/cht-datasource/src/local/contact.ts @@ -1,12 +1,13 @@ import { LocalDataContext, SettingsService } from './libs/data-context'; -import { fetchAndFilterIds, getDocById, queryDocIdsByKey, queryDocIdsByRange } from './libs/doc'; +import { fetchAndFilterIds, getDocById, getDocsByIds, queryDocIdsByKey, queryDocIdsByRange } from './libs/doc'; import { ContactTypeQualifier, FreetextQualifier, isContactTypeQualifier, isFreetextQualifier, isKeyedFreetextQualifier, - UuidQualifier + UuidQualifier, + IdsQualifier } from '../qualifier'; import * as Contact from '../contact'; import { DataObject, Nullable, Page } from '../libs/core'; @@ -18,6 +19,7 @@ import { normalizeFreetextQualifier, validateCursor } from './libs/core'; import { END_OF_ALPHABET_MARKER } from '../libs/constants'; import { fetchHydratedDoc } from './libs/lineage'; import { queryByFreetext, useNouveauIndexes } from './libs/nouveau'; +import { summariseContact } from '@medic/summaries'; const assertValidContactType = (settings: DataObject, qualifier: ContactTypeQualifier) => { const contactTypesIds = contactTypeUtils.getContactTypeIds(settings); @@ -101,6 +103,17 @@ export namespace v1 { }; }; + /** @internal */ + export const getSummaries = ({ medicDb, settings }: LocalDataContext) => { + const getMedicDocsByIds = getDocsByIds(medicDb); + return async ({ ids }: IdsQualifier): Promise => { + const docs = await getMedicDocsByIds(ids); + return docs + .filter(doc => isContact(settings, doc)) + .map(doc => summariseContact(doc)); + }; + }; + /** @internal */ export const getUuidsPage = ({ medicDb, settings }: LocalDataContext) => { const queryNouveauFreetext = queryByFreetext(medicDb, 'contacts_by_freetext'); diff --git a/shared-libs/cht-datasource/src/local/report.ts b/shared-libs/cht-datasource/src/local/report.ts index dfdf29d6f97..aca49f90c2f 100644 --- a/shared-libs/cht-datasource/src/local/report.ts +++ b/shared-libs/cht-datasource/src/local/report.ts @@ -6,7 +6,7 @@ import { queryDocIdsByKey, queryDocIdsByRange, updateDoc } from './libs/doc'; -import { FreetextQualifier, isKeyedFreetextQualifier, UuidQualifier } from '../qualifier'; +import { FreetextQualifier, isKeyedFreetextQualifier, UuidQualifier, IdsQualifier } from '../qualifier'; import { assertHasRequiredField, hasStringFieldWithValue, Nullable, Page } from '../libs/core'; import * as Report from '../report'; import * as LocalContact from './contact'; @@ -26,6 +26,7 @@ import { fetchHydratedDoc, getContactIdForUpdate, getUpdatedContact, minifyDoc } import { queryByFreetext, useNouveauIndexes } from './libs/nouveau'; import { InvalidArgumentError, ResourceNotFoundError } from '../libs/error'; import { assertReportInput } from '../libs/parameter-validators'; +import { summariseReport } from '@medic/summaries'; const FORM_DOC_ID_PREFIX = 'form:'; @@ -106,6 +107,17 @@ export namespace v1 { }; }; + /** @internal */ + export const getSummaries = ({ medicDb }: LocalDataContext) => { + const getMedicDocsByIds = getDocsByIds(medicDb); + return async ({ ids }: IdsQualifier): Promise => { + const docs = await getMedicDocsByIds(ids); + return docs + .filter(isReport) + .map(doc => summariseReport(doc)); + }; + }; + /** @internal */ export const getUuidsPage = ({ medicDb }: LocalDataContext) => { const queryNouveauFreetext = queryByFreetext(medicDb, 'reports_by_freetext'); diff --git a/shared-libs/cht-datasource/src/qualifier.ts b/shared-libs/cht-datasource/src/qualifier.ts index 382ce55a620..53a467d8282 100644 --- a/shared-libs/cht-datasource/src/qualifier.ts +++ b/shared-libs/cht-datasource/src/qualifier.ts @@ -56,6 +56,38 @@ export const isUuidQualifier = (identifier: unknown): identifier is UuidQualifie return isRecord(identifier) && hasField(identifier, { name: 'uuid', type: 'string' }); }; +/** + * A qualifier that identifies entities by their identifiers. + */ +export type IdsQualifier = Readonly<{ ids: string[] }>; + +/** + * Returns `true` if the given qualifier is an {@link IdsQualifier}, otherwise `false`. An empty array of identifiers is + * considered valid. + * @param qualifier the qualifier to check + * @returns `true` if the given qualifier is an {@link IdsQualifier}, otherwise `false` + */ +export const isIdsQualifier = (qualifier: unknown): qualifier is IdsQualifier => { + return isRecord(qualifier) + && hasField(qualifier, { name: 'ids', type: 'object' }) + && Array.isArray(qualifier.ids) + && qualifier.ids.every(id => isString(id) && id.length > 0); +}; + +/** + * Builds a qualifier that identifies entities by their identifiers. + * @param ids the identifiers of the entities + * @returns the qualifier + * @throws InvalidArgumentError if the identifiers are not an array of non-empty strings + */ +export const byIds = (ids: string[]): IdsQualifier => { + const qualifier = { ids }; + if (!isIdsQualifier(qualifier)) { + throw new InvalidArgumentError(`Invalid identifiers [${JSON.stringify(ids)}].`); + } + return qualifier; +}; + /** * A qualifier that identifies contacts based on type. */ diff --git a/shared-libs/cht-datasource/src/remote/contact.ts b/shared-libs/cht-datasource/src/remote/contact.ts index 40be594a32f..22ddaa28b5d 100644 --- a/shared-libs/cht-datasource/src/remote/contact.ts +++ b/shared-libs/cht-datasource/src/remote/contact.ts @@ -1,5 +1,5 @@ -import { getResource, getResources, RemoteDataContext } from './libs/data-context'; -import { ContactTypeQualifier, FreetextQualifier, UuidQualifier } from '../qualifier'; +import { getResource, getResources, postResource, RemoteDataContext } from './libs/data-context'; +import { ContactTypeQualifier, FreetextQualifier, UuidQualifier, IdsQualifier } from '../qualifier'; import { Nullable, Page } from '../libs/core'; import * as Contact from '../contact'; import { isContactType, isFreetextType } from '../libs/parameter-validators'; @@ -24,6 +24,15 @@ export namespace v1 { with_lineage: 'true', }); + const postContactSummary = postResource('api/v1/contact/summary'); + + /** @internal */ + export const getSummaries = ( + remoteContext: RemoteDataContext + ) => ({ ids }: IdsQualifier): Promise => { + return postContactSummary(remoteContext)({ ids }); + }; + /** @internal */ export const getUuidsPage = (remoteContext: RemoteDataContext) => ( qualifier: ContactTypeQualifier | FreetextQualifier, diff --git a/shared-libs/cht-datasource/src/remote/report.ts b/shared-libs/cht-datasource/src/remote/report.ts index 8bf791b63f9..d0a5df2abea 100644 --- a/shared-libs/cht-datasource/src/remote/report.ts +++ b/shared-libs/cht-datasource/src/remote/report.ts @@ -1,5 +1,5 @@ import { getResource, getResources, postResource, putResource, RemoteDataContext } from './libs/data-context'; -import { FreetextQualifier, UuidQualifier } from '../qualifier'; +import { FreetextQualifier, UuidQualifier, IdsQualifier } from '../qualifier'; import * as Report from '../report'; import { Nullable, Page } from '../libs/core'; @@ -28,6 +28,15 @@ export namespace v1 { return getReportUuids(remoteContext)(queryParams); }; + const postReportSummary = postResource('api/v1/report/summary'); + + /** @internal */ + export const getSummaries = ( + remoteContext: RemoteDataContext + ) => ({ ids }: IdsQualifier): Promise => { + return postReportSummary(remoteContext)({ ids }); + }; + /** @internal */ export const create = postResource('api/v1/report'); diff --git a/shared-libs/cht-datasource/src/report.ts b/shared-libs/cht-datasource/src/report.ts index 8d5b50a3ab9..8a3a6ca558a 100644 --- a/shared-libs/cht-datasource/src/report.ts +++ b/shared-libs/cht-datasource/src/report.ts @@ -1,11 +1,19 @@ +import { ReportSummary as LibReportSummary } from '@medic/summaries'; import { DataObject, getPagedGenerator, isIdentifiable, isRecord, NormalizedParent, Nullable, Page } from './libs/core'; import { adapt, assertDataContext, DataContext } from './libs/data-context'; import { Doc } from './libs/doc'; import * as Local from './local'; -import { byFreetext, byUuid, FreetextQualifier, UuidQualifier } from './qualifier'; +import { validateCursor } from './local/libs/core'; +import { byFreetext, byUuid, byIds, FreetextQualifier, UuidQualifier, IdsQualifier } from './qualifier'; import * as Remote from './remote'; -import { DEFAULT_IDS_PAGE_LIMIT } from './libs/constants'; -import { assertCursor, assertFreetextQualifier, assertLimit, assertUuidQualifier } from './libs/parameter-validators'; +import { DEFAULT_DOCS_PAGE_LIMIT, DEFAULT_IDS_PAGE_LIMIT } from './libs/constants'; +import { + assertCursor, + assertFreetextQualifier, + assertLimit, + assertUuidQualifier, + assertIdsQualifier, +} from './libs/parameter-validators'; import * as Input from './input'; import { InvalidArgumentError } from './libs/error'; import * as Contact from './contact'; @@ -31,6 +39,11 @@ export namespace v1 { readonly place?: Contact.v1.ContactWithLineage | NormalizedParent; } + /** + * A compact summary of a report record. + */ + export type ReportSummary = LibReportSummary; + /** * Returns a function for retrieving a report from the given data context. @@ -57,6 +70,75 @@ export namespace v1 { return curriedFn; }; + /** + * Returns a function for retrieving a paged array of report summaries from the given data context. + * @param context the current data context + * @returns a function for retrieving a paged array of report summaries + * @throws Error if a data context is not provided + * @see {@link getSummaries} which provides the same data, but without having to manually account for paging + */ + export const getSummariesPage = (context: DataContext): typeof curriedFn => { + assertDataContext(context); + const fn = adapt(context, Local.Report.v1.getSummaries, Remote.Report.v1.getSummaries); + + /** + * Returns a page of summary records for the reports identified by the given qualifier. Any identifiers that do + * not identify an existing report are silently omitted from the result. + * @param qualifier the identifiers of the reports to summarise + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of summaries to return. Default is 100. + * @returns a page of report summaries for the provided specification + * @throws InvalidArgumentError if the qualifier does not contain an array of non-empty identifier strings + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + const curriedFn = async ( + qualifier: IdsQualifier, + cursor: Nullable = null, + limit: number | `${number}` = DEFAULT_DOCS_PAGE_LIMIT + ): Promise> => { + assertCursor(cursor); + assertLimit(limit); + assertIdsQualifier(qualifier); + + const skip = validateCursor(cursor); + const numberLimit = Number(limit); + const data = await fn({ ids: qualifier.ids.slice(skip, skip + numberLimit) }); + const nextSkip = skip + numberLimit; + return { + data, + cursor: nextSkip < qualifier.ids.length ? `${nextSkip}` : null, + }; + }; + return curriedFn; + }; + + /** + * Returns a function for getting a generator that fetches report summaries from the given data context. + * @param context the current data context + * @returns a function for getting a generator that fetches report summaries + * @throws Error if a data context is not provided + */ + export const getSummaries = (context: DataContext): typeof curriedGen => { + assertDataContext(context); + const getPage = context.bind(v1.getSummariesPage); + + /** + * Returns a generator for fetching summary records for the reports identified by the given qualifier. Any + * identifiers that do not identify an existing report are silently omitted from the result. + * @param qualifier the identifiers of the reports to summarise + * @returns a generator for fetching all the matching report summaries + * @throws InvalidArgumentError if the qualifier does not contain an array of non-empty identifier strings + */ + const curriedGen = (qualifier: IdsQualifier): AsyncGenerator => { + assertIdsQualifier(qualifier); + + return getPagedGenerator(getPage, qualifier); + }; + return curriedGen; + }; + /** * Returns a function for retrieving a paged array of report identifiers from the given data context. * @param context the current data context @@ -210,6 +292,33 @@ export namespace v1 { * Operations for working with reports. */ export interface Datasource { + /** + * Returns a generator for fetching summary records for the given report identifiers. + * @param ids the identifiers of the reports to summarise + * @returns a generator for fetching all the matching report summaries. Identifiers that do not identify an + * existing report are silently omitted from the result. + * @throws InvalidArgumentError if `ids` is not an array of non-empty strings + */ + getSummaries: (ids: string[]) => AsyncGenerator; + + /** + * Returns a paged array of summary records for the given report identifiers. + * @param ids the identifiers of the reports to summarise + * @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be + * returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page. + * @param limit the maximum number of summaries to return. Default is 100. + * @returns a page of report summaries. Identifiers that do not identify an existing report are silently + * omitted from the result. + * @throws InvalidArgumentError if `ids` is not an array of non-empty strings + * @throws InvalidArgumentError if the provided `limit` value is `<=0` + * @throws InvalidArgumentError if the provided cursor is not a valid page token or `null` + */ + getSummariesPage: ( + ids: string[], + cursor?: Nullable, + limit?: number | `${number}` + ) => Promise>; + /** * Returns a report by their UUID. * @param uuid the UUID of the report to retrieve @@ -283,6 +392,12 @@ export namespace v1 { /** @internal */ export const getDatasource = (ctx: DataContext): Datasource => { return { + getSummaries: (ids) => ctx.bind(v1.getSummaries)(byIds(ids)), + getSummariesPage: ( + ids, + cursor = null, + limit = DEFAULT_DOCS_PAGE_LIMIT + ) => ctx.bind(v1.getSummariesPage)(byIds(ids), cursor, limit), getByUuid: (uuid) => ctx.bind(v1.get)(byUuid(uuid)), getByUuidWithLineage: (uuid) => ctx.bind(v1.getWithLineage)(byUuid(uuid)), getUuidsPageByFreetext: ( diff --git a/shared-libs/cht-datasource/test/contact.spec.ts b/shared-libs/cht-datasource/test/contact.spec.ts index 0df190f445f..64560ea5b03 100644 --- a/shared-libs/cht-datasource/test/contact.spec.ts +++ b/shared-libs/cht-datasource/test/contact.spec.ts @@ -132,6 +132,149 @@ describe('contact', () => { }); }); + describe('getSummariesPage', () => { + const ids = ['contact-1', 'contact-2', 'contact-3']; + const qualifier = { ids }; + const summaries = [{ _id: 'contact-1' }, { _id: 'contact-2' }] as Contact.v1.ContactSummary[]; + let getSummariesFn: SinonStub; + + beforeEach(() => { + getSummariesFn = sinon.stub(); + adapt.returns(getSummariesFn); + }); + + it('retrieves the first page of summaries when cursor is null', async () => { + getSummariesFn.resolves(summaries); + + const result = await Contact.v1.getSummariesPage(dataContext)(qualifier, null, 2); + + expect(result).to.deep.equal({ data: summaries, cursor: '2' }); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Contact.v1.getSummaries, Remote.Contact.v1.getSummaries) + ).to.be.true; + expect(getSummariesFn.calledOnceWithExactly({ ids: ['contact-1', 'contact-2'] })).to.be.true; + }); + + it('returns a null cursor for the last page', async () => { + getSummariesFn.resolves(summaries); + + const result = await Contact.v1.getSummariesPage(dataContext)(qualifier, '2', 2); + + expect(result).to.deep.equal({ data: summaries, cursor: null }); + expect(getSummariesFn.calledOnceWithExactly({ ids: ['contact-3'] })).to.be.true; + }); + + it('uses the default limit when not provided', async () => { + getSummariesFn.resolves(summaries); + + const result = await Contact.v1.getSummariesPage(dataContext)(qualifier); + + expect(result).to.deep.equal({ data: summaries, cursor: null }); + expect(getSummariesFn.calledOnceWithExactly({ ids })).to.be.true; + }); + + it('accepts a stringified limit', async () => { + getSummariesFn.resolves(summaries); + + const result = await Contact.v1.getSummariesPage(dataContext)(qualifier, null, '2'); + + expect(result).to.deep.equal({ data: summaries, cursor: '2' }); + expect(getSummariesFn.calledOnceWithExactly({ ids: ['contact-1', 'contact-2'] })).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Contact.v1.getSummariesPage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(adapt.notCalled).to.be.true; + }); + + ([ + null, + undefined, + 'not-an-array', + { ids: 'not-an-array' }, + { ids: [1, 2] }, + { ids: ['valid', ''] }, + ] as unknown[]).forEach((invalid) => { + it(`throws an error for invalid qualifier ${JSON.stringify(invalid)}`, async () => { + await expect(Contact.v1.getSummariesPage(dataContext)(invalid as never)) + .to.be.rejectedWith(`Invalid identifiers [${JSON.stringify(invalid)}].`); + + expect(getSummariesFn.notCalled).to.be.true; + }); + }); + + [-1, null, {}, '', 0, 1.1, false].forEach((limitValue) => { + it(`throws an error if limit is invalid: ${JSON.stringify(limitValue)}`, async () => { + await expect(Contact.v1.getSummariesPage(dataContext)(qualifier, null, limitValue as number)) + .to.be.rejectedWith(`The limit must be a positive integer: [${JSON.stringify(limitValue)}]`); + + expect(getSummariesFn.notCalled).to.be.true; + }); + }); + + [{}, '', 1, false, 'abc', '-1', '1.1'].forEach((cursorValue) => { + it(`throws an error if cursor is invalid: ${JSON.stringify(cursorValue)}`, async () => { + await expect(Contact.v1.getSummariesPage(dataContext)(qualifier, cursorValue as string, 2)) + .to.be.rejectedWith(`The cursor must be a string or null for first page: [${JSON.stringify(cursorValue)}]`); + + expect(getSummariesFn.notCalled).to.be.true; + }); + }); + }); + + describe('getSummaries', () => { + const ids = ['contact-1', 'contact-2']; + const qualifier = { ids }; + const mockGenerator = {} as AsyncGenerator; + let contactGetSummariesPage: sinon.SinonStub; + let getPagedGenerator: sinon.SinonStub; + + beforeEach(() => { + contactGetSummariesPage = sinon.stub(Contact.v1, 'getSummariesPage'); + dataContext.bind = sinon.stub().returns(contactGetSummariesPage); + getPagedGenerator = sinon.stub(Core, 'getPagedGenerator'); + }); + + it('should get summaries generator with correct parameters', () => { + getPagedGenerator.returns(mockGenerator); + + const generator = Contact.v1.getSummaries(dataContext)(qualifier); + + expect(generator).to.deep.equal(mockGenerator); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(getPagedGenerator.calledOnceWithExactly(contactGetSummariesPage, qualifier)).to.be.true; + }); + + it('should throw an error for invalid datacontext', () => { + const errMsg = 'Invalid data context [null].'; + assertDataContext.throws(new Error(errMsg)); + + expect(() => Contact.v1.getSummaries(dataContext)).to.throw(errMsg); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(contactGetSummariesPage.notCalled).to.be.true; + }); + + ([ + null, + undefined, + 'not-an-array', + { ids: 'not-an-array' }, + { ids: [1, 2] }, + { ids: ['valid', ''] }, + ] as unknown[]).forEach((invalid) => { + it(`throws an error for invalid qualifier ${JSON.stringify(invalid)}`, () => { + expect(() => Contact.v1.getSummaries(dataContext)(invalid as never)) + .to.throw(`Invalid identifiers [${JSON.stringify(invalid)}].`); + + expect(getPagedGenerator.notCalled).to.be.true; + }); + }); + }); + describe('getUuidsPage', () => { const contactIds = ['contact1', 'contact2', 'contact3'] as string[]; const cursor = '1'; @@ -427,6 +570,8 @@ describe('contact', () => { it('contains expected keys', () => { expect(contact).to.have.all.keys( [ + 'getSummaries', + 'getSummariesPage', 'getByUuid', 'getByUuidWithLineage', 'getUuidsByTypeFreetext', @@ -439,6 +584,54 @@ describe('contact', () => { ); }); + it('getSummaries', () => { + const mockAsyncGenerator = fakeGenerator(); + const contactGetSummaries = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(contactGetSummaries); + const ids = ['uuid-1', 'uuid-2']; + const qualifier = { ids }; + const byIds = sinon.stub(Qualifier, 'byIds').returns(qualifier); + + const res = contact.getSummaries(ids); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getSummaries)).to.be.true; + expect(contactGetSummaries.calledOnceWithExactly(qualifier)).to.be.true; + expect(byIds.calledOnceWithExactly(ids)).to.be.true; + }); + + it('getSummariesPage', async () => { + const expectedPage: Page = { data: [], cursor: null }; + const contactGetSummariesPage = sinon.stub().resolves(expectedPage); + dataContextBind.returns(contactGetSummariesPage); + const ids = ['uuid-1', 'uuid-2']; + const qualifier = { ids }; + const limit = 2; + const cursor = '1'; + const byIds = sinon.stub(Qualifier, 'byIds').returns(qualifier); + + const returnedPage = await contact.getSummariesPage(ids, cursor, limit); + + expect(returnedPage).to.equal(expectedPage); + expect(dataContextBind.calledOnceWithExactly(Contact.v1.getSummariesPage)).to.be.true; + expect(contactGetSummariesPage.calledOnceWithExactly(qualifier, cursor, limit)).to.be.true; + expect(byIds.calledOnceWithExactly(ids)).to.be.true; + }); + + it('getSummariesPage uses default cursor and limit', async () => { + const expectedPage: Page = { data: [], cursor: null }; + const contactGetSummariesPage = sinon.stub().resolves(expectedPage); + dataContextBind.returns(contactGetSummariesPage); + const ids = ['uuid-1', 'uuid-2']; + const qualifier = { ids }; + sinon.stub(Qualifier, 'byIds').returns(qualifier); + + const returnedPage = await contact.getSummariesPage(ids); + + expect(returnedPage).to.equal(expectedPage); + expect(contactGetSummariesPage.calledOnceWithExactly(qualifier, null, 100)).to.be.true; + }); + it('getByUuid', async () => { const expectedContact = {}; const contactGet = sinon.stub().resolves(expectedContact); diff --git a/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts index 1ead7c6c52c..3852f4cae59 100644 --- a/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts +++ b/shared-libs/cht-datasource/test/libs/parameter-validators.spec.ts @@ -12,7 +12,8 @@ import { isContactType, isFreetextType, isContactTypeAndFreetextType, - assertUuidQualifier + assertUuidQualifier, + assertIdsQualifier } from '../../src/libs/parameter-validators'; import { InvalidArgumentError } from '../../src'; import { DOC_TYPES, CONTACT_TYPES } from '@medic/constants'; @@ -241,6 +242,43 @@ describe('libs parameter-validators', () => { }); }); + describe('assertIdsQualifier', () => { + it('should pass when given a qualifier with an array of non-empty strings', () => { + expect(() => assertIdsQualifier({ ids: ['a', 'b', 'c'] })).to.not.throw(); + }); + + it('should pass when given a qualifier with an empty array', () => { + expect(() => assertIdsQualifier({ ids: [] })).to.not.throw(); + }); + + it('should throw InvalidArgumentError when the ids property is not an array', () => { + expect(() => assertIdsQualifier({ ids: 'abc' })) + .to.throw(InvalidArgumentError, `Invalid identifiers [{"ids":"abc"}].`); + }); + + it('should throw InvalidArgumentError when not given an object', () => { + expect(() => assertIdsQualifier('abc')).to.throw(InvalidArgumentError, `Invalid identifiers ["abc"].`); + }); + + it('should throw InvalidArgumentError when given null', () => { + expect(() => assertIdsQualifier(null)).to.throw(InvalidArgumentError, `Invalid identifiers [null].`); + }); + + it('should throw InvalidArgumentError when given undefined', () => { + expect(() => assertIdsQualifier(undefined)).to.throw(InvalidArgumentError); + }); + + it('should throw InvalidArgumentError when an element is not a string', () => { + expect(() => assertIdsQualifier({ ids: ['a', 123, 'c'] })) + .to.throw(InvalidArgumentError, `Invalid identifiers [{"ids":["a",123,"c"]}].`); + }); + + it('should throw InvalidArgumentError when an element is an empty string', () => { + expect(() => assertIdsQualifier({ ids: ['a', '', 'c'] })) + .to.throw(InvalidArgumentError, `Invalid identifiers [{"ids":["a","","c"]}].`); + }); + }); + describe('assertPersonInput', () => { const personInput = { name: 'apoorva', diff --git a/shared-libs/cht-datasource/test/local/contact.spec.ts b/shared-libs/cht-datasource/test/local/contact.spec.ts index cb35310daed..a290e0c88fd 100644 --- a/shared-libs/cht-datasource/test/local/contact.spec.ts +++ b/shared-libs/cht-datasource/test/local/contact.spec.ts @@ -140,6 +140,58 @@ describe('local contact', () => { }); }); + describe('getSummaries', () => { + let getDocsByIdsOuter: SinonStub; + let getDocsByIdsInner: SinonStub; + + beforeEach(() => { + getDocsByIdsInner = sinon.stub(); + getDocsByIdsOuter = sinon.stub(LocalDoc, 'getDocsByIds').returns(getDocsByIdsInner); + }); + + it('passes empty ids through to getDocsByIds', async () => { + getDocsByIdsInner.resolves([]); + + const result = await Contact.v1.getSummaries(localContext)({ ids: [] }); + + expect(result).to.deep.equal([]); + expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly([])).to.be.true; + }); + + it('summarises contacts and filters out non-contact docs', async () => { + const contactDoc = { + _id: 'a', + _rev: '1', + type: 'person', + name: 'james', + phone: '+456', + parent: { _id: 'f', parent: { _id: 'g' } }, + }; + const reportDoc = { _id: 'b', _rev: '2', type: 'data_record', form: 'x' }; + getDocsByIdsInner.resolves([contactDoc, reportDoc, null]); + isContact.withArgs(settings, contactDoc).returns(true); + isContact.withArgs(settings, reportDoc).returns(false); + + const result = await Contact.v1.getSummaries(localContext)({ ids: ['a', 'b', 'missing'] }); + + expect(result).to.deep.equal([{ + _id: 'a', + _rev: '1', + name: 'james', + phone: '+456', + type: 'person', + contact_type: undefined, + contact: undefined, + lineage: ['f', 'g'], + date_of_death: undefined, + muted: undefined, + }]); + expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly(['a', 'b', 'missing'])).to.be.true; + }); + }); + describe('getWithLineage', () => { const identifier = { uuid: 'uuid' } as const; let mockFetchHydratedDoc: sinon.SinonStub; diff --git a/shared-libs/cht-datasource/test/local/report.spec.ts b/shared-libs/cht-datasource/test/local/report.spec.ts index 70568210612..ea8c27deda5 100644 --- a/shared-libs/cht-datasource/test/local/report.spec.ts +++ b/shared-libs/cht-datasource/test/local/report.spec.ts @@ -106,6 +106,60 @@ describe('local report', () => { }); }); + describe('getSummaries', () => { + let getDocsByIdsOuter: SinonStub; + let getDocsByIdsInner: SinonStub; + + beforeEach(() => { + getDocsByIdsInner = sinon.stub(); + getDocsByIdsOuter = sinon.stub(LocalDoc, 'getDocsByIds').returns(getDocsByIdsInner); + }); + + it('passes empty ids through to getDocsByIds', async () => { + getDocsByIdsInner.resolves([]); + + const result = await Report.v1.getSummaries(localContext)({ ids: [] }); + + expect(result).to.deep.equal([]); + expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly([])).to.be.true; + }); + + it('summarises reports and filters out non-report docs', async () => { + const reportDoc = { + _id: 'a', + _rev: '1', + type: DOC_TYPES.DATA_RECORD, + form: 'delivery', + from: '+123', + reported_date: 100, + fields: { patient_id: 'f', patient_name: 'jeff' }, + }; + const contactDoc = { _id: 'b', _rev: '2', type: 'person', name: 'james' }; + getDocsByIdsInner.resolves([reportDoc, contactDoc, null]); + + const result = await Report.v1.getSummaries(localContext)({ ids: ['a', 'b', 'missing'] }); + + expect(result).to.deep.equal([{ + _id: 'a', + _rev: '1', + from: '+123', + phone: undefined, + form: 'delivery', + read: undefined, + valid: true, + verified: undefined, + reported_date: 100, + contact: undefined, + lineage: [], + subject: { name: 'jeff', value: 'f', type: 'reference' }, + case_id: undefined, + }]); + expect(getDocsByIdsOuter.calledOnceWithExactly(localContext.medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly(['a', 'b', 'missing'])).to.be.true; + }); + }); + describe('getWithLineage', () => { const identifier = { uuid: 'uuid' } as const; let fetchHydratedDocOuter: SinonStub; diff --git a/shared-libs/cht-datasource/test/qualifier.spec.ts b/shared-libs/cht-datasource/test/qualifier.spec.ts index abcf2106e72..9cdc349a25c 100644 --- a/shared-libs/cht-datasource/test/qualifier.spec.ts +++ b/shared-libs/cht-datasource/test/qualifier.spec.ts @@ -6,7 +6,8 @@ import { byFreetext, byReportingPeriod, byUsername, - byUuid, FreetextQualifier, + byUuid, + byIds, FreetextQualifier, isContactTypeQualifier, isContactIdQualifier, isContactIdsQualifier, @@ -15,6 +16,7 @@ import { isReportingPeriodQualifier, isUsernameQualifier, isUuidQualifier, + isIdsQualifier, byId, isIdQualifier } from '../src/qualifier'; @@ -81,6 +83,47 @@ describe('qualifier', () => { }); }); + describe('byIds', () => { + [ + [], + ['id'], + ['id-1', 'id-2'], + ].forEach(ids => { + it(`builds a qualifier that identifies entities by their identifiers for ${JSON.stringify(ids)}`, () => { + expect(byIds(ids)).to.deep.equal({ ids }); + }); + }); + + [ + null, + 'abc', + [''], + ['id', ''], + ].forEach(ids => { + it(`throws an error for ${JSON.stringify(ids)}`, () => { + expect(() => byIds(ids as string[])).to.throw(`Invalid identifiers [${JSON.stringify(ids)}].`); + }); + }); + }); + + describe('isIdsQualifier', () => { + [ + [ null, false ], + [ 'id', false ], + [ { ids: '' }, false ], + [ { ids: { } }, false ], + [ { ids: ['id', ''] }, false ], + [ { ids: [null, 'id'] }, false ], + [ { ids: [] }, true ], + [ { ids: ['id'] }, true ], + [ { ids: ['id-1', 'id-2'] }, true ], + ].forEach(([ qualifier, expected ]) => { + it(`evaluates ${JSON.stringify(qualifier)}`, () => { + expect(isIdsQualifier(qualifier)).to.equal(expected); + }); + }); + }); + describe('byContactType', () => { it('builds a qualifier that identifies an entity by its contactType', () => { expect(byContactType('person')).to.deep.equal({ contactType: 'person' }); diff --git a/shared-libs/cht-datasource/test/remote/contact.spec.ts b/shared-libs/cht-datasource/test/remote/contact.spec.ts index 501e23d488b..c853a2adf00 100644 --- a/shared-libs/cht-datasource/test/remote/contact.spec.ts +++ b/shared-libs/cht-datasource/test/remote/contact.spec.ts @@ -1,24 +1,43 @@ import * as RemoteEnv from '../../src/remote/libs/data-context'; import { RemoteDataContext } from '../../src/remote/libs/data-context'; import sinon, { SinonStub } from 'sinon'; -import * as Contact from '../../src/remote/contact'; import { expect } from 'chai'; describe('remote contact', () => { const remoteContext = {} as RemoteDataContext; + const sandbox = sinon.createSandbox(); + const postSummaryResourceOuter = sandbox.stub(); + + let Contact: typeof import('../../src/remote/contact'); let getResourceInner: SinonStub; let getResourceOuter: SinonStub; let getResourcesInner: SinonStub; let getResourcesOuter: SinonStub; + let postSummaryResourceInner: SinonStub; + + before(() => { + sinon + .stub(RemoteEnv, 'postResource') + .withArgs('api/v1/contact/summary') + .returns(postSummaryResourceOuter); + + Reflect.deleteProperty(require.cache, require.resolve('../../src/remote/contact')); + Contact = require('../../src/remote/contact'); + }); beforeEach(() => { getResourceInner = sinon.stub(); getResourceOuter = sinon.stub(RemoteEnv, 'getResource').returns(getResourceInner); getResourcesInner = sinon.stub(); getResourcesOuter = sinon.stub(RemoteEnv, 'getResources').returns(getResourcesInner); + postSummaryResourceInner = sinon.stub(); + postSummaryResourceOuter.returns(postSummaryResourceInner); }); - afterEach(() => sinon.restore()); + afterEach(() => { + sinon.restore(); + sandbox.reset(); + }); describe('v1', () => { const identifier = { uuid: 'uuid' } as const; @@ -69,6 +88,29 @@ describe('remote contact', () => { }); }); + describe('getSummaries', () => { + it('POSTs empty ids to the contact summary endpoint', async () => { + postSummaryResourceInner.resolves([]); + + const result = await Contact.v1.getSummaries(remoteContext)({ ids: [] }); + + expect(result).to.deep.equal([]); + expect(postSummaryResourceOuter.calledOnceWithExactly(remoteContext)).to.be.true; + expect(postSummaryResourceInner.calledOnceWithExactly({ ids: [] })).to.be.true; + }); + + it('POSTs the ids array to the contact summary endpoint', async () => { + const summaries = [{ _id: 'a' }, { _id: 'b' }]; + postSummaryResourceInner.resolves(summaries); + + const result = await Contact.v1.getSummaries(remoteContext)({ ids: ['a', 'b'] }); + + expect(result).to.equal(summaries); + expect(postSummaryResourceOuter.calledOnceWithExactly(remoteContext)).to.be.true; + expect(postSummaryResourceInner.calledOnceWithExactly({ ids: ['a', 'b'] })).to.be.true; + }); + }); + describe('getUuidsPage', () => { const limit = 3; const cursor = '1'; diff --git a/shared-libs/cht-datasource/test/remote/report.spec.ts b/shared-libs/cht-datasource/test/remote/report.spec.ts index f9a89ee035d..1a91b50b177 100644 --- a/shared-libs/cht-datasource/test/remote/report.spec.ts +++ b/shared-libs/cht-datasource/test/remote/report.spec.ts @@ -8,7 +8,9 @@ describe('remote report', () => { const remoteContext = {} as RemoteDataContext; const sandbox = sinon.createSandbox(); const postResourceOuter = sandbox.stub(); + const postSummaryResourceOuter = sandbox.stub(); const putResourceOuter = sandbox.stub(); + let postResourceStub: SinonStub; let Report: typeof import('../../src/remote/report'); let getResourceInner: SinonStub; @@ -16,13 +18,13 @@ describe('remote report', () => { let getResourcesInner: SinonStub; let getResourcesOuter: SinonStub; let postResourceInner: SinonStub; + let postSummaryResourceInner: SinonStub; let putResourceInner: SinonStub; before(() => { - sinon - .stub(RemoteEnv, 'postResource') - .withArgs('api/v1/report') - .returns(postResourceOuter); + postResourceStub = sinon.stub(RemoteEnv, 'postResource'); + postResourceStub.withArgs('api/v1/report').returns(postResourceOuter); + postResourceStub.withArgs('api/v1/report/summary').returns(postSummaryResourceOuter); sinon .stub(RemoteEnv, 'putResource') .withArgs('api/v1/report') @@ -39,6 +41,8 @@ describe('remote report', () => { getResourcesOuter = sinon.stub(RemoteEnv, 'getResources').returns(getResourcesInner); postResourceInner = sinon.stub(); postResourceOuter.returns(postResourceInner); + postSummaryResourceInner = sinon.stub(); + postSummaryResourceOuter.returns(postSummaryResourceInner); putResourceInner = sinon.stub(); putResourceOuter.returns(putResourceInner); }); @@ -150,6 +154,29 @@ describe('remote report', () => { }); }); + describe('getSummaries', () => { + it('POSTs empty ids to the report summary endpoint', async () => { + postSummaryResourceInner.resolves([]); + + const result = await Report.v1.getSummaries(remoteContext)({ ids: [] }); + + expect(result).to.deep.equal([]); + expect(postSummaryResourceOuter.calledOnceWithExactly(remoteContext)).to.be.true; + expect(postSummaryResourceInner.calledOnceWithExactly({ ids: [] })).to.be.true; + }); + + it('POSTs the ids array to the report summary endpoint', async () => { + const summaries = [{ _id: 'a' }, { _id: 'b' }]; + postSummaryResourceInner.resolves(summaries); + + const result = await Report.v1.getSummaries(remoteContext)({ ids: ['a', 'b'] }); + + expect(result).to.equal(summaries); + expect(postSummaryResourceOuter.calledOnceWithExactly(remoteContext)).to.be.true; + expect(postSummaryResourceInner.calledOnceWithExactly({ ids: ['a', 'b'] })).to.be.true; + }); + }); + describe('create', () => { it('returns a report doc for a valid input', async () => { const input = { diff --git a/shared-libs/cht-datasource/test/report.spec.ts b/shared-libs/cht-datasource/test/report.spec.ts index cf9240cc002..8a7a550a4b5 100644 --- a/shared-libs/cht-datasource/test/report.spec.ts +++ b/shared-libs/cht-datasource/test/report.spec.ts @@ -143,6 +143,149 @@ describe('report', () => { }); }); + describe('getSummariesPage', () => { + const ids = ['report-1', 'report-2', 'report-3']; + const qualifier = { ids }; + const summaries = [{ _id: 'report-1' }, { _id: 'report-2' }] as Report.v1.ReportSummary[]; + let getSummariesFn: SinonStub; + + beforeEach(() => { + getSummariesFn = sinon.stub(); + adapt.returns(getSummariesFn); + }); + + it('retrieves the first page of summaries when cursor is null', async () => { + getSummariesFn.resolves(summaries); + + const result = await Report.v1.getSummariesPage(dataContext)(qualifier, null, 2); + + expect(result).to.deep.equal({ data: summaries, cursor: '2' }); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect( + adapt.calledOnceWithExactly(dataContext, Local.Report.v1.getSummaries, Remote.Report.v1.getSummaries) + ).to.be.true; + expect(getSummariesFn.calledOnceWithExactly({ ids: ['report-1', 'report-2'] })).to.be.true; + }); + + it('returns a null cursor for the last page', async () => { + getSummariesFn.resolves(summaries); + + const result = await Report.v1.getSummariesPage(dataContext)(qualifier, '2', 2); + + expect(result).to.deep.equal({ data: summaries, cursor: null }); + expect(getSummariesFn.calledOnceWithExactly({ ids: ['report-3'] })).to.be.true; + }); + + it('uses the default limit when not provided', async () => { + getSummariesFn.resolves(summaries); + + const result = await Report.v1.getSummariesPage(dataContext)(qualifier); + + expect(result).to.deep.equal({ data: summaries, cursor: null }); + expect(getSummariesFn.calledOnceWithExactly({ ids })).to.be.true; + }); + + it('accepts a stringified limit', async () => { + getSummariesFn.resolves(summaries); + + const result = await Report.v1.getSummariesPage(dataContext)(qualifier, null, '2'); + + expect(result).to.deep.equal({ data: summaries, cursor: '2' }); + expect(getSummariesFn.calledOnceWithExactly({ ids: ['report-1', 'report-2'] })).to.be.true; + }); + + it('throws an error if the data context is invalid', () => { + assertDataContext.throws(new Error(`Invalid data context [null].`)); + + expect(() => Report.v1.getSummariesPage(dataContext)).to.throw(`Invalid data context [null].`); + + expect(adapt.notCalled).to.be.true; + }); + + ([ + null, + undefined, + 'not-an-array', + { ids: 'not-an-array' }, + { ids: [1, 2] }, + { ids: ['valid', ''] }, + ] as unknown[]).forEach((invalid) => { + it(`throws an error for invalid qualifier ${JSON.stringify(invalid)}`, async () => { + await expect(Report.v1.getSummariesPage(dataContext)(invalid as never)) + .to.be.rejectedWith(`Invalid identifiers [${JSON.stringify(invalid)}].`); + + expect(getSummariesFn.notCalled).to.be.true; + }); + }); + + [-1, null, {}, '', 0, 1.1, false].forEach((limitValue) => { + it(`throws an error if limit is invalid: ${JSON.stringify(limitValue)}`, async () => { + await expect(Report.v1.getSummariesPage(dataContext)(qualifier, null, limitValue as number)) + .to.be.rejectedWith(`The limit must be a positive integer: [${JSON.stringify(limitValue)}]`); + + expect(getSummariesFn.notCalled).to.be.true; + }); + }); + + [{}, '', 1, false, 'abc', '-1', '1.1'].forEach((cursorValue) => { + it(`throws an error if cursor is invalid: ${JSON.stringify(cursorValue)}`, async () => { + await expect(Report.v1.getSummariesPage(dataContext)(qualifier, cursorValue as string, 2)) + .to.be.rejectedWith(`The cursor must be a string or null for first page: [${JSON.stringify(cursorValue)}]`); + + expect(getSummariesFn.notCalled).to.be.true; + }); + }); + }); + + describe('getSummaries', () => { + const ids = ['report-1', 'report-2']; + const qualifier = { ids }; + const mockGenerator = {} as AsyncGenerator; + let reportGetSummariesPage: sinon.SinonStub; + let getPagedGenerator: sinon.SinonStub; + + beforeEach(() => { + reportGetSummariesPage = sinon.stub(Report.v1, 'getSummariesPage'); + dataContext.bind = sinon.stub().returns(reportGetSummariesPage); + getPagedGenerator = sinon.stub(Core, 'getPagedGenerator'); + }); + + it('should get summaries generator with correct parameters', () => { + getPagedGenerator.returns(mockGenerator); + + const generator = Report.v1.getSummaries(dataContext)(qualifier); + + expect(generator).to.deep.equal(mockGenerator); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(getPagedGenerator.calledOnceWithExactly(reportGetSummariesPage, qualifier)).to.be.true; + }); + + it('should throw an error for invalid datacontext', () => { + const errMsg = 'Invalid data context [null].'; + assertDataContext.throws(new Error(errMsg)); + + expect(() => Report.v1.getSummaries(dataContext)).to.throw(errMsg); + expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true; + expect(reportGetSummariesPage.notCalled).to.be.true; + }); + + ([ + null, + undefined, + 'not-an-array', + { ids: 'not-an-array' }, + { ids: [1, 2] }, + { ids: ['valid', ''] }, + ] as unknown[]).forEach((invalid) => { + it(`throws an error for invalid qualifier ${JSON.stringify(invalid)}`, () => { + expect(() => Report.v1.getSummaries(dataContext)(invalid as never)) + .to.throw(`Invalid identifiers [${JSON.stringify(invalid)}].`); + + expect(getPagedGenerator.notCalled).to.be.true; + }); + }); + }); + describe('getUuidsPage', () => { const ids = ['report1', 'report2', 'report3']; const cursor = '1'; @@ -430,6 +573,8 @@ describe('report', () => { it('contains expected keys', () => { expect(report).to.have.all.keys([ + 'getSummaries', + 'getSummariesPage', 'getUuidsByFreetext', 'getUuidsPageByFreetext', 'getByUuid', @@ -439,6 +584,54 @@ describe('report', () => { ]); }); + it('getSummaries', () => { + const mockAsyncGenerator = fakeGenerator(); + const reportGetSummaries = sinon.stub().returns(mockAsyncGenerator); + dataContextBind.returns(reportGetSummaries); + const ids = ['uuid-1', 'uuid-2']; + const qualifier = { ids }; + const byIds = sinon.stub(Qualifier, 'byIds').returns(qualifier); + + const res = report.getSummaries(ids); + + expect(res).to.deep.equal(mockAsyncGenerator); + expect(dataContextBind.calledOnceWithExactly(Report.v1.getSummaries)).to.be.true; + expect(reportGetSummaries.calledOnceWithExactly(qualifier)).to.be.true; + expect(byIds.calledOnceWithExactly(ids)).to.be.true; + }); + + it('getSummariesPage', async () => { + const expectedPage: Page = { data: [], cursor: null }; + const reportGetSummariesPage = sinon.stub().resolves(expectedPage); + dataContextBind.returns(reportGetSummariesPage); + const ids = ['uuid-1', 'uuid-2']; + const qualifier = { ids }; + const limit = 2; + const cursor = '1'; + const byIds = sinon.stub(Qualifier, 'byIds').returns(qualifier); + + const returnedPage = await report.getSummariesPage(ids, cursor, limit); + + expect(returnedPage).to.equal(expectedPage); + expect(dataContextBind.calledOnceWithExactly(Report.v1.getSummariesPage)).to.be.true; + expect(reportGetSummariesPage.calledOnceWithExactly(qualifier, cursor, limit)).to.be.true; + expect(byIds.calledOnceWithExactly(ids)).to.be.true; + }); + + it('getSummariesPage uses default cursor and limit', async () => { + const expectedPage: Page = { data: [], cursor: null }; + const reportGetSummariesPage = sinon.stub().resolves(expectedPage); + dataContextBind.returns(reportGetSummariesPage); + const ids = ['uuid-1', 'uuid-2']; + const qualifier = { ids }; + sinon.stub(Qualifier, 'byIds').returns(qualifier); + + const returnedPage = await report.getSummariesPage(ids); + + expect(returnedPage).to.equal(expectedPage); + expect(reportGetSummariesPage.calledOnceWithExactly(qualifier, null, 100)).to.be.true; + }); + it('getByUuid', async () => { const expectedReport = {}; const reportGet = sinon.stub().resolves(expectedReport); diff --git a/shared-libs/summaries/package.json b/shared-libs/summaries/package.json new file mode 100644 index 00000000000..41e26b52aa1 --- /dev/null +++ b/shared-libs/summaries/package.json @@ -0,0 +1,15 @@ +{ + "name": "@medic/summaries", + "version": "1.0.0", + "description": "Produces compact summaries of contact and report documents", + "main": "src/index.js", + "scripts": { + "test": "nyc --nycrcPath='../nyc.config.js' mocha ./test" + }, + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@medic/constants": "file:../constants", + "@medic/contact-types-utils": "file:../contact-types-utils" + } +} diff --git a/shared-libs/summaries/src/index.d.ts b/shared-libs/summaries/src/index.d.ts new file mode 100644 index 00000000000..42a1c5df613 --- /dev/null +++ b/shared-libs/summaries/src/index.d.ts @@ -0,0 +1,52 @@ +/** + * A compact summary of a contact record. + */ +export interface ContactSummary { + readonly _id: string; + readonly _rev: string; + readonly name?: string; + readonly phone?: string; + readonly type: string; + readonly contact_type?: string; + readonly contact?: string; + readonly lineage: string[]; + readonly date_of_death?: number; + readonly muted?: boolean; +} + +/** + * A compact summary of a report record. + */ +export interface ReportSummary { + readonly _id: string; + readonly _rev: string; + readonly from?: string; + readonly phone?: string; + readonly form: string; + readonly read?: string[]; + readonly valid: boolean; + readonly verified?: boolean; + readonly reported_date?: number; + readonly contact?: string; + readonly lineage: string[]; + readonly subject: { + readonly name?: string; + readonly value?: string; + readonly type?: string; + }; + readonly case_id?: string; +} + +/** Produces a compact summary of a contact document. */ +export function summariseContact(doc: Record): ContactSummary; + +/** Produces a compact summary of a report document. */ +export function summariseReport(doc: Record): ReportSummary; + +/** + * Returns a compact summary of the given document, or `undefined` if the document is not a + * supported record type (contact or report). + */ +export function summarise( + doc: Record | null | undefined +): ContactSummary | ReportSummary | undefined; diff --git a/shared-libs/summaries/src/index.js b/shared-libs/summaries/src/index.js new file mode 100644 index 00000000000..d84a34e26f9 --- /dev/null +++ b/shared-libs/summaries/src/index.js @@ -0,0 +1,119 @@ +const contactTypesUtils = require('@medic/contact-types-utils'); +const { DOC_TYPES } = require('@medic/constants'); + +const SUBJECT_FIELDS = new Set(['patient_id', 'patient_uuid', 'patient_name', 'place_id']); + +const getLineage = (contact) => { + const parts = []; + let current = contact; + while (current) { + if (current._id) { + parts.push(current._id); + } + current = current.parent; + } + return parts; +}; + +const isMissingSubjectError = (error) => { + return error.code === 'sys.missing_fields' + && Array.isArray(error.fields) + && error.fields.some(field => SUBJECT_FIELDS.has(field)); +}; + +const getReference = (doc) => { + return doc.patient_id + ?? doc.fields?.patient_id + ?? doc.fields?.patient_uuid + ?? doc.place_id + ?? doc.fields?.place_id; +}; + +const getSubject = (doc) => { + const subject = {}; + const reference = getReference(doc); + const patientName = doc.fields?.patient_name; + + if (patientName) { + subject.name = patientName; + } + + if (reference) { + subject.value = reference; + subject.type = 'reference'; + } else if (patientName) { + subject.value = patientName; + subject.type = 'name'; + } else if (doc.errors?.some(error => isMissingSubjectError(error))) { + subject.type = 'unknown'; + } + + return subject; +}; + +const isContact = (doc) => { + const type = doc?.type; + if (!type) { + return false; + } + return type === 'contact' || contactTypesUtils.isHardcodedType(type); +}; + +const isReport = (doc) => { + return doc?.type === DOC_TYPES.DATA_RECORD && typeof doc.form === 'string' && !!doc.form; +}; + +const summariseReport = (doc) => { + return { + _id: doc._id, + _rev: doc._rev, + from: doc.from ?? doc.sent_by, + phone: doc.contact?.phone, + form: doc.form, + read: doc.read, + valid: !doc.errors?.length, + verified: doc.verified, + reported_date: doc.reported_date, + contact: doc.contact?._id, + lineage: getLineage(doc.contact?.parent), + subject: getSubject(doc), + case_id: doc.case_id ?? doc.fields?.case_id, + }; +}; + +const summariseContact = (doc) => { + return { + _id: doc._id, + _rev: doc._rev, + name: doc.name ?? doc.phone, + phone: doc.phone, + type: doc.type, + contact_type: doc.contact_type, + contact: doc.contact?._id, + lineage: getLineage(doc.parent), + date_of_death: doc.date_of_death, + muted: doc.muted, + }; +}; + +// Returns a compact summary of the given document, or `undefined` if the document is not a +// supported record type (contact or report). +const summarise = (doc) => { + if (!doc) { + return; + } + + if (isReport(doc)) { + return summariseReport(doc); + } + + if (isContact(doc)) { + return summariseContact(doc); + } +}; + +module.exports = { + summarise, + summariseContact, + summariseReport, +}; diff --git a/shared-libs/summaries/test/index.spec.js b/shared-libs/summaries/test/index.spec.js new file mode 100644 index 00000000000..10900ef0071 --- /dev/null +++ b/shared-libs/summaries/test/index.spec.js @@ -0,0 +1,257 @@ +const { expect } = require('chai'); +const { + summarise, + summariseContact, + summariseReport, +} = require('../src'); + +describe('summary lib', () => { + describe('summariseReport', () => { + it('summarises a report', () => { + const doc = { + _id: 'report-1', + _rev: '1-abc', + type: 'data_record', + form: 'delivery', + from: '+123', + contact: { + _id: 'c', + phone: '+456', + parent: { _id: 'd', parent: { _id: 'e' } }, + }, + verified: true, + reported_date: 100, + fields: { patient_name: 'jeff', patient_id: 'f' }, + }; + + expect(summariseReport(doc)).to.deep.equal({ + _id: 'report-1', + _rev: '1-abc', + from: '+123', + phone: '+456', + form: 'delivery', + read: undefined, + valid: true, + verified: true, + reported_date: 100, + contact: 'c', + lineage: ['d', 'e'], + subject: { name: 'jeff', value: 'f', type: 'reference' }, + case_id: undefined, + }); + }); + + it('uses sent_by when from is missing', () => { + const doc = { _id: 'r1', _rev: '1', type: 'data_record', form: 'R', sent_by: '+321' }; + expect(summariseReport(doc).from).to.equal('+321'); + }); + + it('marks invalid when errors present', () => { + const doc = { + _id: 'r1', + _rev: '1', + type: 'data_record', + form: 'R', + errors: [{ code: 'sys.missing_fields', fields: ['patient_id'] }], + }; + expect(summariseReport(doc).valid).to.equal(false); + }); + + it('includes case_id from doc', () => { + const doc = { _id: 'r1', _rev: '1', type: 'data_record', form: 'R', case_id: '12345' }; + expect(summariseReport(doc).case_id).to.equal('12345'); + }); + + it('includes case_id from fields', () => { + const doc = { _id: 'r1', _rev: '1', type: 'data_record', form: 'R', fields: { case_id: '67890' } }; + expect(summariseReport(doc).case_id).to.equal('67890'); + }); + + describe('lineage', () => { + it('is empty when the report contact has no parent', () => { + const doc = { _id: 'r1', _rev: '1', type: 'data_record', form: 'R' }; + expect(summariseReport(doc).lineage).to.deep.equal([]); + }); + + it('contains the contact parent ids', () => { + const doc = { + _id: 'r1', + _rev: '1', + type: 'data_record', + form: 'R', + contact: { _id: 'c', parent: { _id: 'd', parent: { _id: 'e' } } }, + }; + expect(summariseReport(doc).lineage).to.deep.equal(['d', 'e']); + }); + }); + + describe('subject', () => { + it('returns reference type for patient_id', () => { + expect(summariseReport({ patient_id: '12345', fields: {} }).subject) + .to.deep.equal({ value: '12345', type: 'reference' }); + }); + + it('returns reference type for fields.patient_id', () => { + expect(summariseReport({ fields: { patient_id: '12345' } }).subject) + .to.deep.equal({ value: '12345', type: 'reference' }); + }); + + it('returns reference type for fields.patient_uuid', () => { + expect(summariseReport({ fields: { patient_uuid: 'uuid-123' } }).subject) + .to.deep.equal({ value: 'uuid-123', type: 'reference' }); + }); + + it('returns reference type for place_id', () => { + expect(summariseReport({ place_id: 'place-1', fields: {} }).subject) + .to.deep.equal({ value: 'place-1', type: 'reference' }); + }); + + it('returns reference type for fields.place_id', () => { + expect(summariseReport({ fields: { place_id: 'place-1' } }).subject) + .to.deep.equal({ value: 'place-1', type: 'reference' }); + }); + + it('includes patient_name when present with reference', () => { + expect(summariseReport({ fields: { patient_id: '123', patient_name: 'jeff' } }).subject) + .to.deep.equal({ name: 'jeff', value: '123', type: 'reference' }); + }); + + it('returns name type when only patient_name', () => { + expect(summariseReport({ fields: { patient_name: 'jeff' } }).subject) + .to.deep.equal({ name: 'jeff', value: 'jeff', type: 'name' }); + }); + + it('returns unknown type for missing subject errors', () => { + const doc = { fields: {}, errors: [{ code: 'sys.missing_fields', fields: ['patient_id'] }] }; + expect(summariseReport(doc).subject).to.deep.equal({ type: 'unknown' }); + }); + + it('returns empty object when no subject info', () => { + expect(summariseReport({ fields: {} }).subject).to.deep.equal({}); + }); + + it('returns empty object for non-missing-subject errors', () => { + const doc = { fields: {}, errors: [{ code: 'other_error', fields: ['patient_id'] }] }; + expect(summariseReport(doc).subject).to.deep.equal({}); + }); + }); + }); + + describe('summariseContact', () => { + it('summarises a person contact', () => { + const doc = { + _id: 'person-1', + _rev: '1-abc', + type: 'person', + name: 'james', + phone: '+456', + contact: { _id: 'c' }, + parent: { _id: 'f', parent: { _id: 'g' } }, + date_of_death: 999, + }; + + expect(summariseContact(doc)).to.deep.equal({ + _id: 'person-1', + _rev: '1-abc', + name: 'james', + phone: '+456', + type: 'person', + contact_type: undefined, + contact: 'c', + lineage: ['f', 'g'], + date_of_death: 999, + muted: undefined, + }); + }); + + it('summarises a configurable contact', () => { + const doc = { + _id: 'contact-1', + _rev: '2-def', + type: 'contact', + contact_type: 'patient', + phone: '+123', + parent: { _id: 'f', parent: { _id: 'g' } }, + muted: true, + }; + + expect(summariseContact(doc)).to.deep.equal({ + _id: 'contact-1', + _rev: '2-def', + name: '+123', + phone: '+123', + type: 'contact', + contact_type: 'patient', + contact: undefined, + lineage: ['f', 'g'], + date_of_death: undefined, + muted: true, + }); + }); + + it('uses phone as name when name is missing', () => { + const doc = { _id: 'c1', _rev: '1', type: 'person', phone: '0123456789' }; + expect(summariseContact(doc).name).to.equal('0123456789'); + }); + + describe('lineage', () => { + it('is empty when the contact has no parent', () => { + const doc = { _id: 'c1', _rev: '1', type: 'person' }; + expect(summariseContact(doc).lineage).to.deep.equal([]); + }); + + it('skips ancestors without an _id', () => { + const doc = { _id: 'c1', _rev: '1', type: 'person', parent: { parent: { _id: 'b' } } }; + expect(summariseContact(doc).lineage).to.deep.equal(['b']); + }); + }); + }); + + describe('summarise', () => { + it('returns undefined for falsy doc', () => { + expect(summarise(null)).to.equal(undefined); + expect(summarise(undefined)).to.equal(undefined); + }); + + it('returns undefined for a doc without a type', () => { + expect(summarise({ _id: 'a', _rev: '1' })).to.equal(undefined); + }); + + it('returns undefined for non-matching doc types', () => { + expect(summarise({ _id: 'a', _rev: '1', type: 'form' })).to.equal(undefined); + }); + + it('returns undefined for data_record without form', () => { + expect(summarise({ _id: 'a', _rev: '1', type: 'data_record' })).to.equal(undefined); + }); + + it('dispatches to summariseReport for data_record docs', () => { + const doc = { _id: 'r1', _rev: '1', type: 'data_record', form: 'R', from: '+1' }; + const summary = summarise(doc); + expect(summary).to.include({ _id: 'r1', form: 'R', from: '+1' }); + }); + + it('dispatches to summariseContact for contact docs', () => { + const doc = { _id: 'c1', _rev: '1', type: 'person', name: 'jeff' }; + const summary = summarise(doc); + expect(summary).to.include({ _id: 'c1', type: 'person', name: 'jeff' }); + }); + + it('dispatches to summariseContact for configurable contact types', () => { + const doc = { _id: 'c1', _rev: '1', type: 'contact', contact_type: 'patient' }; + expect(summarise(doc)).to.include({ _id: 'c1', type: 'contact', contact_type: 'patient' }); + }); + + it('dispatches to summariseContact for hardcoded contact types', () => { + for (const type of ['person', 'clinic', 'health_center', 'district_hospital']) { + expect(summarise({ _id: 'c1', _rev: '1', type })).to.include({ _id: 'c1', type }); + } + }); + + it('does not treat a non-data_record with a form as a report', () => { + const summary = summarise({ _id: 'c1', _rev: '1', type: 'person', form: 'pregnancy' }); + expect(summary).to.include({ _id: 'c1', type: 'person' }); + expect(summary).to.not.have.property('subject'); + }); + }); +}); diff --git a/tests/e2e/default/reports/reports-subject.wdio-spec.js b/tests/e2e/default/reports/reports-subject.wdio-spec.js index aa46c810fe7..21a68fa8ec5 100644 --- a/tests/e2e/default/reports/reports-subject.wdio-spec.js +++ b/tests/e2e/default/reports/reports-subject.wdio-spec.js @@ -148,7 +148,7 @@ describe('Reports Subject', () => { from: user.phone, fields: { patient_name: '' } }; - await createSmsReport(report, '1!RR!', [{ fields: 'patient_name', code: 'sys.missing_fields' }]); + await createSmsReport(report, '1!RR!', [{ fields: ['patient_name'], code: 'sys.missing_fields' }]); await verifyListReportContent({ formName: 'NAM_NAM', subject: 'Unknown subject' }); await verifyOpenReportContent({ formName: 'NAM_NAM', subject: 'Unknown subject' }); }); diff --git a/webapp/src/ts/modules/contacts/contacts.component.ts b/webapp/src/ts/modules/contacts/contacts.component.ts index 8330ef0a0e8..08ba2340827 100644 --- a/webapp/src/ts/modules/contacts/contacts.component.ts +++ b/webapp/src/ts/modules/contacts/contacts.component.ts @@ -248,7 +248,7 @@ export class ContactsComponent implements OnInit, OnDestroy { .getUserFacilityId(userSettings) .filter(id => !!id); - let homePlaces = await this.getDataRecordsService.get(facilityIds); + let homePlaces = await this.getDataRecordsService.getContacts(facilityIds); homePlaces = homePlaces?.filter(place => !!place); homePlaces?.forEach(homePlace => homePlace.home = true); return homePlaces; diff --git a/webapp/src/ts/services/get-data-records.service.ts b/webapp/src/ts/services/get-data-records.service.ts index 808167d011b..a3b5dccf3fb 100644 --- a/webapp/src/ts/services/get-data-records.service.ts +++ b/webapp/src/ts/services/get-data-records.service.ts @@ -46,7 +46,19 @@ export class GetDataRecordsService { return await this.getSubjectSummariesService.get(summaries); } - async get(ids: string[], options: { hydrateContactNames?: boolean, include_docs?: boolean } = {}) { + getContacts(ids: string[], options: { hydrateContactNames?: boolean, include_docs?: boolean } = {}) { + return this.getRecords(ids, 'contact', options); + } + + getReports(ids: string[], options: { hydrateContactNames?: boolean, include_docs?: boolean } = {}) { + return this.getRecords(ids, 'report', options); + } + + private async getRecords( + ids: string[], + type: 'contact' | 'report', + options: { hydrateContactNames?: boolean, include_docs?: boolean } + ) { if (!ids?.length) { return Promise.resolve([]); } @@ -56,7 +68,9 @@ export class GetDataRecordsService { return this.getDocs(ids); } - const summaries = await this.getSummariesService.get(ids); + const summaries = type === 'report' + ? await this.getSummariesService.getReports(ids) + : await this.getSummariesService.getContacts(ids); return this.prepareSummaries(summaries, opts); } diff --git a/webapp/src/ts/services/get-subject-summaries.service.ts b/webapp/src/ts/services/get-subject-summaries.service.ts index a3b7ace0e05..45fa476a931 100644 --- a/webapp/src/ts/services/get-subject-summaries.service.ts +++ b/webapp/src/ts/services/get-subject-summaries.service.ts @@ -75,7 +75,7 @@ export class GetSubjectSummariesService { const uniqueIds = [...new Set(ids)]; return this - .getSummariesService.get(uniqueIds) + .getSummariesService.getContacts(uniqueIds) .then((response) => { return this.replaceIdsWithNames(summaries, response); }); diff --git a/webapp/src/ts/services/get-summaries.service.ts b/webapp/src/ts/services/get-summaries.service.ts index 6dd2c28e83f..fa9f553a078 100644 --- a/webapp/src/ts/services/get-summaries.service.ts +++ b/webapp/src/ts/services/get-summaries.service.ts @@ -1,139 +1,45 @@ import { Injectable } from '@angular/core'; +import { Contact, Qualifier, Report } from '@medic/cht-datasource'; +import { summarise } from '@medic/summaries'; -import { ContactTypesService } from '@mm-services/contact-types.service'; -import { DbService } from '@mm-services/db.service'; -import { SessionService } from '@mm-services/session.service'; -import { DOC_TYPES } from '@medic/constants'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; @Injectable({ providedIn: 'root' }) export class GetSummariesService { + private readonly getContactSummaries: ReturnType; + private readonly getReportSummaries: ReturnType; + constructor( - private contactTypesService:ContactTypesService, - private dbService:DbService, - private sessionService:SessionService, + chtDatasourceService: CHTDatasourceService, ) { + this.getContactSummaries = chtDatasourceService.bindGenerator(Contact.v1.getSummaries); + this.getReportSummaries = chtDatasourceService.bindGenerator(Report.v1.getSummaries); } - private readonly SUBJECT_FIELDS = [ 'patient_id', 'patient_uuid', 'patient_name', 'place_id' ]; - - private getLineage(contact) { - const parts: any[] = []; - while (contact) { - if (contact._id) { - parts.push(contact._id); - } - contact = contact.parent; - } - return parts; - } - - private isMissingSubjectError(error) { - return error.code === 'sys.missing_fields' && - error.fields && - error.fields.some(field => this.SUBJECT_FIELDS.includes(field)); - } - - private getSubject(doc) { - const subject:any = {}; - const reference = - doc.patient_id || - (doc.fields && doc.fields.patient_id) || - (doc.fields && doc.fields.patient_uuid) || - doc.place_id || - (doc.fields && doc.fields.place_id); - const patientName = doc.fields && doc.fields.patient_name; - if (patientName) { - subject.name = patientName; - } - - if (reference) { - subject.value = reference; - subject.type = 'reference'; - } else if (patientName) { - subject.value = patientName; - subject.type = 'name'; - } else if (doc.errors) { - if (doc.errors.some(error => this.isMissingSubjectError(error))) { - subject.type = 'unknown'; - } - } - - return subject; - } - - // WARNING: This is a copy of the medic/doc_summaries_by_id view - // with some minor modifications and needs to be kept in sync until - // this workaround is no longer needed. - // https://github.com/medic/medic/issues/4666 - private summarise(doc) { - if (!doc) { - // happens when the doc with the requested id wasn't found in the DB - return; + async getContacts(ids?) { + if (!ids?.length) { + return []; } - if (doc.type === DOC_TYPES.DATA_RECORD && doc.form) { // report - return { - _id: doc._id, - _rev: doc._rev, - from: doc.from || doc.sent_by, - phone: doc.contact && doc.contact.phone, - form: doc.form, - read: doc.read, - valid: !doc.errors || !doc.errors.length, - verified: doc.verified, - reported_date: doc.reported_date, - contact: doc.contact && doc.contact._id, - lineage: this.getLineage(doc.contact && doc.contact.parent), - subject: this.getSubject(doc), - case_id: doc.case_id || (doc.fields && doc.fields.case_id) - }; - } - if (this.contactTypesService.includes(doc)) { // contact - return { - _id: doc._id, - _rev: doc._rev, - name: doc.name || doc.phone, - phone: doc.phone, - type: doc.type, - contact_type: doc.contact_type, - contact: doc.contact && doc.contact._id, - lineage: this.getLineage(doc.parent), - date_of_death: doc.date_of_death, - muted: doc.muted - }; + const summaries: Contact.v1.ContactSummary[] = []; + for await (const summary of this.getContactSummaries(Qualifier.byIds(ids))) { + summaries.push(summary); } + return summaries; } - private getRemote(ids) { - return this.dbService - .get() - .query('medic/doc_summaries_by_id', { keys: ids }) - .then(response => { - return response.rows.map(row => { - row.value._id = row.id; - return row.value; - }); - }); - } - - async get(ids?) { + async getReports(ids?) { if (!ids?.length) { - return Promise.resolve([]); + return []; } - if (this.sessionService.isOnlineOnly()) { - return this.getRemote(ids); + const summaries: Report.v1.ReportSummary[] = []; + for await (const summary of this.getReportSummaries(Qualifier.byIds(ids))) { + summaries.push(summary); } - - const result = await this.dbService - .get() - .allDocs({ keys: ids, include_docs: true }); - - return result?.rows - ?.map(row => this.summarise(row.doc)) - .filter(summary => summary); + return summaries; } getByDocs(docs) { @@ -142,7 +48,7 @@ export class GetSummariesService { } return docs - .map(doc => this.summarise(doc)) + .map(doc => summarise(doc)) .filter(summary => summary); } } diff --git a/webapp/src/ts/services/hydrate-contact-names.service.ts b/webapp/src/ts/services/hydrate-contact-names.service.ts index 797dc523ae0..a408b756608 100644 --- a/webapp/src/ts/services/hydrate-contact-names.service.ts +++ b/webapp/src/ts/services/hydrate-contact-names.service.ts @@ -61,7 +61,7 @@ export class HydrateContactNamesService { return Promise.resolve(summaries); } - return this.getSummariesService.get(ids).then((response) => { + return this.getSummariesService.getContacts(ids).then((response) => { summaries = this.getMutedState(summaries, response); return this.replaceContactIdsWithNames(summaries, response); }); diff --git a/webapp/src/ts/services/message-contact.service.ts b/webapp/src/ts/services/message-contact.service.ts index 49a4dec9269..bcf4b0ac175 100644 --- a/webapp/src/ts/services/message-contact.service.ts +++ b/webapp/src/ts/services/message-contact.service.ts @@ -51,7 +51,7 @@ export class MessageContactService { const ids = response.rows.map(row => row.value && row.value.id); return this.getDataRecordsService - .get(ids, {include_docs: true}) + .getReports(ids, {include_docs: true}) .then(docs => { response.rows.forEach((row, idx) => row.doc = docs[idx]); return response.rows; diff --git a/webapp/src/ts/services/report-view-model-generator.service.ts b/webapp/src/ts/services/report-view-model-generator.service.ts index 371a4462662..0e5721d8bd0 100644 --- a/webapp/src/ts/services/report-view-model-generator.service.ts +++ b/webapp/src/ts/services/report-view-model-generator.service.ts @@ -52,7 +52,7 @@ export class ReportViewModelGeneratorService { }) .then((model) => { return this.getSummariesService - .get([model.doc._id]) + .getReports([model.doc._id]) .then((results) => { return this.getSubjectSummariesService.get(results, true); }) diff --git a/webapp/src/ts/services/search.service.ts b/webapp/src/ts/services/search.service.ts index 44e2e23f310..b7cecc811d8 100644 --- a/webapp/src/ts/services/search.service.ts +++ b/webapp/src/ts/services/search.service.ts @@ -171,10 +171,10 @@ export class SearchService { additionalDocIds .filter(docId => searchResults.docIds.indexOf(docId) === -1) .forEach(docId => searchResults.docIds.push(docId)); - const dataRecordsPromise = this.getDataRecordsService.get( - searchResults.docIds, - { hydrateContactNames, include_docs: options.include_docs } - ); + const getDataRecordsOptions = { hydrateContactNames, include_docs: options.include_docs }; + const dataRecordsPromise = type === 'reports' + ? this.getDataRecordsService.getReports(searchResults.docIds, getDataRecordsOptions) + : this.getDataRecordsService.getContacts(searchResults.docIds, getDataRecordsOptions); if (!displayLastVisitedDate) { return dataRecordsPromise; diff --git a/webapp/src/ts/services/target-aggregates.service.ts b/webapp/src/ts/services/target-aggregates.service.ts index b205b688b35..9a9ae89e60b 100644 --- a/webapp/src/ts/services/target-aggregates.service.ts +++ b/webapp/src/ts/services/target-aggregates.service.ts @@ -198,7 +198,7 @@ export class TargetAggregatesService { .map(place => place.contact); return this.getDataRecordsService - .get(contactIds) + .getContacts(contactIds) .then(newContacts => { contacts.push(...newContacts); @@ -221,7 +221,7 @@ export class TargetAggregatesService { private async getHomePlace(facilityId?) { if (facilityId) { - const places = await this.getDataRecordsService.get([ facilityId ]); + const places = await this.getDataRecordsService.getContacts([ facilityId ]); return places?.[0]; } @@ -229,7 +229,7 @@ export class TargetAggregatesService { if (!facilityIds?.length) { return; } - const places = await this.getDataRecordsService.get(facilityIds); + const places = await this.getDataRecordsService.getContacts(facilityIds); return places?.[0]; } diff --git a/webapp/src/ts/services/user-settings.service.ts b/webapp/src/ts/services/user-settings.service.ts index 5e23f8e9077..099868e9463 100644 --- a/webapp/src/ts/services/user-settings.service.ts +++ b/webapp/src/ts/services/user-settings.service.ts @@ -81,7 +81,7 @@ export class UserSettingsService { if (userFacilities && !Array.isArray(userFacilities)) { userFacilities = [ userFacilities ]; } - return this.getDataRecordsService.get(userFacilities); + return this.getDataRecordsService.getContacts(userFacilities); }) .catch((err) => { console.error('Error fetching user facility:', err); diff --git a/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts index f421c565b25..5b6ce2c3107 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts @@ -80,7 +80,7 @@ describe('Contacts component', () => { get: sinon.stub().resolves({ facility_id: district._id }) }; getDataRecordsService = { - get: sinon.stub().resolves([ district ]) + getContacts: sinon.stub().resolves([ district ]) }; contactTypesService = { getChildren: sinon.stub().resolves([ @@ -275,7 +275,7 @@ describe('Contacts component', () => { sinon.resetHistory(); sessionService.isOnlineOnly.returns(true); userSettingsService.get.resolves({ facility_id: undefined }); - getDataRecordsService.get.resolves(undefined); + getDataRecordsService.getContacts.resolves(undefined); searchResults = [{ _id: 'search-result' }]; searchService.search.resolves(searchResults); component.contactsActions.updateContactsList = sinon.stub(); @@ -303,7 +303,7 @@ describe('Contacts component', () => { it('when paginating, does not skip the extra place for admins #4085', fakeAsync(() => { sinon.resetHistory(); userSettingsService.get.resolves({ facility_id: undefined }); - getDataRecordsService.get.resolves(undefined); + getDataRecordsService.getContacts.resolves(undefined); const searchResult = { _id: 'search-result' }; searchResults = Array(50).fill(searchResult); searchService.search.resolves(searchResults); @@ -365,7 +365,7 @@ describe('Contacts component', () => { it('when refreshing list as admin, does not modify limit #4085', fakeAsync(() => { sinon.resetHistory(); userSettingsService.get.resolves({ facility_id: undefined }); - getDataRecordsService.get.resolves(undefined); + getDataRecordsService.getContacts.resolves(undefined); const searchResult = { _id: 'search-result' }; searchResults = Array(60).fill(searchResult); searchService.search.resolves(searchResults); @@ -1184,7 +1184,7 @@ describe('Contacts component', () => { }]; userSettingsService.get.resolves({ facility_id: [multi_facility[0]._id, multi_facility[1]._id] }); - getDataRecordsService.get.resolves(multi_facility); + getDataRecordsService.getContacts.resolves(multi_facility); sinon.stub(ContactsActions.prototype, 'updateContactsList'); component.ngOnInit(); diff --git a/webapp/tests/karma/ts/services/contact-view-model-generator.service.spec.ts b/webapp/tests/karma/ts/services/contact-view-model-generator.service.spec.ts index 58a9169334d..1704ad2c96c 100644 --- a/webapp/tests/karma/ts/services/contact-view-model-generator.service.spec.ts +++ b/webapp/tests/karma/ts/services/contact-view-model-generator.service.spec.ts @@ -67,7 +67,8 @@ describe('ContactViewModelGenerator service', () => { }; const stubGetDataRecordsService = (dataRecords) => { - getDataRecordsService.get = sinon.stub().resolves(dataRecords); + getDataRecordsService.getContacts = sinon.stub().resolves(dataRecords); + getDataRecordsService.getReports = sinon.stub().resolves(dataRecords); getDataRecordsService.getDocsSummaries = sinon.stub().resolves(dataRecords); }; diff --git a/webapp/tests/karma/ts/services/get-data-records.service.spec.ts b/webapp/tests/karma/ts/services/get-data-records.service.spec.ts index 7d6f089ddca..77f91140bd3 100644 --- a/webapp/tests/karma/ts/services/get-data-records.service.spec.ts +++ b/webapp/tests/karma/ts/services/get-data-records.service.spec.ts @@ -17,7 +17,8 @@ describe('GetDataRecords service', () => { allDocs = sinon.stub(); hydrateContactNamesService = { get: sinon.stub() }; getSummariesService = { - get: sinon.stub(), + getContacts: sinon.stub(), + getReports: sinon.stub(), getByDocs: sinon.stub(), }; @@ -37,7 +38,7 @@ describe('GetDataRecords service', () => { }); it('returns empty array when given empty array', () => { - return service.get([]).then(actual => expect(actual).to.deep.equal([])); + return service.getContacts([]).then(actual => expect(actual).to.deep.equal([])); }); describe('getDocsSummaries()', () => { @@ -111,12 +112,12 @@ describe('GetDataRecords service', () => { }); }); - describe('get() - summaries', () => { + describe('getContacts() - summaries', () => { it('db errors', () => { - getSummariesService.get.rejects('missing'); + getSummariesService.getContacts.rejects('missing'); return service - .get([ '5' ]) + .getContacts([ '5' ]) .then(() => { throw new Error('expected error to be thrown'); }) @@ -124,15 +125,16 @@ describe('GetDataRecords service', () => { }); it('no result', () => { - getSummariesService.get.resolves(null); + getSummariesService.getContacts.resolves(null); hydrateContactNamesService.get.resolves([]); return service - .get([ '5' ]) + .getContacts([ '5' ]) .then(actual => { expect(actual).to.deep.equal([]); - expect(getSummariesService.get.callCount).to.equal(1); - expect(getSummariesService.get.args[0][0]).to.deep.equal(['5']); + expect(getSummariesService.getContacts.callCount).to.equal(1); + expect(getSummariesService.getContacts.args[0][0]).to.deep.equal(['5']); + expect(getSummariesService.getReports.callCount).to.equal(0); expect(allDocs.callCount).to.equal(0); }); }); @@ -144,7 +146,7 @@ describe('GetDataRecords service', () => { contact: 'jim', lineage: [ 'area', 'center' ] }; - getSummariesService.get.resolves([ + getSummariesService.getContacts.resolves([ { _id: '5', name: 'five', @@ -155,10 +157,10 @@ describe('GetDataRecords service', () => { hydrateContactNamesService.get.resolves([ expected ]); return service - .get([ '5' ], { hydrateContactNames: true }) + .getContacts([ '5' ], { hydrateContactNames: true }) .then(actual => { expect(actual).to.deep.equal([ expected ]); - expect(getSummariesService.get.callCount).to.equal(1); + expect(getSummariesService.getContacts.callCount).to.equal(1); expect(allDocs.callCount).to.equal(0); expect(hydrateContactNamesService.get.callCount).to.equal(1); expect(hydrateContactNamesService.get.args[0][0]).to.deep.equal([{ @@ -176,7 +178,7 @@ describe('GetDataRecords service', () => { { _id: '6', name: 'six' }, { _id: '7', name: 'seven' } ]; - getSummariesService.get.resolves([ + getSummariesService.getContacts.resolves([ { _id: '5', name: 'five' }, { _id: '6', name: 'six' }, { _id: '7', name: 'seven' } @@ -184,22 +186,40 @@ describe('GetDataRecords service', () => { hydrateContactNamesService.get.resolves(expected); return service - .get([ '5', '6', '7' ], { hydrateContactNames: true }) + .getContacts([ '5', '6', '7' ], { hydrateContactNames: true }) .then(actual => { expect(actual).to.deep.equal(expected); - expect(getSummariesService.get.callCount).to.equal(1); - expect(getSummariesService.get.args[0][0]).to.deep.equal([ '5', '6', '7' ]); + expect(getSummariesService.getContacts.callCount).to.equal(1); + expect(getSummariesService.getContacts.args[0][0]).to.deep.equal([ '5', '6', '7' ]); expect(allDocs.callCount).to.equal(0); }); }); }); - describe('get() - details', () => { + describe('getReports() - summaries', () => { + it('only loads report summaries', () => { + const summaries = [{ _id: '5', form: 'delivery' }]; + getSummariesService.getReports.resolves(summaries); + hydrateContactNamesService.get.resolves(summaries); + + return service + .getReports([ '5' ]) + .then(actual => { + expect(actual).to.deep.equal(summaries); + expect(getSummariesService.getReports.callCount).to.equal(1); + expect(getSummariesService.getReports.args[0][0]).to.deep.equal(['5']); + expect(getSummariesService.getContacts.callCount).to.equal(0); + expect(allDocs.callCount).to.equal(0); + }); + }); + }); + + describe('getContacts() - details', () => { it('db errors', () => { allDocs.rejects('missing'); return service - .get([ '5' ], { include_docs: true }) + .getContacts([ '5' ], { include_docs: true }) .then(() => { throw new Error('expected error to be thrown'); }) @@ -210,12 +230,12 @@ describe('GetDataRecords service', () => { allDocs.resolves({ rows: [] }); return service - .get([ '5' ], { include_docs: true }) + .getContacts([ '5' ], { include_docs: true }) .then(actual => { expect(actual).to.deep.equal([]); expect(allDocs.callCount).to.equal(1); expect(allDocs.args[0][0]).to.deep.equal({ keys: [ '5' ], include_docs: true }); - expect(getSummariesService.get.callCount).to.equal(0); + expect(getSummariesService.getContacts.callCount).to.equal(0); }); }); @@ -226,12 +246,12 @@ describe('GetDataRecords service', () => { ] }); return service - .get([ '5' ], { include_docs: true }) + .getContacts([ '5' ], { include_docs: true }) .then(actual => { expect(actual).to.deep.equal([ { _id: '5', name: 'five' } ]); expect(allDocs.callCount).to.equal(1); expect(allDocs.args[0][0]).to.deep.equal({ keys: [ '5' ], include_docs: true }); - expect(getSummariesService.get.callCount).to.equal(0); + expect(getSummariesService.getContacts.callCount).to.equal(0); }); }); @@ -244,7 +264,7 @@ describe('GetDataRecords service', () => { ] }); return service - .get([ '5', '6', '7' ], { include_docs: true }) + .getReports([ '5', '6', '7' ], { include_docs: true }) .then(actual => { expect(actual).to.deep.equal([ { _id: '5', name: 'five' }, @@ -253,7 +273,7 @@ describe('GetDataRecords service', () => { ]); expect(allDocs.callCount).to.equal(1); expect(allDocs.args[0][0]).to.deep.equal({ keys: [ '5', '6', '7' ], include_docs: true }); - expect(getSummariesService.get.callCount).to.equal(0); + expect(getSummariesService.getReports.callCount).to.equal(0); }); }); }); diff --git a/webapp/tests/karma/ts/services/get-subject-summaries.service.spec.ts b/webapp/tests/karma/ts/services/get-subject-summaries.service.spec.ts index bb57f074551..01b215b0d88 100644 --- a/webapp/tests/karma/ts/services/get-subject-summaries.service.spec.ts +++ b/webapp/tests/karma/ts/services/get-subject-summaries.service.spec.ts @@ -30,7 +30,7 @@ describe('GetSubjectSummaries service', () => { TestBed.configureTestingModule({ providers: [ { provide: DbService, useValue: { get: sinon.stub().returns({ query }) } }, - { provide: GetSummariesService, useValue: { get: GetSummaries } }, + { provide: GetSummariesService, useValue: { getContacts: GetSummaries } }, { provide: LineageModelGeneratorService, useValue: LineageModelGenerator }, ] }); diff --git a/webapp/tests/karma/ts/services/get-summaries.service.spec.ts b/webapp/tests/karma/ts/services/get-summaries.service.spec.ts index 7bf4a304df0..a256d4bd4ad 100644 --- a/webapp/tests/karma/ts/services/get-summaries.service.spec.ts +++ b/webapp/tests/karma/ts/services/get-summaries.service.spec.ts @@ -3,29 +3,24 @@ import sinon from 'sinon'; import { expect } from 'chai'; import { GetSummariesService } from '@mm-services/get-summaries.service'; -import { DbService } from '@mm-services/db.service'; -import { SessionService } from '@mm-services/session.service'; -import { ContactTypesService } from '@mm-services/contact-types.service'; -import { DOC_TYPES } from '@medic/constants'; +import { CHTDatasourceService } from '@mm-services/cht-datasource.service'; describe('GetSummaries service', () => { - let service:GetSummariesService; - let query; - let allDocs; - let includes; - let isOnlineOnly; + let service: GetSummariesService; + let getContactSummaries; + let getReportSummaries; beforeEach(() => { - query = sinon.stub(); - allDocs = sinon.stub(); - includes = sinon.stub(); - isOnlineOnly = sinon.stub(); + getContactSummaries = sinon.stub(); + getReportSummaries = sinon.stub(); + + const bindGenerator = sinon.stub(); + bindGenerator.onFirstCall().returns(getContactSummaries); + bindGenerator.onSecondCall().returns(getReportSummaries); TestBed.configureTestingModule({ providers: [ - { provide: DbService, useValue: { get: () => ({ query, allDocs }) } }, - { provide: SessionService, useValue: { isOnlineOnly } }, - { provide: ContactTypesService, useValue: { includes } }, + { provide: CHTDatasourceService, useValue: { bindGenerator } }, ] }); @@ -36,277 +31,150 @@ describe('GetSummaries service', () => { sinon.restore(); }); - it('returns empty array when given no ids', () => { - return service.get().then(actual => { + describe('getContacts', () => { + it('returns empty array when given no ids', async () => { + const actual = await service.getContacts(); expect(actual).to.deep.equal([]); + expect(getContactSummaries.notCalled).to.be.true; + expect(getReportSummaries.notCalled).to.be.true; }); - }); - it('returns empty array when given empty array', () => { - return service.get([]).then(actual => { + it('returns empty array when given empty array', async () => { + const actual = await service.getContacts([]); expect(actual).to.deep.equal([]); + expect(getContactSummaries.notCalled).to.be.true; + expect(getReportSummaries.notCalled).to.be.true; }); - }); - describe('online users', () => { + it('only loads contact summaries from the datasource', async () => { + const contactSummaries = [{ _id: 'a', name: 'james' }]; + getContactSummaries.returns((async function* () { + yield* contactSummaries; + })()); - beforeEach(() => isOnlineOnly.returns(true)); + const actual = await service.getContacts(['a', 'b']); - it('queries the view and returns', () => { - query.resolves({ - rows: [ - { - id: 'a', - value: { reported_date: 1 } - }, - { - id: 'b', - value: { reported_date: 2 } - }, - ] }); - return service.get([ 'a', 'b' ]).then(actual => { - expect(query.callCount).to.equal(1); - expect(query.args[0][0]).to.equal('medic/doc_summaries_by_id'); - expect(query.args[0][1]).to.deep.equal({ keys: [ 'a', 'b' ] }); - expect(allDocs.callCount).to.equal(0); - expect(actual).to.deep.equal([ - { - _id: 'a', - reported_date: 1 - }, - { - _id: 'b', - reported_date: 2 - }, - ]); - }); + expect(getContactSummaries.calledOnceWithExactly({ ids: ['a', 'b'] })).to.be.true; + expect(getReportSummaries.notCalled).to.be.true; + expect(actual).to.deep.equal(contactSummaries); }); - }); - describe('restricted users', () => { + describe('getReports', () => { + it('returns empty array when given no ids', async () => { + const actual = await service.getReports(); + expect(actual).to.deep.equal([]); + expect(getContactSummaries.notCalled).to.be.true; + expect(getReportSummaries.notCalled).to.be.true; + }); - beforeEach(() => isOnlineOnly.returns(false)); + it('returns empty array when given empty array', async () => { + const actual = await service.getReports([]); + expect(actual).to.deep.equal([]); + expect(getContactSummaries.notCalled).to.be.true; + expect(getReportSummaries.notCalled).to.be.true; + }); - it('queries allDocs and summarises reports', () => { - allDocs.resolves({ - rows: [ - { - doc: { - _id: 'a', - _rev: '1', - type: DOC_TYPES.DATA_RECORD, - form: 'delivery', - from: '+123', - contact: { - _id: 'c', - phone: '+456', - parent: { - _id: 'd', - parent: { - _id: 'e' - } - } - }, - verified: true, - reported_date: 100, - fields: { - patient_name: 'jeff', - patient_id: 'f' - } - } - }, - { - doc: { - _id: 'b', - _rev: '2', - type: DOC_TYPES.DATA_RECORD, - form: 'registration', - sent_by: '+321', - errors: [ { code: 'sys.missing_fields', fields: [ 'patient_id' ] } ], - reported_date: 200 - } - }, - { - doc: { - _id: 'c', - _rev: '1', - type: DOC_TYPES.DATA_RECORD, - form: 'delivery', - from: '+123', - contact: { - _id: 'c', - phone: '+456', - parent: { - _id: 'd', - parent: { - _id: 'e' - } - } - }, - verified: true, - reported_date: 100, - fields: { - place_id: 'd', - } - } - }, - ] }); - return service.get([ 'a', 'b' ]).then(actual => { - expect(query.callCount).to.equal(0); - expect(allDocs.callCount).to.equal(1); - expect(allDocs.args[0][0]).to.deep.equal({ keys: [ 'a', 'b' ], include_docs: true }); - expect(actual).to.deep.equal([ - { - _id: 'a', - _rev: '1', - from: '+123', - phone: '+456', - form: 'delivery', - read: undefined, - valid: true, - verified: true, - reported_date: 100, - contact: 'c', - lineage: [ 'd', 'e' ], - subject: { - name: 'jeff', - value: 'f', - type: 'reference' - }, - case_id: undefined - }, - { - _id: 'b', - _rev: '2', - from: '+321', - phone: undefined, - form: 'registration', - read: undefined, - valid: false, - verified: undefined, - reported_date: 200, - contact: undefined, - lineage: [], - subject: { - type: 'unknown' - }, - case_id: undefined - }, - { - _id: 'c', - _rev: '1', - from: '+123', - phone: '+456', - form: 'delivery', - read: undefined, - valid: true, - verified: true, - reported_date: 100, - contact: 'c', - lineage: [ 'd', 'e' ], - subject: { - value: 'd', - type: 'reference' - }, - case_id: undefined - }, - ]); - }); + it('only loads report summaries from the datasource', async () => { + const reportSummaries = [{ _id: 'b', form: 'delivery' }]; + getReportSummaries.returns((async function* () { + yield* reportSummaries; + })()); + + const actual = await service.getReports(['a', 'b']); + + expect(getReportSummaries.calledOnceWithExactly({ ids: ['a', 'b'] })).to.be.true; + expect(getContactSummaries.notCalled).to.be.true; + expect(actual).to.deep.equal(reportSummaries); + }); + }); + + describe('getByDocs', () => { + it('returns empty array when given no docs', () => { + expect(service.getByDocs(undefined)).to.deep.equal([]); + expect(service.getByDocs([])).to.deep.equal([]); }); - it('queries allDocs and summarises contacts', () => { - includes.returns(true); - allDocs.resolves({ - rows: [ - { - doc: { - _id: 'a', - _rev: '1', - type: 'person', - name: 'james', - phone: '+456', - contact: { - _id: 'c', - phone: '+456', - parent: { - _id: 'd', - parent: { - _id: 'e' - } - } - }, - date_of_death: 999 - } }, - { - doc: { - _id: 'b', - _rev: '2', - type: 'contact', - contact_type: 'patient', - phone: '+123', - parent: { - _id: 'f', - parent: { - _id: 'g' - } - }, - muted: true - } }, - ] }); - return service.get([ 'a', 'b' ]).then(actual => { - expect(query.callCount).to.equal(0); - expect(allDocs.callCount).to.equal(1); - expect(allDocs.args[0][0]).to.deep.equal({ keys: [ 'a', 'b' ], include_docs: true }); - expect(actual).to.deep.equal([ - { - _id: 'a', - _rev: '1', - name: 'james', - phone: '+456', - type: 'person', - contact_type: undefined, - contact: 'c', - lineage: [], - date_of_death: 999, - muted: undefined - }, - { - _id: 'b', - _rev: '2', - name: '+123', - phone: '+123', - type: 'contact', - contact_type: 'patient', - contact: undefined, - lineage: [ 'f', 'g' ], - date_of_death: undefined, - muted: true + it('summarises reports', () => { + const docs = [{ + _id: 'a', + _rev: '1', + type: 'data_record', + form: 'delivery', + from: '+123', + contact: { + _id: 'c', + phone: '+456', + parent: { + _id: 'd', + parent: { + _id: 'e' + } } - ]); - }); + }, + verified: true, + reported_date: 100, + fields: { + patient_name: 'jeff', + patient_id: 'f' + } + }]; + + const actual = service.getByDocs(docs); + + expect(actual).to.deep.equal([{ + _id: 'a', + _rev: '1', + from: '+123', + phone: '+456', + form: 'delivery', + read: undefined, + valid: true, + verified: true, + reported_date: 100, + contact: 'c', + lineage: ['d', 'e'], + subject: { + name: 'jeff', + value: 'f', + type: 'reference' + }, + case_id: undefined, + }]); }); - it('queries allDocs and ignores other types', () => { - includes.returns(false); - allDocs.resolves({ - rows: [ - { - doc: { - type: 'form' - } } - ] }); - return service.get([ 'a', 'b' ]).then(actual => { - expect(actual).to.deep.equal([]); - }); + it('summarises contacts', () => { + const docs = [{ + _id: 'a', + _rev: '1', + type: 'person', + name: 'james', + phone: '+456', + contact: { + _id: 'c' + }, + date_of_death: 999 + }]; + + const actual = service.getByDocs(docs); + + expect(actual).to.deep.equal([{ + _id: 'a', + _rev: '1', + name: 'james', + phone: '+456', + type: 'person', + contact_type: undefined, + contact: 'c', + lineage: [], + date_of_death: 999, + muted: undefined, + }]); }); - it('queries allDocs and ignores docs that are missing', () => { - includes.returns(false); - allDocs.resolves({ rows: [ { key: 'a', error: 'not_found' } ] }); - return service.get([ 'a' ]).then(actual => { - expect(actual).to.deep.equal([]); - }); + it('ignores other doc types', () => { + const docs = [{ type: 'form' }]; + expect(service.getByDocs(docs)).to.deep.equal([]); }); }); }); diff --git a/webapp/tests/karma/ts/services/hydrate-contact-names.service.spec.ts b/webapp/tests/karma/ts/services/hydrate-contact-names.service.spec.ts index 1edbfce761a..16bd048c2e4 100644 --- a/webapp/tests/karma/ts/services/hydrate-contact-names.service.spec.ts +++ b/webapp/tests/karma/ts/services/hydrate-contact-names.service.spec.ts @@ -13,7 +13,7 @@ describe('HydrateContactNames service', () => { GetSummaries = sinon.stub(); TestBed.configureTestingModule({ providers: [ - { provide: GetSummariesService, useValue: { get: GetSummaries } }, + { provide: GetSummariesService, useValue: { getContacts: GetSummaries } }, ] }); diff --git a/webapp/tests/karma/ts/services/message-contact.service.spec.ts b/webapp/tests/karma/ts/services/message-contact.service.spec.ts index 71c4a23987d..9d77ba12b6e 100644 --- a/webapp/tests/karma/ts/services/message-contact.service.spec.ts +++ b/webapp/tests/karma/ts/services/message-contact.service.spec.ts @@ -17,7 +17,7 @@ describe('Message Contacts Service', () => { beforeEach(() => { dbService = { get: () => ({ query: sinon.stub() }) }; - getDataRecordsService = { get: sinon.stub() }; + getDataRecordsService = { getReports: sinon.stub() }; hydrateMessagesService = { hydrate: sinon.stub() }; addReadStatusService = { updateMessages: sinon.stub().returnsArg(0) }; @@ -55,7 +55,7 @@ describe('Message Contacts Service', () => { query: sinon.stub().resolves(dbRows), allDocs: sinon.stub().resolves(dbRows) }); - getDataRecordsService.get.resolves([ + getDataRecordsService.getReports.resolves([ { _id: 'id1' }, { _id: 'id2' }, { _id: 'id3' }, @@ -72,8 +72,8 @@ describe('Message Contacts Service', () => { .getList() .then(list => { expect(addReadStatusService.updateMessages.callCount).to.equal(1); - expect(getDataRecordsService.get.callCount).to.equal(1); - expect(getDataRecordsService.get.args[0]).to.deep.equal([ + expect(getDataRecordsService.getReports.callCount).to.equal(1); + expect(getDataRecordsService.getReports.args[0]).to.deep.equal([ ['id1', 'id2', 'id3', 'id4'], { include_docs: true } ]); expect(hydrateMessagesService.hydrate.callCount).to.equal(1); @@ -144,7 +144,7 @@ describe('Message Contacts Service', () => { .then(result => { expect(query.args[0][1]).to.deep.equal(expectedQueryParams); expect(addReadStatusService.updateMessages.callCount).to.equal(1); - expect(getDataRecordsService.get.callCount).to.equal(0); + expect(getDataRecordsService.getReports.callCount).to.equal(0); expect(hydrateMessagesService.hydrate.callCount).to.equal(1); expect(hydrateMessagesService.hydrate.args[0]).to.deep.equal([[ { id: 'some_id1', value: { id: 'id1' }, doc: { _id: 'some_id1' } }, @@ -182,7 +182,7 @@ describe('Message Contacts Service', () => { .getConversation('abc', 45) .then(result => { expect(query.args[0][1]).to.deep.equal(expectedQueryParams); - expect(getDataRecordsService.get.callCount).to.deep.equal(0); + expect(getDataRecordsService.getReports.callCount).to.deep.equal(0); expect(hydrateMessagesService.hydrate.callCount).to.equal(1); expect(hydrateMessagesService.hydrate.args[0]).to.deep.equal([[]]); expect(result).to.deep.equal([]); @@ -210,7 +210,7 @@ describe('Message Contacts Service', () => { .getConversation('abc', 45, 120) .then(result => { expect(query.args[0][1]).to.deep.equal(expectedQueryParams); - expect(getDataRecordsService.get.callCount).to.deep.equal(0); + expect(getDataRecordsService.getReports.callCount).to.deep.equal(0); expect(hydrateMessagesService.hydrate.callCount).to.equal(1); expect(hydrateMessagesService.hydrate.args[0]).to.deep.equal([[]]); expect(result).to.deep.equal([]); @@ -238,7 +238,7 @@ describe('Message Contacts Service', () => { .getConversation('abc', 45, 45) .then(result => { expect(query.args[0][1]).to.deep.equal(expectedQueryParams); - expect(getDataRecordsService.get.callCount).to.deep.equal(0); + expect(getDataRecordsService.getReports.callCount).to.deep.equal(0); expect(hydrateMessagesService.hydrate.callCount).to.equal(1); expect(hydrateMessagesService.hydrate.args[0]).to.deep.equal([[]]); expect(result).to.deep.equal([]); diff --git a/webapp/tests/karma/ts/services/report-view-model-generator.service.spec.ts b/webapp/tests/karma/ts/services/report-view-model-generator.service.spec.ts index 00c6b93d156..a81d66613d4 100644 --- a/webapp/tests/karma/ts/services/report-view-model-generator.service.spec.ts +++ b/webapp/tests/karma/ts/services/report-view-model-generator.service.spec.ts @@ -19,7 +19,7 @@ describe('ReportViewModelGeneratorService Service', () => { beforeEach(() => { formatDataRecordService = { format: sinon.stub() }; getSubjectSummariesService = { get: sinon.stub() }; - getSummariesService = { get: sinon.stub() }; + getSummariesService = { getReports: sinon.stub() }; lineageModelGeneratorService = { report: sinon.stub() }; TestBed.configureTestingModule({ providers: [ @@ -58,7 +58,7 @@ describe('ReportViewModelGeneratorService Service', () => { lineageModelGeneratorService.report.resolves({ doc: report }); formatDataRecordService.format.resolves({ formatted1: 1, formatted2: 2 }); getSubjectSummariesService.get.resolves([{ summary: true, subject: 'subject' }]); - getSummariesService.get.resolves([{ summary: true }]); + getSummariesService.getReports.resolves([{ summary: true }]); return service.get(report._id).then(result => { expect(lineageModelGeneratorService.report.callCount).to.equal(1); @@ -67,8 +67,8 @@ describe('ReportViewModelGeneratorService Service', () => { expect(formatDataRecordService.format.callCount).to.equal(1); expect(formatDataRecordService.format.args[0]).to.deep.equal([report]); - expect(getSummariesService.get.callCount).to.equal(1); - expect(getSummariesService.get.args[0]).to.deep.equal([[ 'my-report' ]]); + expect(getSummariesService.getReports.callCount).to.equal(1); + expect(getSummariesService.getReports.args[0]).to.deep.equal([[ 'my-report' ]]); expect(getSubjectSummariesService.get.callCount).to.equal(1); expect(getSubjectSummariesService.get.args[0]).to.deep.equal([[{ summary: true }], true]); diff --git a/webapp/tests/karma/ts/services/search.service.spec.ts b/webapp/tests/karma/ts/services/search.service.spec.ts index 48558f57bb3..1cf15321237 100644 --- a/webapp/tests/karma/ts/services/search.service.spec.ts +++ b/webapp/tests/karma/ts/services/search.service.spec.ts @@ -34,7 +34,7 @@ describe('Search service', () => { providers: [ { provide: CHTDatasourceService, useValue: { } }, { provide: DbService, useValue: { get: () => db } }, - { provide: GetDataRecordsService, useValue: { get: GetDataRecords } }, + { provide: GetDataRecordsService, useValue: { getContacts: GetDataRecords, getReports: GetDataRecords } }, { provide: SessionService, useValue: session }, { provide: SearchFactoryService, useValue: { get: sinon.stub().resolves(searchStub) } }, { provide: PerformanceService, useValue: performanceService }, diff --git a/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts b/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts index 7ebd1102136..97732934dc1 100644 --- a/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts +++ b/webapp/tests/karma/ts/services/target-aggregates.service.spec.ts @@ -46,7 +46,7 @@ describe('TargetAggregatesService', () => { getPlaceChildTypes: sinon.stub(), isPerson: sinon.stub(), }; - getDataRecordsService = {get: sinon.stub()}; + getDataRecordsService = {getContacts: sinon.stub()}; searchService = {search: sinon.stub()}; userSettingsService = { get: sinon.stub(), @@ -186,7 +186,7 @@ describe('TargetAggregatesService', () => { }] } } }); userSettingsService.get.resolves({ facility_id: 'home' }); - getDataRecordsService.get.withArgs([ 'home' ]).resolves(); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves(); return service .getAggregates() @@ -213,8 +213,8 @@ describe('TargetAggregatesService', () => { }] } } }); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }, { id: 'type2' }, { id: 'type3' }]); searchService.search.resolves([]); @@ -225,9 +225,9 @@ describe('TargetAggregatesService', () => { expect(result[0].id).to.equal('target'); expect(settingsService.get.callCount).to.equal(1); expect(userSettingsService.getUserFacilities.callCount).to.equal(1); - expect(getDataRecordsService.get.callCount).to.equal(2); - expect(getDataRecordsService.get.args[0]).to.deep.equal([[ 'home' ]]); - expect(getDataRecordsService.get.args[1]).to.deep.equal([[]]); + expect(getDataRecordsService.getContacts.callCount).to.equal(2); + expect(getDataRecordsService.getContacts.args[0]).to.deep.equal([[ 'home' ]]); + expect(getDataRecordsService.getContacts.args[1]).to.deep.equal([[]]); expect(contactTypesService.getTypeId.callCount).to.equal(1); expect(contactTypesService.getTypeId.args[0]).to.deep.equal([{ _id: 'home' }]); expect(contactTypesService.getPlaceChildTypes.callCount).to.equal(1); @@ -249,8 +249,8 @@ describe('TargetAggregatesService', () => { }] } } }); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }, { id: 'type2' }, { id: 'type3' }]); @@ -291,7 +291,7 @@ describe('TargetAggregatesService', () => { tasks: { targets: { items: [{ id: 'target', aggregate: true, type: 'count' }] } } }); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }, { id: 'type2' }]); @@ -300,9 +300,9 @@ describe('TargetAggregatesService', () => { searchService.search.onCall(0).resolves(places.slice(0, 100)); searchService.search.onCall(1).resolves(places.slice(100, 200)); searchService.search.onCall(2).resolves(places.slice(200, 300)); - getDataRecordsService.get.onCall(1).resolves(contacts.slice(0, 100)); - getDataRecordsService.get.onCall(2).resolves(contacts.slice(100, 200)); - getDataRecordsService.get.onCall(3).resolves(contacts.slice(200, 300)); + getDataRecordsService.getContacts.onCall(1).resolves(contacts.slice(0, 100)); + getDataRecordsService.getContacts.onCall(2).resolves(contacts.slice(100, 200)); + getDataRecordsService.getContacts.onCall(3).resolves(contacts.slice(200, 300)); const targetDocs = contacts.map(contact => ({ owner: contact._id, @@ -340,11 +340,14 @@ describe('TargetAggregatesService', () => { { limit: 100, skip: 200 }, ]); - expect(getDataRecordsService.get.callCount).to.equal(4); - expect(getDataRecordsService.get.args[0]).to.deep.equal([[ 'home' ]]); - expect(getDataRecordsService.get.args[1]).to.deep.equal([places.slice(0, 100).map(place => place.contact)]); - expect(getDataRecordsService.get.args[2]).to.deep.equal([places.slice(100, 200).map(place => place.contact)]); - expect(getDataRecordsService.get.args[3]).to.deep.equal([places.slice(200, 300).map(place => place.contact)]); + expect(getDataRecordsService.getContacts.callCount).to.equal(4); + expect(getDataRecordsService.getContacts.args[0]).to.deep.equal([[ 'home' ]]); + expect(getDataRecordsService.getContacts.args[1]) + .to.deep.equal([places.slice(0, 100).map(place => place.contact)]); + expect(getDataRecordsService.getContacts.args[2]) + .to.deep.equal([places.slice(100, 200).map(place => place.contact)]); + expect(getDataRecordsService.getContacts.args[3]) + .to.deep.equal([places.slice(200, 300).map(place => place.contact)]); expect(getTargets.args).to.deep.equal([[Qualifier.and( baseReportingPeriodQualifier, byContactIds(contacts.map(({ _id }) => _id) as [string, ...string[]]) @@ -354,7 +357,7 @@ describe('TargetAggregatesService', () => { it('should discard contacts that are not under the parent place or that have no contact', async () => { const genContact = (id) => ({ _id: id, name: randomString() }); const contacts: { _id: any; name: string }[] = []; - getDataRecordsService.get.callsFake(contactIds => { + getDataRecordsService.getContacts.callsFake(contactIds => { const responseContacts = contactIds.map(genContact); contacts.push(...responseContacts); return Promise.resolve(responseContacts); @@ -363,7 +366,7 @@ describe('TargetAggregatesService', () => { tasks: { targets: { items: [{ id: 'target', aggregate: true, type: 'count' }] } } }); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); @@ -416,15 +419,15 @@ describe('TargetAggregatesService', () => { { limit: 100, skip: 200 }, ]); - expect(getDataRecordsService.get.callCount).to.equal(4); - expect(getDataRecordsService.get.args[0]).to.deep.equal([[ 'home' ]]); - expect(getDataRecordsService.get.args[1]).to.deep.equal([ + expect(getDataRecordsService.getContacts.callCount).to.equal(4); + expect(getDataRecordsService.getContacts.args[0]).to.deep.equal([[ 'home' ]]); + expect(getDataRecordsService.getContacts.args[1]).to.deep.equal([ places.slice(0, 100).filter(isRelevantPlace).map(place => place.contact) ]); - expect(getDataRecordsService.get.args[2]).to.deep.equal([ + expect(getDataRecordsService.getContacts.args[2]).to.deep.equal([ places.slice(100, 200).filter(isRelevantPlace).map(place => place.contact) ]); - expect(getDataRecordsService.get.args[3]).to.deep.equal([ + expect(getDataRecordsService.getContacts.args[3]).to.deep.equal([ places.slice(200, 300).filter(isRelevantPlace).map(place => place.contact) ]); expect(getTargets.args).to.deep.equal([[Qualifier.and( @@ -438,8 +441,8 @@ describe('TargetAggregatesService', () => { tasks: { targets: { items: [{ id: 'target', aggregate: true, type: 'count' }] } } }); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([ { id: 'type1' }, { id: 'type2' } ]); searchService.search.resolves([]); @@ -455,9 +458,9 @@ describe('TargetAggregatesService', () => { { types: { selected: ['type1', 'type2'] } }, { limit: 100, skip: 0 }, ]); - expect(getDataRecordsService.get.callCount).to.equal(2); - expect(getDataRecordsService.get.args[0]).to.deep.equal([[ 'home' ]]); - expect(getDataRecordsService.get.args[1]).to.deep.equal([[]]); + expect(getDataRecordsService.getContacts.callCount).to.equal(2); + expect(getDataRecordsService.getContacts.args[0]).to.deep.equal([[ 'home' ]]); + expect(getDataRecordsService.getContacts.args[1]).to.deep.equal([[]]); }); it('should not run search is there are no available types', async () => { @@ -465,7 +468,7 @@ describe('TargetAggregatesService', () => { tasks: { targets: { items: [{ id: 'target', aggregate: true, type: 'count' }] } } }); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([]); @@ -475,8 +478,8 @@ describe('TargetAggregatesService', () => { expect(result[0].id).to.equal('target'); expect(result[0].values.length).to.equal(0); expect(searchService.search.callCount).to.equal(0); - expect(getDataRecordsService.get.callCount).to.equal(1); - expect(getDataRecordsService.get.args[0]).to.deep.equal([[ 'home' ]]); + expect(getDataRecordsService.getContacts.callCount).to.equal(1); + expect(getDataRecordsService.getContacts.args[0]).to.deep.equal([[ 'home' ]]); }); it('should call getAggregates with provided facilityId when fetching aggregates', async () => { @@ -485,8 +488,8 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([facilityId]).resolves([{ _id: facilityId, name: 'Test Facility' }]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([facilityId]).resolves([{ _id: facilityId, name: 'Test Facility' }]); contactTypesService.getTypeId.returns('district'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'health_center' }]); searchService.search.resolves([]); @@ -496,8 +499,8 @@ describe('TargetAggregatesService', () => { expect(result.length).to.equal(1); expect(result[0].id).to.equal('target'); - expect(getDataRecordsService.get.callCount).to.equal(2); - expect(getDataRecordsService.get.args[0]).to.deep.equal([[facilityId]]); + expect(getDataRecordsService.getContacts.callCount).to.equal(2); + expect(getDataRecordsService.getContacts.args[0]).to.deep.equal([[facilityId]]); expect(getTargets.notCalled).to.be.true; expect(rulesEngineService.getTargetIntervalTag.calledOnceWithExactly(config, ReportingPeriod.CURRENT)).to.be.true; }); @@ -507,8 +510,8 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); searchService.search.resolves([]); @@ -530,8 +533,8 @@ describe('TargetAggregatesService', () => { rulesEngineService.getTargetIntervalTag.returns('2019-07'); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([facilityId]).resolves([{ _id: facilityId, name: 'Test Facility' }]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([facilityId]).resolves([{ _id: facilityId, name: 'Test Facility' }]); contactTypesService.getTypeId.returns('district'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'health_center' }]); searchService.search.resolves([]); @@ -559,8 +562,8 @@ describe('TargetAggregatesService', () => { translateService.instant = sinon.stub().returnsArg(0); settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'home', name: 'Facility 1' }]); - getDataRecordsService.get.resolves([]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.resolves([]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); searchService.search.resolves([]); @@ -680,10 +683,10 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); - getDataRecordsService.get.withArgs(sinon.match.array).resolves(contacts); + getDataRecordsService.getContacts.withArgs(sinon.match.array).resolves(contacts); searchService.search.resolves([]); getTargets.returns(fakeGenerator(targetDocs)); translateService.instant = sinon.stub().returnsArg(0); @@ -828,10 +831,10 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); - getDataRecordsService.get.withArgs(sinon.match.array).resolves(contacts); + getDataRecordsService.getContacts.withArgs(sinon.match.array).resolves(contacts); searchService.search.resolves([]); getTargets.returns(fakeGenerator(targetDocs)); @@ -891,10 +894,10 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); - getDataRecordsService.get.withArgs(sinon.match.array).resolves(contacts); + getDataRecordsService.getContacts.withArgs(sinon.match.array).resolves(contacts); searchService.search.resolves([]); getTargets.returns(fakeGenerator(targetDocs)); @@ -979,10 +982,10 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); - getDataRecordsService.get.withArgs(sinon.match.array).resolves(contacts); + getDataRecordsService.getContacts.withArgs(sinon.match.array).resolves(contacts); searchService.search.resolves([]); getTargets.returns(fakeGenerator(targetDocs)); @@ -1061,10 +1064,10 @@ describe('TargetAggregatesService', () => { settingsService.get.resolves(config); userSettingsService.getUserFacilities.resolves([{ _id: 'facility-1', name: 'Facility 1' }]); - getDataRecordsService.get.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); + getDataRecordsService.getContacts.withArgs([ 'home' ]).resolves([ { _id: 'home' } ]); contactTypesService.getTypeId.returns('home_type'); contactTypesService.getPlaceChildTypes.resolves([{ id: 'type1' }]); - getDataRecordsService.get.withArgs(sinon.match.array).resolves(contacts); + getDataRecordsService.getContacts.withArgs(sinon.match.array).resolves(contacts); searchService.search.resolves([]); getTargets.returns(fakeGenerator(targetDocs)); diff --git a/webapp/tests/karma/ts/services/user-settings.service.spec.ts b/webapp/tests/karma/ts/services/user-settings.service.spec.ts index 9ce71be84e1..176d82383c5 100644 --- a/webapp/tests/karma/ts/services/user-settings.service.spec.ts +++ b/webapp/tests/karma/ts/services/user-settings.service.spec.ts @@ -31,7 +31,7 @@ describe('UserSettings service', () => { { provide: LanguageService, useValue: { get: getLanguage } }, { provide: SessionService, useValue: { userCtx } }, { provide: DbService, useValue: { get: () => ({ get }) } }, - { provide: GetDataRecordsService, useValue: { get: getDataRecords } } + { provide: GetDataRecordsService, useValue: { getContacts: getDataRecords } } ], }); service = TestBed.inject(UserSettingsService); diff --git a/webapp/tests/mocha/unit/views/doc_summaries_by_id.spec.js b/webapp/tests/mocha/unit/views/doc_summaries_by_id.spec.js deleted file mode 100644 index 72be759a388..00000000000 --- a/webapp/tests/mocha/unit/views/doc_summaries_by_id.spec.js +++ /dev/null @@ -1,576 +0,0 @@ -const assert = require('chai').assert; -const utils = require('./utils'); -const { DOC_TYPES } = require('@medic/constants'); - -const person = { - _id: '2bba279f-8ad9-4823-be69-a8eb09879402', - _rev: '3-b99ee0615633d44f414362c8bf21454a', - parent: { - _id: '1a1aac55-04d6-40dc-aae2-e67a75a1496d' - }, - type: 'person', - name: 'patient1', - date_of_birth: '', - phone: '', - phone_alternate: '', - sex: 'female', - role: 'patient', - external_id: '99999', - notes: '', - reported_date: 1517420299278, - patient_id: '47806', -}; -const personBis = Object.assign({}, person, { - _id: '2bba279f-8ad9-4823-be69-a8eb09879402-bis', - date_of_death: 10, - type: 'contact', - contact_type: 'patient', - name: '', - phone: '0123456789', - muted: true -}); - -const householdVisit = { - _id: '5294b4c0-7499-41d5-b8d9-c548381799c0', - _rev: '2-25a86f61d544f9254b6c738ca6f644ad', - form: 'household_visit', - type: DOC_TYPES.DATA_RECORD, - verified: true, - content_type: 'xml', - reported_date: 1517418915669, - contact: { - _id: 'df28f38e-cd3c-475f-96b5-48080d863e34', - parent: { - _id: '1a1aac55-04d6-40dc-aae2-e67a75a1496d' - } - }, - from: '+111232543221', - fields: { - place_id: '09f62048-ac69-4066-bf8b-bcaf534ef8b1', - place_name: 'some area', - meta: { - instanceID: 'uuid:e950c9eb-2650-42f4-b75d-72da2a20fba1' - } - } -}; - -const householdVisitBis = Object.assign({}, householdVisit, { - _id: '5294b4c0-7499-41d5-b8d9-c548381799c0-bis', - errors: [{ - code: 'sys.missing_fields', - fields: ['place_id'] - }], - fields: Object.assign({}, householdVisit.fields, { - place_id: null - }), - verified: false -}); - -const postNatalVisit = { - _id: '4971a859-bde7-4ff0-a0ed-326925b83038', - _rev: '1-daf9f65652fbe6da38911d3ffd6c1d77', - form: 'postnatal_visit', - type: DOC_TYPES.DATA_RECORD, - content_type: 'xml', - case_id: '12345', - reported_date: 1517392010413, - contact: { - _id: 'df28f38e-cd3c-475f-96b5-48080d863e34', - parent: { - _id: '1a1aac55-04d6-40dc-aae2-e67a75a1496d' - } - }, - from: '', - fields: { - patient_age_in_years: '25', - patient_phone: '', - patient_uuid: 'a29c933c-90cb-4cb0-9e25-36403499aee4', - patient_id: 'a29c933c-90cb-4cb0-9e25-36403499aee4', - patient_name: 'mother', - meta: { - instanceID: 'uuid:a53c23dc-eedb-433c-a81d-30c495ce7602' - } - }, - verified: true -}; - -const postNatalVisitBis = Object.assign({}, postNatalVisit, { - _id: '4971a859-bde7-4ff0-a0ed-326925b83038-bis', - fields: Object.assign({}, postNatalVisit.fields, { - patient_id: null, - patient_uuid: null, - }) -}); - - -const postNatalVisitPatientIdNoUuid = Object.assign({}, postNatalVisit, { - _id: '4971a859-bde7-4ff0-a0ed-326925b83038-idnouuid', - fields: Object.assign({}, postNatalVisit.fields, { - patient_id: 'a29c933c-90cb-4cb0-9e25-36403499aee6', - patient_uuid: null, - }) -}); - -const postNatalVisitPatientUuidNoId = Object.assign({}, postNatalVisit, { - _id: '4971a859-bde7-4ff0-a0ed-326925b83038-uuidnoid', - fields: Object.assign({}, postNatalVisit.fields, { - patient_id: null, - patient_uuid: 'a29c933c-90cb-4cb0-9e25-36403499aee7', - }) -}); - -const jsonR = { - _id: '60f2df4791ea8f83b531cdcf30003abe', - _rev: '2-2fcc401c60fc33f91842482f0931fc27', - type: DOC_TYPES.DATA_RECORD, - from: '+13125551212', - form: 'R', - errors: [], - tasks: [], - fields: { - patient_name: 'test' - }, - reported_date: 1517405737096, -}; - -const jsonRBis = Object.assign({}, jsonR, { - _id: '60f2df4791ea8f83b531cdcf30003abe-bis', - errors: [{ - code: 'sys.missing_fields', - fields: ['patient_name'] - }], - fields: Object.assign({}, jsonR.fields, { - patient_name: null - }) -}); - -const jsonOth = { - _id: '60f2df4791ea8f83b531cdcf3007fffa', - _rev: '2-6a2f4afb456e70db09c2bb8348b61267', - type: DOC_TYPES.DATA_RECORD, - from: '+13125551212', - form: 'OTH', - errors: [true], - tasks: [], - fields: {}, - reported_date: 1517491485049, -}; - -const communityEvent = { - _id: 'e3f70ed4-7875-41ab-86f4-0808beb0fceb', - _rev: '2-5ad6ee169ca8a5a0b21b504bbd65a85a', - form: 'community_event', - type: DOC_TYPES.DATA_RECORD, - content_type: 'xml', - reported_date: 1517495666367, - contact: { - _id: 'df28f38e-cd3c-475f-96b5-48080d863e34', - parent: { - _id: '1a1aac55-04d6-40dc-aae2-e67a75a1496d' - } - }, - from: '+13125551212', - hidden_fields: [], - fields: { - community_event_info: { - event_date: '2018-01-16', - no_of_attendees: '13', - subject: 'newborn', - notes: '' - }, - meta: { - instanceID: 'uuid:90ed62fc-66a7-4ab6-a8f2-a44060fbcb2d' - } - } -}; - -const jsonD = { - _id: '60f2df4791ea8f83b531cdcf3000c44a', - _rev: '2-b515aeb6076ef05b474a9b15bbeb1106', - type: DOC_TYPES.DATA_RECORD, - from: '+13125551212', - form: 'D', - fields: { - patient_id: '22323', - delivery_code: 'F', - notes: 'note' - }, - reported_date: 1517408179956, -}; - -const jsonDBis = Object.assign({}, jsonD, { - _id: '60f2df4791ea8f83b531cdcf3000c44a-bis', - errors: [{ - code: 'sys.missing_fields', - fields: ['patient_id'] - }], - fields: Object.assign({}, jsonD.fields, { - patient_id: null, - }) -}); - -const jsonHousehold = { - _id: '5294b4c0-7499-41d5-b8d9-c548381799c0', - _rev: '2-25a86f61d544f9254b6c738ca6f644ad', - type: DOC_TYPES.DATA_RECORD, - from: '+13125551212', - form: 'H', - fields: { - delivery_code: 'F', - notes: 'note' - }, - place_id: '111111', - reported_date: 1517408179956, -}; - -const jsonHouseholdBis = Object.assign({}, jsonHousehold, { - _id: '5294b4c0-7499-41d5-b8d9-c548381799c0-bis', - errors: [{ - code: 'sys.missing_fields', - fields: ['place_id'] - }], - place_id: null -}); - -describe('doc_summaries_by_id view', () => { - it('indexes name, phone, type, contact, lineage, dod for non-data-records', () => { - const map = utils.loadView('medic-db', 'medic', 'doc_summaries_by_id'); - - const emitted = map(person, true) && map(personBis, true); - assert.deepEqual(emitted[0], { - key: '2bba279f-8ad9-4823-be69-a8eb09879402', - value: { - _rev: '3-b99ee0615633d44f414362c8bf21454a', - name: 'patient1', - phone: '', - type: 'person', - contact_type: undefined, - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - date_of_death: undefined, - contact: undefined, - muted: undefined - } - }); - assert.deepEqual(emitted[1], { - key: '2bba279f-8ad9-4823-be69-a8eb09879402-bis', - value: { - _rev: '3-b99ee0615633d44f414362c8bf21454a', - name: '0123456789', - phone: '0123456789', - type: 'contact', - contact_type: 'patient', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - date_of_death: 10, - contact: undefined, - muted: true - } - }); - }); - - it('indexes data-records summary and subject', () => { - const map = utils.loadView('medic-db', 'medic', 'doc_summaries_by_id'); - - const reportsList = [ - householdVisit, - householdVisitBis, - postNatalVisit, - postNatalVisitBis, - jsonR, - jsonRBis, - jsonOth, - communityEvent, - jsonD, - jsonDBis, - jsonHousehold, - jsonHouseholdBis, - postNatalVisitPatientIdNoUuid, - postNatalVisitPatientUuidNoId, - ]; - - let emitted = true; - reportsList.forEach(report => { - emitted = emitted && map(report, true); - }); - - assert.deepEqual(emitted[0], { - key: '5294b4c0-7499-41d5-b8d9-c548381799c0', - value: { - _rev: '2-25a86f61d544f9254b6c738ca6f644ad', - from: '+111232543221', - phone: undefined, - form: 'household_visit', - read: undefined, - valid: true, - verified: true, - reported_date: 1517418915669, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: { - type: 'reference', - value: '09f62048-ac69-4066-bf8b-bcaf534ef8b1' - }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[1], { - key: '5294b4c0-7499-41d5-b8d9-c548381799c0-bis', - value: { - _rev: '2-25a86f61d544f9254b6c738ca6f644ad', - from: '+111232543221', - phone: undefined, - form: 'household_visit', - read: undefined, - valid: false, - verified: false, - reported_date: 1517418915669, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: { type: 'unknown'}, - case_id: undefined - } - }); - - assert.deepEqual(emitted[2], { - key: '4971a859-bde7-4ff0-a0ed-326925b83038', - value: { - _rev: '1-daf9f65652fbe6da38911d3ffd6c1d77', - from: undefined, - phone: undefined, - form: 'postnatal_visit', - read: undefined, - valid: true, - verified: true, - reported_date: 1517392010413, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: { - name: 'mother', - type: 'reference', - value: 'a29c933c-90cb-4cb0-9e25-36403499aee4' - }, - case_id: '12345' - } - }); - - assert.deepEqual(emitted[3], { - key: '4971a859-bde7-4ff0-a0ed-326925b83038-bis', - value: { - _rev: '1-daf9f65652fbe6da38911d3ffd6c1d77', - from: undefined, - phone: undefined, - form: 'postnatal_visit', - read: undefined, - valid: true, - verified: true, - reported_date: 1517392010413, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: { - name: 'mother', - type: 'name', - value: 'mother' - }, - case_id: '12345' - } - }); - - assert.deepEqual(emitted[4], { - key: '60f2df4791ea8f83b531cdcf30003abe', - value: { - _rev: '2-2fcc401c60fc33f91842482f0931fc27', - from: '+13125551212', - phone: undefined, - form: 'R', - read: undefined, - valid: true, - verified: undefined, - reported_date: 1517405737096, - contact: undefined, - lineage: [], - subject: { - name: 'test', - type: 'name', - value: 'test' - }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[5], { - key: '60f2df4791ea8f83b531cdcf30003abe-bis', - value: { - _rev: '2-2fcc401c60fc33f91842482f0931fc27', - from: '+13125551212', - phone: undefined, - form: 'R', - read: undefined, - valid: false, - verified: undefined, - reported_date: 1517405737096, - contact: undefined, - lineage: [], - subject: { type: 'unknown' }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[6], { - key: '60f2df4791ea8f83b531cdcf3007fffa', - value: { - _rev: '2-6a2f4afb456e70db09c2bb8348b61267', - from: '+13125551212', - phone: undefined, - form: 'OTH', - read: undefined, - valid: false, - verified: undefined, - reported_date: 1517491485049, - contact: undefined, - lineage: [], - subject: { }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[7], { - key: 'e3f70ed4-7875-41ab-86f4-0808beb0fceb', - value: { - _rev: '2-5ad6ee169ca8a5a0b21b504bbd65a85a', - from: '+13125551212', - phone: undefined, - form: 'community_event', - read: undefined, - valid: true, - verified: undefined, - reported_date: 1517495666367, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: {}, - case_id: undefined - } - }); - - assert.deepEqual(emitted[8], { - key: '60f2df4791ea8f83b531cdcf3000c44a', - value: { - _rev: '2-b515aeb6076ef05b474a9b15bbeb1106', - from: '+13125551212', - phone: undefined, - form: 'D', - read: undefined, - valid: true, - verified: undefined, - reported_date: 1517408179956, - contact: undefined, - lineage: [], - subject: { - type: 'reference', - value: '22323' - }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[9], { - key: '60f2df4791ea8f83b531cdcf3000c44a-bis', - value: { - _rev: '2-b515aeb6076ef05b474a9b15bbeb1106', - from: '+13125551212', - phone: undefined, - form: 'D', - read: undefined, - valid: false, - verified: undefined, - reported_date: 1517408179956, - contact: undefined, - lineage: [], - subject: { type: 'unknown' }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[10], { - key: '5294b4c0-7499-41d5-b8d9-c548381799c0', - value: { - _rev: '2-25a86f61d544f9254b6c738ca6f644ad', - from: '+13125551212', - phone: undefined, - form: 'H', - read: undefined, - valid: true, - verified: undefined, - reported_date: 1517408179956, - contact: undefined, - lineage: [], - subject: { - type: 'reference', - value: '111111' - }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[11], { - key: '5294b4c0-7499-41d5-b8d9-c548381799c0-bis', - value: { - _rev: '2-25a86f61d544f9254b6c738ca6f644ad', - from: '+13125551212', - phone: undefined, - form: 'H', - read: undefined, - valid: false, - verified: undefined, - reported_date: 1517408179956, - contact: undefined, - lineage: [], - subject: { type: 'unknown' }, - case_id: undefined - } - }); - - assert.deepEqual(emitted[12], { - key: '4971a859-bde7-4ff0-a0ed-326925b83038-idnouuid', - value: { - _rev: '1-daf9f65652fbe6da38911d3ffd6c1d77', - from: undefined, - phone: undefined, - form: 'postnatal_visit', - read: undefined, - valid: true, - verified: true, - reported_date: 1517392010413, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: { - name: 'mother', - type: 'reference', - value: 'a29c933c-90cb-4cb0-9e25-36403499aee6' - }, - case_id: '12345' - } - }); - - assert.deepEqual(emitted[13], { - key: '4971a859-bde7-4ff0-a0ed-326925b83038-uuidnoid', - value: { - _rev: '1-daf9f65652fbe6da38911d3ffd6c1d77', - from: undefined, - phone: undefined, - form: 'postnatal_visit', - read: undefined, - valid: true, - verified: true, - reported_date: 1517392010413, - contact: 'df28f38e-cd3c-475f-96b5-48080d863e34', - lineage: ['1a1aac55-04d6-40dc-aae2-e67a75a1496d'], - subject: { - name: 'mother', - type: 'reference', - value: 'a29c933c-90cb-4cb0-9e25-36403499aee7' - }, - case_id: '12345' - } - }); - }); -}); From 64a941864a1b45a8ae9c4c50df64bebb66f48597 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Thu, 18 Jun 2026 12:46:59 +0300 Subject: [PATCH 2/5] feat(#10638): only index needed doc types --- admin/src/js/controllers/display-languages.js | 7 +- .../js/controllers/display-translations.js | 7 +- admin/src/js/controllers/forms-xml.js | 9 ++- admin/src/js/controllers/users.js | 10 ++- .../controllers/display-languages.spec.js | 17 ++-- .../controllers/display-translations.spec.js | 19 +++-- .../tests/unit/controllers/forms-xml.spec.js | 2 +- api/README.md | 4 +- api/src/db-batch.js | 2 +- api/src/migrations.js | 33 ++------ api/tests/mocha/migrations.spec.js | 32 -------- .../medic-client/views/doc_by_type/map.js | 5 +- sentinel/src/config.js | 7 +- sentinel/tests/unit/config.spec.js | 18 ++--- shared-libs/constants/src/index.js | 3 +- shared-libs/user-management/src/users.js | 6 +- tests/utils/index.js | 11 ++- webapp/src/ts/services/languages.service.ts | 8 +- webapp/src/ts/services/telemetry.service.ts | 10 ++- webapp/src/ts/services/xml-forms.service.ts | 14 ++-- .../ts/services/languages.service.spec.ts | 2 +- .../ts/services/telemetry.service.spec.ts | 80 +++++++++++-------- .../ts/services/xml-forms.service.spec.ts | 8 +- 23 files changed, 154 insertions(+), 160 deletions(-) diff --git a/admin/src/js/controllers/display-languages.js b/admin/src/js/controllers/display-languages.js index 736078c39b0..1921c806180 100644 --- a/admin/src/js/controllers/display-languages.js +++ b/admin/src/js/controllers/display-languages.js @@ -2,7 +2,7 @@ const _ = require('lodash/core'); _.uniq = require('lodash/uniq'); const constants = require('@medic/constants'); const DOC_IDS = constants.DOC_IDS; -const DOC_TYPES = constants.DOC_TYPES; +const PREFIXES = constants.PREFIXES; angular.module('controllers').controller('DisplayLanguagesCtrl', function ( @@ -91,8 +91,9 @@ angular.module('controllers').controller('DisplayLanguagesCtrl', $scope.loading = true; $q .all([ - DB().query('medic-client/doc_by_type', { - key: [ DOC_TYPES.TRANSLATIONS ], + DB().allDocs({ + start_key: PREFIXES.TRANSLATIONS, + end_key: PREFIXES.TRANSLATIONS + '\ufff0', include_docs: true }), Settings() diff --git a/admin/src/js/controllers/display-translations.js b/admin/src/js/controllers/display-translations.js index 0cab384032b..57615760978 100644 --- a/admin/src/js/controllers/display-translations.js +++ b/admin/src/js/controllers/display-translations.js @@ -1,7 +1,7 @@ const _ = require('lodash/core'); _.union = require('lodash/union'); const constants = require('@medic/constants'); -const DOC_TYPES = constants.DOC_TYPES; +const PREFIXES = constants.PREFIXES; const TRANSLATION_KEYS_OPTION = { doc: {code: 'keys', name: 'Translation Keys'} }; const DEFAULT_LANGUAGE = 'en'; @@ -56,8 +56,9 @@ angular.module('controllers').controller('DisplayTranslationsCtrl', const updateTranslations = function() { return DB() - .query('medic-client/doc_by_type', { - key: [ DOC_TYPES.TRANSLATIONS ], + .allDocs({ + start_key: PREFIXES.TRANSLATIONS, + end_key: PREFIXES.TRANSLATIONS + '\ufff0', include_docs: true }) .then(function(results) { diff --git a/admin/src/js/controllers/forms-xml.js b/admin/src/js/controllers/forms-xml.js index 672772c713a..75f1217ae27 100644 --- a/admin/src/js/controllers/forms-xml.js +++ b/admin/src/js/controllers/forms-xml.js @@ -1,4 +1,6 @@ const _ = require('lodash/core'); +const constants = require('@medic/constants'); +const PREFIXES = constants.PREFIXES; angular.module('controllers').controller('FormsXmlCtrl', function ( @@ -20,10 +22,11 @@ angular.module('controllers').controller('FormsXmlCtrl', const getForms = () => { const options = { include_docs: true, - key: ['form'] + start_key: PREFIXES.FORM, + end_key: PREFIXES.FORM + '\ufff0', }; return DB() - .query('medic-client/doc_by_type', options) + .allDocs(options) .then(res => res.rows.map(row => row.doc)); }; @@ -111,7 +114,7 @@ angular.module('controllers').controller('FormsXmlCtrl', const $xml = $($.parseXML(xml)); const title = getXmlTitle($xml, xml); const formId = getXmlFormId($xml, meta); - const couchId = 'form:' + formId; + const couchId = PREFIXES.FORM + formId; return DB() .get(couchId, { include_attachments: true }) .catch(err => { diff --git a/admin/src/js/controllers/users.js b/admin/src/js/controllers/users.js index a89180ede70..66bb0866d6d 100644 --- a/admin/src/js/controllers/users.js +++ b/admin/src/js/controllers/users.js @@ -1,4 +1,6 @@ const _ = require('lodash/core'); +const constants = require('@medic/constants'); +const PREFIXES = constants.PREFIXES; angular.module('controllers').controller('UsersCtrl', function ( @@ -22,8 +24,12 @@ angular.module('controllers').controller('UsersCtrl', $scope.updateList = function() { $scope.loading = true; - const params = { include_docs: true, key: ['user-settings'] }; - DB().query('medic-client/doc_by_type', params) + const params = { + include_docs: true, + start_key: PREFIXES.COUCH_USER, + end_key: PREFIXES.COUCH_USER + '\ufff0', + }; + DB().allDocs(params) .then(function(settings) { $scope.users = _.map(settings.rows, 'doc'); $scope.loading = false; diff --git a/admin/tests/unit/controllers/display-languages.spec.js b/admin/tests/unit/controllers/display-languages.spec.js index 896998f6936..0b61beed48c 100644 --- a/admin/tests/unit/controllers/display-languages.spec.js +++ b/admin/tests/unit/controllers/display-languages.spec.js @@ -20,7 +20,7 @@ describe('Display Languages controller', function() { settings = sinon.stub(); updateSettings = sinon.stub(); db = { - query: sinon.stub() + allDocs: sinon.stub() }; stubLanguages = sinon.stub(); stubLanguages.returns(Promise.resolve([ @@ -52,7 +52,7 @@ describe('Display Languages controller', function() { it('should display error when language settings are invalid', async () => { settings.resolves({}); - db.query.withArgs('medic-client/doc_by_type').resolves({ + db.allDocs.resolves({ rows: [ { id: 'messages-en', @@ -83,7 +83,7 @@ describe('Display Languages controller', function() { it('should not mutate the language object', async () => { settings.resolves({ languages: [{ locale: 'en', locale_outgoing: 'sw' }] }); - db.query.withArgs('medic-client/doc_by_type').resolves({ + db.allDocs.resolves({ rows: [ { id: 'messages-en', @@ -120,8 +120,9 @@ describe('Display Languages controller', function() { await createController(); rootScope.$digest(); - chai.expect(db.query.firstCall.args[1]).to.deep.equal({ - key: [DOC_TYPES.TRANSLATIONS], + chai.expect(db.allDocs.firstCall.args[0]).to.deep.equal({ + start_key: 'messages-', + end_key: 'messages-\ufff0', include_docs: true, }); chai.expect(scope.languagesModel.totalTranslations).to.equal(5); @@ -160,7 +161,7 @@ describe('Display Languages controller', function() { { locale: 'sw', enabled: true }, ], }); - db.query.withArgs('medic-client/doc_by_type').resolves({ + db.allDocs.resolves({ rows: [ { id: 'messages-en', @@ -205,7 +206,7 @@ describe('Display Languages controller', function() { { locale: 'sw', enabled: false }, ], }); - db.query.withArgs('medic-client/doc_by_type').resolves({ + db.allDocs.resolves({ rows: [ { id: 'messages-en', @@ -267,7 +268,7 @@ describe('Display Languages controller', function() { locale: 'en', locale_outgoing: 'sw', }); - db.query.withArgs('medic-client/doc_by_type').resolves({ + db.allDocs.resolves({ rows: [ { id: 'messages-en', diff --git a/admin/tests/unit/controllers/display-translations.spec.js b/admin/tests/unit/controllers/display-translations.spec.js index 5a531ceced4..0e606aa23d4 100644 --- a/admin/tests/unit/controllers/display-translations.spec.js +++ b/admin/tests/unit/controllers/display-translations.spec.js @@ -1,11 +1,10 @@ describe('DisplayTranslationsCtrl controller', function() { - const { DOC_TYPES } = require('@medic/constants'); 'use strict'; let rootScope; let scope; let createController; - let queryStub; + let allDocsStub; let modalStub; let logError; @@ -19,7 +18,7 @@ describe('DisplayTranslationsCtrl controller', function() { beforeEach(inject(function($rootScope, $controller) { rootScope = $rootScope; scope = $rootScope.$new(); - queryStub = sinon.stub().resolves({ rows: translationsRows }); + allDocsStub = sinon.stub().resolves({ rows: translationsRows }); modalStub = sinon.stub().resolves(); logError = sinon.stub(); @@ -27,7 +26,7 @@ describe('DisplayTranslationsCtrl controller', function() { return $controller('DisplayTranslationsCtrl', { '$log': { error: logError }, '$scope': scope, - 'DB': sinon.stub().returns({ query: queryStub }), + 'DB': sinon.stub().returns({ allDocs: allDocsStub }), 'Modal': modalStub, }); }; @@ -57,11 +56,11 @@ describe('DisplayTranslationsCtrl controller', function() { chai.expect(modelByKey.bye.lhs).to.equal('See ya'); chai.expect(modelByKey.bye.rhs).to.equal('Au revoir'); - chai.expect(queryStub.callCount).to.equal(1); - chai.expect(queryStub.firstCall.args).to.deep.equal([ - 'medic-client/doc_by_type', + chai.expect(allDocsStub.callCount).to.equal(1); + chai.expect(allDocsStub.firstCall.args).to.deep.equal([ { - key: [DOC_TYPES.TRANSLATIONS], + start_key: 'messages-', + end_key: 'messages-\ufff0', include_docs: true, } ]); @@ -115,12 +114,12 @@ describe('DisplayTranslationsCtrl controller', function() { // After modal resolves, updateTranslations should be called; our queryStub should be called again rootScope.$digest(); // Called twice: initial load + refresh after modal - chai.expect(queryStub.callCount).to.be.at.least(2); + chai.expect(allDocsStub.callCount).to.be.at.least(2); }); it('logs error when fetching fails', async () => { // make first call reject - queryStub.rejects(new Error('boom')); + allDocsStub.rejects(new Error('boom')); await createController(); await scope.setupPromise; rootScope.$digest(); diff --git a/admin/tests/unit/controllers/forms-xml.spec.js b/admin/tests/unit/controllers/forms-xml.spec.js index 003b072483e..ae45f2ef452 100644 --- a/admin/tests/unit/controllers/forms-xml.spec.js +++ b/admin/tests/unit/controllers/forms-xml.spec.js @@ -37,7 +37,7 @@ describe('FormsXmlCtrl controller', () => { scope = $rootScope.$new(); rootScope = $rootScope; db = { - query: sinon.stub().resolves({}), + allDocs: sinon.stub().resolves({}), get: sinon.stub().resolves({}), put: sinon.stub().resolves() }; diff --git a/api/README.md b/api/README.md index 0bbc63f3b32..6033bb63480 100644 --- a/api/README.md +++ b/api/README.md @@ -59,10 +59,10 @@ Place your script in the `/migrations` folder and it will get picked up by medic See [`migrations.js`](https://github.com/medic/cht-core/tree/master/api/src/migrations). -Importantly, the record of which migrations have been run is stored in the `migrations` array of an arbitrarily named document in CouchDB with the `.type` of `meta`. Because of this it can be a hard document to find, but you can get it using `curl`, and pretty print it with `jq`: +Importantly, the record of which migrations have been run is stored in the `migrations` array of a CouchDB document with the `_id` of `migration-log` and `.type` of `meta`. You can get it using `curl`, and pretty print it with `jq`: ``` -curl 'http://myadminuser:myadminpass@localhost:5984/medic/_design/medic-client/_view/doc_by_type?key=\["meta"\]&include_docs=true' | jq .rows[].doc +curl 'http://myadminuser:myadminpass@localhost:5984/medic/migration-log' | jq . ``` So, if you want to re-run a migration, delete its entry in the `migrations` list and re-run api. diff --git a/api/src/db-batch.js b/api/src/db-batch.js index f03114e8bd8..f984127ed01 100644 --- a/api/src/db-batch.js +++ b/api/src/db-batch.js @@ -47,7 +47,7 @@ const runBatch = (ddoc, view, viewParams, iteratee) => { /** * Run an operation over all documents returned from the query in batches. * - * @param {String} viewName Name of the view, eg: "medic-client/doc_by_type". + * @param {String} viewName Name of the view, eg: "medic/contacts_by_phone". * @param {Object} viewParams Parameters to pass to the view query. * `include_docs` defaults to `true` and cannot be overridden. * `startkey` and `startkey_docid` cannot be overriden. diff --git a/api/src/migrations.js b/api/src/migrations.js index 75525bfa052..45d1420d315 100644 --- a/api/src/migrations.js +++ b/api/src/migrations.js @@ -16,34 +16,13 @@ const hasRun = (log, migration) => { return log.migrations.indexOf(migration.name) !== -1; }; -const getLogWithView = () => { - const options = { - include_docs: true, - key: [MIGRATION_LOG_TYPE], - }; - return db.medic.query('medic-client/doc_by_type', options).then(result => { - return result && result.rows && result.rows[0] && result.rows[0].doc; - }); -}; - -const deleteOldLog = oldLog => { - if (oldLog) { - oldLog._deleted = true; - return db.medic.put(oldLog); - } -}; - const createMigrationLog = () => { - return getLogWithView() - .then(oldLog => { - const newLog = { - _id: MIGRATION_LOG_ID, - type: MIGRATION_LOG_TYPE, - migrations: (oldLog && oldLog.migrations) || [], - }; - return db.medic.put(newLog).then(() => deleteOldLog(oldLog)); - }) - .then(() => getLog()); + const newLog = { + _id: MIGRATION_LOG_ID, + type: MIGRATION_LOG_TYPE, + migrations: [], + }; + return db.medic.put(newLog).then(() => getLog()); }; const getLog = () => { diff --git a/api/tests/mocha/migrations.spec.js b/api/tests/mocha/migrations.spec.js index 5cd2ed8eff1..1a33b1ea811 100644 --- a/api/tests/mocha/migrations.spec.js +++ b/api/tests/mocha/migrations.spec.js @@ -170,11 +170,9 @@ describe('migrations', () => { getLog.onCall(0).returns(Promise.reject({ status: 404 })); getLog.onCall(1).resolves({ _id: DOC_IDS.MIGRATION_LOG, type: 'meta', migrations: [] }); getLog.onCall(2).resolves({ _id: DOC_IDS.MIGRATION_LOG, type: 'meta', migrations: [] }); - const query = sinon.stub(db.medic, 'query').resolves({ rows: [ ] }); sinon.stub(migrations, 'get').resolves(migration); const put = sinon.stub(db.medic, 'put').resolves({}); return migrations.run().then(() => { - chai.expect(query.callCount).to.equal(1); chai.expect(put.callCount).to.equal(2); chai.expect(put.firstCall.args[0]).to.deep.equal({ _id: DOC_IDS.MIGRATION_LOG, @@ -189,34 +187,4 @@ describe('migrations', () => { }); }); - it('migrates meta to log', () => { - const migration = [{ - name: 'xyz', - created: new Date(2015, 1, 1, 1, 0, 0, 0), - run: () => Promise.reject(new Error('should not be called!')) - }]; - const oldLog = { _id: 1, type: 'meta', migrations: [ 'xyz' ] }; - const getLog = sinon.stub(db.medic, 'get'); - getLog.onCall(0).returns(Promise.reject({ status: 404 })); - getLog.onCall(1).resolves({ _id: DOC_IDS.MIGRATION_LOG, type: 'meta', migrations: [ 'xyz' ] }); - const query = sinon.stub(db.medic, 'query').resolves({ rows: [ { doc: oldLog } ] }); - sinon.stub(migrations, 'get').resolves(migration); - const put = sinon.stub(db.medic, 'put').resolves({}); - return migrations.run().then(() => { - chai.expect(query.callCount).to.equal(1); - chai.expect(put.callCount).to.equal(2); - chai.expect(put.firstCall.args[0]).to.deep.equal({ - _id: DOC_IDS.MIGRATION_LOG, - migrations: [ 'xyz' ], - type: 'meta' - }); - chai.expect(put.secondCall.args[0]).to.deep.equal({ - _id: 1, - migrations: [ 'xyz' ], - type: 'meta', - _deleted: true - }); - }); - }); - }); diff --git a/ddocs/medic-db/medic-client/views/doc_by_type/map.js b/ddocs/medic-db/medic-client/views/doc_by_type/map.js index 24fba9490d5..1b9db011bc1 100644 --- a/ddocs/medic-db/medic-client/views/doc_by_type/map.js +++ b/ddocs/medic-db/medic-client/views/doc_by_type/map.js @@ -1,3 +1,6 @@ function(doc) { - emit([ doc.type ]); + var indexedTypes = ['form', 'user-settings']; + if (indexedTypes.indexOf(doc.type) !== -1) { + emit([ doc.type ]); + } } diff --git a/sentinel/src/config.js b/sentinel/src/config.js index 4921284be17..138cf5c604a 100644 --- a/sentinel/src/config.js +++ b/sentinel/src/config.js @@ -2,7 +2,7 @@ const _ = require('lodash'); const db = require('./db'); const logger = require('@medic/logger'); const translationUtils = require('@medic/translation-utils'); -const { DOC_IDS, DOC_TYPES, PREFIXES } = require('@medic/constants'); +const { DOC_IDS, PREFIXES } = require('@medic/constants'); const translations = {}; const DEFAULT_CONFIG = { @@ -20,11 +20,12 @@ let transitionsLib; const loadTranslations = () => { const options = { - key: [DOC_TYPES.TRANSLATIONS], + start_key: PREFIXES.TRANSLATIONS, + end_key: PREFIXES.TRANSLATIONS + '\ufff0', include_docs: true, }; return db.medic - .query('medic-client/doc_by_type', options) + .allDocs(options) .then(result => { result.rows.forEach(row => { const values = Object.assign(row.doc.generic, row.doc.custom || {}); diff --git a/sentinel/tests/unit/config.spec.js b/sentinel/tests/unit/config.spec.js index 0cec1348a8f..345784dc467 100644 --- a/sentinel/tests/unit/config.spec.js +++ b/sentinel/tests/unit/config.spec.js @@ -3,7 +3,7 @@ const sinon = require('sinon'); const db = require('../../src/db'); const translationUtils = require('@medic/translation-utils'); const transitions = require('../../src/transitions'); -const { DOC_IDS, DOC_TYPES } = require('@medic/constants'); +const { DOC_IDS } = require('@medic/constants'); const rewire = require('rewire'); const expect = chai.expect; @@ -11,7 +11,7 @@ const expect = chai.expect; describe('config', () => { beforeEach(() => { // Ensure modules are re-required with stubs - sinon.stub(db.medic, 'query').resolves({ rows: [] }); + sinon.stub(db.medic, 'allDocs').resolves({ rows: [] }); sinon.stub(db.medic, 'get').resolves({ _id: DOC_IDS.SETTINGS, settings: {} }); sinon.stub(db.medic, 'changes').returns({ on: function(event, handler) { @@ -45,11 +45,11 @@ describe('config', () => { const changes = db.medic.changes.returnValues[0]; expect(changes).to.be.an('object'); - expect(db.medic.query.calledOnce).to.be.true; - expect(db.medic.query.firstCall.args).to.deep.equal([ - 'medic-client/doc_by_type', + expect(db.medic.allDocs.calledOnce).to.be.true; + expect(db.medic.allDocs.firstCall.args).to.deep.equal([ { - key: [DOC_TYPES.TRANSLATIONS], + start_key: 'messages-', + end_key: 'messages-\ufff0', include_docs: true, } ]); @@ -67,7 +67,7 @@ describe('config', () => { it('loadTranslations populates translations store', async () => { const config = rewire('../../../sentinel/src/config'); - db.medic.query.resolves({ rows: [ + db.medic.allDocs.resolves({ rows: [ { doc: { code: 'en', generic: { a: 'A' } } }, { doc: { code: 'fr', generic: { hello: 'Bonjour' }, custom: { bye: 'Au revoir' } } }, ]}); @@ -89,11 +89,11 @@ describe('config', () => { changes.on_change({ id: DOC_IDS.SETTINGS }); // messages change triggers translations reload then initTransitionLib - db.medic.query.resetHistory(); + db.medic.allDocs.resetHistory(); changes.on_change({ id: 'messages-fr' }); // allow microtasks to flush await new Promise(res => setImmediate(res)); - expect(db.medic.query.calledOnce).to.be.true; + expect(db.medic.allDocs.calledOnce).to.be.true; }); it('initFeed error logs and exits process', async () => { diff --git a/shared-libs/constants/src/index.js b/shared-libs/constants/src/index.js index 7c694606ffb..d79660220ea 100644 --- a/shared-libs/constants/src/index.js +++ b/shared-libs/constants/src/index.js @@ -60,10 +60,11 @@ const USER_ROLES = { const DB_ADMIN_ROLES = [USER_ROLES.ADMIN, USER_ROLES.COUCHDB_ADMIN]; -// Prefixes +// Document ID prefixes used for _all_docs prefix range scans. const PREFIXES = { COUCH_USER: 'org.couchdb.user:', TRANSLATIONS: 'messages-', + FORM: 'form:', }; module.exports = { diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index d7d2f413eb5..64339790ad3 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -115,7 +115,9 @@ const queryDocs = (db, view, key) => db .query(view, { include_docs: true, key }) .then(({ rows }) => rows.map(({ doc }) => doc)); -const getAllUserSettings = () => queryDocs(db.medic, 'medic-client/doc_by_type', ['user-settings']); +const getAllUserSettings = () => db.medic + .allDocs({ include_docs: true, start_key: USER_PREFIX, end_key: USER_PREFIX + '\ufff0' }) + .then(({ rows }) => rows.map(({ doc }) => doc)); const getSettingsByIds = async (ids) => { const { rows } = await db.medic.allDocs({ keys: ids, include_docs: true }); @@ -125,7 +127,7 @@ const getSettingsByIds = async (ids) => { }; const getAllUsers = async () => db.users - .allDocs({ include_docs: true, start_key: PREFIXES.COUCH_USER, end_key: PREFIXES.COUCH_USER + '\ufff0' }) + .allDocs({ include_docs: true, start_key: USER_PREFIX, end_key: USER_PREFIX + '\ufff0' }) .then(({ rows }) => rows.map(({ doc }) => doc)); const getUsers = async (facilityId, contactId) => { diff --git a/tests/utils/index.js b/tests/utils/index.js index 41b6f3dee55..400744915b9 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -715,7 +715,10 @@ const getDefaultForms = async () => { const doc = await db.get(docName); PROTECTED_DOCS.push(...doc.forms); } catch { - const result = await db.allDocs({ startkey: 'form:', endkey: 'form:\ufff0' }); + const result = await db.allDocs({ + startkey: PREFIXES.FORM, + endkey: PREFIXES.FORM + '\ufff0', + }); const doc = { _id: docName, forms: result.rows.map(row => row.id), @@ -915,7 +918,11 @@ const createUsers = async (users, meta = false, password_change_required = false }; const getAllUserSettings = () => db - .query('medic-client/doc_by_type', { include_docs: true, key: ['user-settings'] }) + .allDocs({ + include_docs: true, + start_key: PREFIXES.COUCH_USER, + end_key: PREFIXES.COUCH_USER + '\ufff0', + }) .then(response => response.rows.map(row => row.doc)); /** diff --git a/webapp/src/ts/services/languages.service.ts b/webapp/src/ts/services/languages.service.ts index 3ab16335984..f344a1fb609 100644 --- a/webapp/src/ts/services/languages.service.ts +++ b/webapp/src/ts/services/languages.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; +import { PREFIXES } from '@medic/constants'; import { DbService } from '@mm-services/db.service'; import { SettingsService } from '@mm-services/settings.service'; -import { DOC_TYPES } from '@medic/constants'; @Injectable({ providedIn: 'root' @@ -22,7 +22,11 @@ export class LanguagesService { const result = await this.dbService .get() - .query('medic-client/doc_by_type', { key: [DOC_TYPES.TRANSLATIONS], include_docs: true }); + .allDocs({ + start_key: PREFIXES.TRANSLATIONS, + end_key: PREFIXES.TRANSLATIONS + '\ufff0', + include_docs: true, + }); return result.rows .filter(row => enabledLanguages.includes(row.doc.code) || !enabledLanguages.length) diff --git a/webapp/src/ts/services/telemetry.service.ts b/webapp/src/ts/services/telemetry.service.ts index a7a33d6aa20..24e3a65febc 100644 --- a/webapp/src/ts/services/telemetry.service.ts +++ b/webapp/src/ts/services/telemetry.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, NgZone } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { v7 as uuid } from 'uuid'; -import { DOC_IDS } from '@medic/constants'; +import { DOC_IDS, PREFIXES } from '@medic/constants'; import { DbService } from '@mm-services/db.service'; import { SessionService } from '@mm-services/session.service'; @@ -70,7 +70,11 @@ export class TelemetryService { return Promise .all([ this.dbService.get().get('_design/medic-client'), - this.dbService.get().query('medic-client/doc_by_type', { key: ['form'], include_docs: true }), + this.dbService.get().allDocs({ + start_key: PREFIXES.FORM, + end_key: PREFIXES.FORM + '\ufff0', + include_docs: true, + }), this.dbService.get().allDocs({ key: DOC_IDS.SETTINGS }) ]) .then(([ ddoc, formResults, settingsResults ]) => { @@ -92,7 +96,7 @@ export class TelemetryService { versions: { app: version, forms: forms, - settings: settingsResults?.rows?.[0].value?.rev, + settings: settingsResults?.rows?.[0]?.value?.rev, } }; }); diff --git a/webapp/src/ts/services/xml-forms.service.ts b/webapp/src/ts/services/xml-forms.service.ts index 5ab048ccf63..fc9bab98aee 100644 --- a/webapp/src/ts/services/xml-forms.service.ts +++ b/webapp/src/ts/services/xml-forms.service.ts @@ -1,5 +1,6 @@ import { Injectable, NgZone } from '@angular/core'; import { Subject } from 'rxjs'; +import { PREFIXES } from '@medic/constants'; import { AuthService } from '@mm-services/auth.service'; import { ChangesService } from '@mm-services/changes.service'; @@ -11,8 +12,8 @@ import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-util import { ParseProvider } from '@mm-providers/parse.provider'; import { UserContactSummaryService } from '@mm-services/user-contact-summary.service'; -export const TRAINING_FORM_ID_PREFIX: string = 'form:training:'; -export const CONTACT_FORM_ID_PREFIX: string = 'form:contact:'; +export const TRAINING_FORM_ID_PREFIX: string = `${PREFIXES.FORM}training:`; +export const CONTACT_FORM_ID_PREFIX: string = `${PREFIXES.FORM}contact:`; @Injectable({ providedIn: 'root' @@ -40,7 +41,7 @@ export class XmlFormsService { this.changesService.subscribe({ key: 'xml-forms', filter: (change) => { - return change.id.indexOf('form:') === 0; + return change.id.startsWith(PREFIXES.FORM); }, callback: () => { this.init = this.getForms(); @@ -54,10 +55,11 @@ export class XmlFormsService { private getForms() { const options = { include_docs: true, - key: ['form'] + start_key: PREFIXES.FORM, + end_key: PREFIXES.FORM + '\ufff0', }; return this.dbService.get() - .query('medic-client/doc_by_type', options) + .allDocs(options) .then((res) => { if (!res?.rows) { return; @@ -70,7 +72,7 @@ export class XmlFormsService { } private getById(internalId) { - const formId = `form:${internalId}`; + const formId = `${PREFIXES.FORM}${internalId}`; return this.dbService.get().get(formId); } diff --git a/webapp/tests/karma/ts/services/languages.service.spec.ts b/webapp/tests/karma/ts/services/languages.service.spec.ts index b206cea6637..5e03e89d7ae 100644 --- a/webapp/tests/karma/ts/services/languages.service.spec.ts +++ b/webapp/tests/karma/ts/services/languages.service.spec.ts @@ -61,7 +61,7 @@ describe('Languages service', () => { TestBed.configureTestingModule({ providers: [ { provide: SettingsService, useValue: settingsService }, - { provide: DbService, useValue: { get: () => ({ query: dbQuery }) } }, + { provide: DbService, useValue: { get: () => ({ allDocs: dbQuery }) } }, ] }); languagesService = TestBed.inject(LanguagesService); diff --git a/webapp/tests/karma/ts/services/telemetry.service.spec.ts b/webapp/tests/karma/ts/services/telemetry.service.spec.ts index 4f31b41f3a6..d908e24c9a9 100644 --- a/webapp/tests/karma/ts/services/telemetry.service.spec.ts +++ b/webapp/tests/karma/ts/services/telemetry.service.spec.ts @@ -82,7 +82,6 @@ describe('TelemetryService', () => { medicDb = { info: sinon.stub(), get: sinon.stub(), - query: sinon.stub(), allDocs: sinon.stub() }; const getStub = sinon.stub(); @@ -137,7 +136,7 @@ describe('TelemetryService', () => { describe('record()', () => { it('should record a piece of telemetry', async () => { - medicDb.query.resolves({ rows: [] }); + medicDb.allDocs.resolves({ rows: [] }); telemetryDb.query.resolves({ rows: [] }); const oldTelemetryDBNames = [ '_pouch_medic-user-koko-telemetry-98y7c3a1-5a1a-4d3f-a076-d86ec38b1d87', @@ -172,7 +171,7 @@ describe('TelemetryService', () => { }); it('should use separate telemetry DBs for different users on the same day', async () => { - medicDb.query.resolves({ rows: [] }); + medicDb.allDocs.resolves({ rows: [] }); telemetryDb.query.resolves({ rows: [] }); // Simulate switching from user 'greg' to user 'jane' on the same day @@ -197,7 +196,7 @@ describe('TelemetryService', () => { }); it('should default the value to 1 if not passed', async () => { - medicDb.query.resolves({ rows: [] }); + medicDb.allDocs.resolves({ rows: [] }); telemetryDb.query.resolves({ rows: [] }); windowMock.indexedDB.databases.resolves([ 'telemetry-2018-11-10-greg', @@ -233,26 +232,30 @@ describe('TelemetryService', () => { _id: '_design/medic-client', build_info: { version: '3.0.0' } }); - medicDb.query.resolves({ - rows: [ - { - id: 'form:anc_followup', - key: 'anc_followup', - doc: { - _id: 'form:anc_followup', - _rev: '1-abc', - internalId: 'anc_followup' + medicDb.allDocs + .withArgs({ start_key: 'form:', end_key: 'form:\ufff0', include_docs: true }) + .resolves({ + rows: [ + { + id: 'form:anc_followup', + key: 'anc_followup', + doc: { + _id: 'form:anc_followup', + _rev: '1-abc', + internalId: 'anc_followup' + } } - } - ] - }); - medicDb.allDocs.resolves({ - rows: [{ - value: { - rev: 'somerandomrevision' - } - }] - }); + ] + }); + medicDb.allDocs + .withArgs({ key: 'settings' }) + .resolves({ + rows: [{ + value: { + rev: 'somerandomrevision' + } + }] + }); }; it('should aggregate once a day and delete previous telemetry databases', async () => { @@ -318,9 +321,14 @@ describe('TelemetryService', () => { deviceInfo: {} }); - expect(medicDb.query.calledTwice).to.be.true; - expect(medicDb.query.args[0][0]).to.equal('medic-client/doc_by_type'); - expect(medicDb.query.args[0][1]).to.deep.equal({ key: [ 'form' ], include_docs: true }); + const formsAllDocsCalls = medicDb.allDocs.getCalls() + .filter(call => call.args[0]?.start_key === 'form:'); + expect(formsAllDocsCalls.length).to.equal(2); + expect(formsAllDocsCalls[0].args[0]).to.deep.equal({ + start_key: 'form:', + end_key: 'form:\ufff0', + include_docs: true, + }); expect(telemetryDb.destroy.calledTwice).to.be.true; expect(telemetryDb.close.notCalled).to.be.true; @@ -456,14 +464,18 @@ describe('TelemetryService', () => { version: '3.0.0' } }); - medicDb.allDocs.resolves({ - rows: [{ - value: { - rev: 'randomrev' - } - }] - }); - medicDb.query.resolves({ rows: [] }); + medicDb.allDocs + .withArgs({ start_key: 'form:', end_key: 'form:\ufff0', include_docs: true }) + .resolves({ rows: [] }); + medicDb.allDocs + .withArgs({ key: 'settings' }) + .resolves({ + rows: [{ + value: { + rev: 'randomrev' + } + }] + }); await service.record('test', 1); diff --git a/webapp/tests/karma/ts/services/xml-forms.service.spec.ts b/webapp/tests/karma/ts/services/xml-forms.service.spec.ts index eb272b1d53c..d50cfbd61fe 100644 --- a/webapp/tests/karma/ts/services/xml-forms.service.spec.ts +++ b/webapp/tests/karma/ts/services/xml-forms.service.spec.ts @@ -80,7 +80,7 @@ describe('XmlForms service', () => { TestBed.configureTestingModule({ providers: [ { provide: DbService, useValue: { get: () => ({ - query: dbQuery, get: dbGet, getAttachment: dbGetAttachment + allDocs: dbQuery, get: dbGet, getAttachment: dbGetAttachment } ) } }, { provide: ChangesService, useValue: { subscribe: Changes } }, { provide: AuthService, useValue: { has: hasAuth } }, @@ -1379,10 +1379,10 @@ describe('XmlForms service', () => { expect(warn.args[0][0]).to.equal('Error in XMLFormService : getById : '); expect(actual).to.deep.equal(expected); expect(dbQuery.callCount).to.equal(1); - expect(dbQuery.args[0][0]).to.equal(`medic-client/doc_by_type`); - const options = dbQuery.args[0][1]; + const options = dbQuery.args[0][0]; expect(options.include_docs).to.equal(true); - expect(options.key).to.deep.equal(['form']); + expect(options.start_key).to.equal('form:'); + expect(options.end_key).to.equal('form:\ufff0'); }); }); From 36278cd3dffa1a1aa2a86413932eca792ebca5d1 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Thu, 18 Jun 2026 13:05:00 +0300 Subject: [PATCH 3/5] feat(#10749): move tasks by contact to offline clients --- shared-libs/memdown/src/memdown-medic.js | 10 ++++++ .../rules-engine/src/pouchdb-provider.js | 8 ++--- .../rules-engine/test/integration.spec.js | 14 ++++---- .../test/pouchdb-provider.spec.js | 35 ++++++++++++++----- .../rules-engine/test/provider-wireup.spec.js | 2 +- .../db/initial-replication.wdio-spec.js | 2 +- .../js/bootstrapper/offline-ddocs/index.js | 6 +++- .../medic-offline-tasks/index.js | 10 ++++++ .../medic-offline-tasks/tasks_by_contact.js | 8 ++--- 9 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/index.js rename ddocs/medic-db/medic-client/views/tasks_by_contact/map.js => webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/tasks_by_contact.js (56%) diff --git a/shared-libs/memdown/src/memdown-medic.js b/shared-libs/memdown/src/memdown-medic.js index 884b4591e26..b911443ab7d 100644 --- a/shared-libs/memdown/src/memdown-medic.js +++ b/shared-libs/memdown/src/memdown-medic.js @@ -57,6 +57,16 @@ module.exports = (rootDir='./') => { if (!ddocs) { ddocs = []; filesIn(`${rootDir}/ddocs/medic-db`).forEach(ddoc => loadDdoc(rootDir, 'medic-db', ddoc)); + + // Load offline-only tasks view for tests + const tasksMapPath = `${rootDir}/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/tasks_by_contact.js`; + if (fs.existsSync(tasksMapPath)) { + const tasksMap = readFile(tasksMapPath).replace(/module.exports.map = /, ''); + ddocs.push({ + _id: '_design/medic-offline-tasks', + views: { tasks_by_contact: { map: tasksMap } } + }); + } } const db = new PouchDB(uuid(), { adapter: 'memory' }); return Promise.all(ddocs.map(ddoc => db.put(ddoc))) diff --git a/shared-libs/rules-engine/src/pouchdb-provider.js b/shared-libs/rules-engine/src/pouchdb-provider.js index 2e3301e01d9..be42c323343 100644 --- a/shared-libs/rules-engine/src/pouchdb-provider.js +++ b/shared-libs/rules-engine/src/pouchdb-provider.js @@ -39,7 +39,7 @@ const medicPouchProvider = db => { // For users with ~1000 contacts it is ~50x faster to provider a start/end key instead of specifying all ids allTasks: prefix => { const options = { startkey: `${prefix}-`, endkey: `${prefix}-\ufff0`, include_docs: true }; - return docsOf(dbQuery('medic-client/tasks_by_contact', options)); + return docsOf(dbQuery('medic-offline-tasks/tasks_by_contact', options)); }, allTaskData: userSettingsDoc => { @@ -108,12 +108,12 @@ const medicPouchProvider = db => { tasksByRelation: (contactIds, prefix) => { const keys = contactIds.map(contactId => `${prefix}-${contactId}`); - return docsOf(dbQuery( 'medic-client/tasks_by_contact', { keys, include_docs: true })); + return docsOf(dbQuery( 'medic-offline-tasks/tasks_by_contact', { keys, include_docs: true })); }, allTaskRowsByOwner: (contactIds) => { const keys = contactIds.map(contactId => (['owner', 'all', contactId])); - return rowsOf(dbQuery( 'medic-client/tasks_by_contact', { keys })); + return rowsOf(dbQuery( 'medic-offline-tasks/tasks_by_contact', { keys })); }, allTaskRows: () => { @@ -122,7 +122,7 @@ const medicPouchProvider = db => { endkey: ['owner', 'all', '\ufff0'], }; - return rowsOf(dbQuery( 'medic-client/tasks_by_contact', options)); + return rowsOf(dbQuery( 'medic-offline-tasks/tasks_by_contact', options)); }, taskDataFor: async (contactIds, userSettingsDoc) => { diff --git a/shared-libs/rules-engine/test/integration.spec.js b/shared-libs/rules-engine/test/integration.spec.js index b6141e28296..30edef084c6 100644 --- a/shared-libs/rules-engine/test/integration.spec.js +++ b/shared-libs/rules-engine/test/integration.spec.js @@ -71,12 +71,12 @@ const reportByPatientIdOnly = { const expectedQueriesForAllFreshData = [ 'medic-client/contacts_by_type', 'medic-client/reports_by_subject', - 'medic-client/tasks_by_contact' + 'medic-offline-tasks/tasks_by_contact' ]; const expectedQueriesForFreshData = [ 'medic-client/reports_by_subject', - 'medic-client/tasks_by_contact', - 'medic-client/tasks_by_contact', + 'medic-offline-tasks/tasks_by_contact', + 'medic-offline-tasks/tasks_by_contact', ]; const fetchTargets = async (interval) => { @@ -458,7 +458,7 @@ describe(`Rules Engine Integration Tests`, () => { const tasksAfterPurge = await rulesEngine.fetchTasksFor(); expect(tasksAfterPurge).to.have.property('length', 0); - const allTasks = await db.query('medic-client/tasks_by_contact'); + const allTasks = await db.query('medic-offline-tasks/tasks_by_contact'); expect(allTasks.total_rows).to.eq(0); }); @@ -487,7 +487,7 @@ describe(`Rules Engine Integration Tests`, () => { expect(secondTasks).to.deep.eq(firstTasks); expect(db.query.args.map(args => args[0])).to.deep.eq([ ...expectedQueriesForAllFreshData, - 'medic-client/tasks_by_contact', + 'medic-offline-tasks/tasks_by_contact', 'medic-client/contacts_by_reference', ...expectedQueriesForFreshData ]); @@ -545,7 +545,7 @@ describe(`Rules Engine Integration Tests`, () => { expect(rulesEmitter.getEmissionsFor.args).excludingEvery(['_rev', 'state', 'stateHistory']) .to.deep.eq([[[], [headlessReport, headlessReport2], [taskEmittedByHeadless2]]]); expect(db.query.args.map(args => args[0])) - .to.deep.eq([...expectedQueriesForAllFreshData, 'medic-client/tasks_by_contact']); + .to.deep.eq([...expectedQueriesForAllFreshData, 'medic-offline-tasks/tasks_by_contact']); expect(firstResult).excludingEvery('_rev').to.deep.eq([taskOwnedByHeadless]); expect(db.bulkDocs.callCount).to.eq(2); // taskEmittedByHeadless2 gets cancelled @@ -773,7 +773,7 @@ const triggerFacilityReminderInReadyState = async (selectBy, docs = [patientCont const tasks = await rulesEngine.fetchTasksFor(selectBy); expect(tasks).to.have.property('length', 1); expect(db.query.args.map(args => args[0])).to.deep.eq( - selectBy ? expectedQueriesForFreshData : [...expectedQueriesForAllFreshData, 'medic-client/tasks_by_contact'] + selectBy ? expectedQueriesForFreshData : [...expectedQueriesForAllFreshData, 'medic-offline-tasks/tasks_by_contact'] ); expect(db.bulkDocs.callCount).to.eq(2); expect(tasks[0]).to.deep.include({ diff --git a/shared-libs/rules-engine/test/pouchdb-provider.spec.js b/shared-libs/rules-engine/test/pouchdb-provider.spec.js index c32acc0998d..2181a60b476 100644 --- a/shared-libs/rules-engine/test/pouchdb-provider.spec.js +++ b/shared-libs/rules-engine/test/pouchdb-provider.spec.js @@ -348,7 +348,10 @@ describe('pouchdb provider', () => { }); expect(db.query.args).to.deep.equal([ ['medic-client/reports_by_subject', { keys: ['abc'], include_docs: true, ...defaultQueryParams }], - ['medic-client/tasks_by_contact', { keys: ['requester-abc'], include_docs: true, ...defaultQueryParams }], + [ + 'medic-offline-tasks/tasks_by_contact', + { keys: ['requester-abc'], include_docs: true, ...defaultQueryParams }, + ], ]); }); it('cht contact yields', async() => { @@ -370,11 +373,19 @@ describe('pouchdb provider', () => { expect(db.query.args).to.deep.equal([ [ 'medic-client/reports_by_subject', - { keys: [chtDocs.contact._id, 'abc', chtDocs.contact.patient_id], include_docs: true, ...defaultQueryParams }, + { + keys: [chtDocs.contact._id, 'abc', chtDocs.contact.patient_id], + include_docs: true, + ...defaultQueryParams, + }, ], [ - 'medic-client/tasks_by_contact', - { keys: [`requester-${chtDocs.contact._id}`, 'requester-abc'], include_docs: true, ...defaultQueryParams } + 'medic-offline-tasks/tasks_by_contact', + { + keys: [`requester-${chtDocs.contact._id}`, 'requester-abc'], + include_docs: true, + ...defaultQueryParams, + }, ], ]); }); @@ -440,11 +451,19 @@ describe('pouchdb provider', () => { expect(db.query.args).to.deep.equal([ [ 'medic-client/reports_by_subject', - { keys: [ ...contactIds, 'place_id', 'patient_id' ], include_docs: true, ...defaultQueryParams }, + { + keys: [ ...contactIds, 'place_id', 'patient_id' ], + include_docs: true, + ...defaultQueryParams, + }, ], [ - 'medic-client/tasks_by_contact', - { keys: contactIds.map(id => `requester-${id}`), include_docs: true, ...defaultQueryParams } + 'medic-offline-tasks/tasks_by_contact', + { + keys: contactIds.map(id => `requester-${id}`), + include_docs: true, + ...defaultQueryParams, + }, ], ]); @@ -482,7 +501,7 @@ describe('pouchdb provider', () => { { include_docs: true, ...defaultQueryParams }, ], [ - 'medic-client/tasks_by_contact', + 'medic-offline-tasks/tasks_by_contact', { include_docs: true, ...defaultQueryParams } ], ]); diff --git a/shared-libs/rules-engine/test/provider-wireup.spec.js b/shared-libs/rules-engine/test/provider-wireup.spec.js index 4eb06dad798..8471f40fd96 100644 --- a/shared-libs/rules-engine/test/provider-wireup.spec.js +++ b/shared-libs/rules-engine/test/provider-wireup.spec.js @@ -404,7 +404,7 @@ describe('provider-wireup integration tests', () => { expect(actual).to.be.empty; expect(rulesEmitter.getEmissionsFor.callCount).to.eq(1); expect(db.query.callCount).to.eq(3); - expect(db.query.args[2][0]).to.eq('medic-client/tasks_by_contact'); + expect(db.query.args[2][0]).to.eq('medic-offline-tasks/tasks_by_contact'); expect(db.query.args[2][1]).to.not.have.property('keys'); }); diff --git a/tests/e2e/default/db/initial-replication.wdio-spec.js b/tests/e2e/default/db/initial-replication.wdio-spec.js index 850b97c6d93..477384d3808 100644 --- a/tests/e2e/default/db/initial-replication.wdio-spec.js +++ b/tests/e2e/default/db/initial-replication.wdio-spec.js @@ -7,7 +7,7 @@ const loginPage = require('@page-objects/default/login/login.wdio.page'); const dataFactory = require('@factories/cht/generate'); const { DOC_IDS, PREFIXES } = require('@medic/constants'); -const LOCAL_ONLY_DOC_IDS = ['_design/medic-offline-freetext']; +const LOCAL_ONLY_DOC_IDS = ['_design/medic-offline-freetext', '_design/medic-offline-tasks']; describe('initial-replication', () => { const LOCAL_LOG = '_local/initial-replication'; diff --git a/webapp/src/js/bootstrapper/offline-ddocs/index.js b/webapp/src/js/bootstrapper/offline-ddocs/index.js index b6cda237df4..38044cc25c1 100644 --- a/webapp/src/js/bootstrapper/offline-ddocs/index.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/index.js @@ -1,4 +1,5 @@ const contactsByFreetext = require('./medic-offline-freetext'); +const tasks = require('./medic-offline-tasks'); const getRev = async (db, id) => db .get(id) @@ -15,4 +16,7 @@ const initDdoc = async (db, ddoc) => db.put({ _rev: await getRev(db, ddoc._id), }); -module.exports.init = async (db) => initDdoc(db, contactsByFreetext); +module.exports.init = async (db) => { + await initDdoc(db, contactsByFreetext); + await initDdoc(db, tasks); +}; diff --git a/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/index.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/index.js new file mode 100644 index 00000000000..0bb7a0a85c4 --- /dev/null +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/index.js @@ -0,0 +1,10 @@ +const tasksByContact = require('./tasks_by_contact'); + +const packageView = ({ map }) => ({ map: map.toString() }); + +module.exports = { + _id: '_design/medic-offline-tasks', + views: { + tasks_by_contact: packageView(tasksByContact), + } +}; diff --git a/ddocs/medic-db/medic-client/views/tasks_by_contact/map.js b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/tasks_by_contact.js similarity index 56% rename from ddocs/medic-db/medic-client/views/tasks_by_contact/map.js rename to webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/tasks_by_contact.js index 533a2eab413..2c803c4a1e2 100644 --- a/ddocs/medic-db/medic-client/views/tasks_by_contact/map.js +++ b/webapp/src/js/bootstrapper/offline-ddocs/medic-offline-tasks/tasks_by_contact.js @@ -1,7 +1,7 @@ -function(doc) { +module.exports.map = function(doc) { if (doc.type === 'task') { - var isTerminalState = ['Cancelled', 'Completed', 'Failed'].indexOf(doc.state) >= 0; - var owner = (doc.owner || '_unassigned'); + const isTerminalState = ['Cancelled', 'Completed', 'Failed'].includes(doc.state); + const owner = (doc.owner || '_unassigned'); if (!isTerminalState) { emit('owner-' + owner); @@ -13,4 +13,4 @@ function(doc) { emit(['owner', 'all', owner], { state: doc.state }); } -} +}; From 99cfbcb4fc64da28bce1996eff31b7986b020600 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Thu, 18 Jun 2026 13:16:37 +0300 Subject: [PATCH 4/5] feat(#10875): infoDoc locking --- shared-libs/infodoc/src/infodoc.js | 57 ++++-- shared-libs/infodoc/test/infodoc.js | 74 ++++++++ .../transitions/src/transitions/index.js | 110 +++++++---- .../test/integration/transitions.js | 175 +++++++++++++++--- .../test/unit/finalize-transition.js | 27 ++- .../transitions/test/unit/process_docs.js | 19 +- 6 files changed, 378 insertions(+), 84 deletions(-) diff --git a/shared-libs/infodoc/src/infodoc.js b/shared-libs/infodoc/src/infodoc.js index 2fcdad0bd44..55d468ea299 100644 --- a/shared-libs/infodoc/src/infodoc.js +++ b/shared-libs/infodoc/src/infodoc.js @@ -35,7 +35,7 @@ const resolveInfoDocs = (changes, writeDirtyInfoDocs) => { return results.reduce((acc, row) => { if (!row.doc) { acc.missing.push({ _id: row.key }); - } else if (!row.doc.transitions) { + } else if (!row.doc.transitions && !row.doc.transitions_started) { // No transitions may mean that API created this infodoc on write but sentinel hasn't seen // it yet. It's possible that there is a legacy infodoc with transition information. acc.missingTransitions.push(row.doc); @@ -151,33 +151,62 @@ const updateTransition = (change, transition, ok) => { }; }; -const saveTransitions = change => { - return saveProperty(change.id, change.info, 'transitions', {}); +const saveTransitions = (change, clearStarted = false) => { + const modify = infoDoc => { + infoDoc.transitions = change.info?.transitions || {}; + // Clear the in-progress marker in the same write that commits the transitions (API branch only) + if (clearStarted) { + delete infoDoc.transitions_started; + } + }; + return modifyInfoDoc(change.id, modify, change.info); }; const saveCompletedTasks = (id, infodoc, completedTasks = []) => { - return saveProperty(id, infodoc, 'completed_tasks', completedTasks); + return modifyInfoDoc(id, infoDoc => { + infoDoc.completed_tasks = infodoc?.completed_tasks || completedTasks; + }, infodoc); +}; + +const markTransitionsStarted = (id) => { + const modify = infoDoc => { + infoDoc.transitions_started = new Date().toISOString(); + }; + return modifyInfoDoc(id, modify, blankInfoDoc(id)); +}; + +const clearTransitionsStarted = (id) => { + const modify = infoDoc => { + delete infoDoc.transitions_started; + }; + return modifyInfoDoc(id, modify, blankInfoDoc(id)); }; -const saveProperty = async (id, infodoc, property, defaultValue = {}) => { - let updatedInfoDoc; +// Fetch the infodoc. If it is missing, return `fallback` (to be created) when provided; +const fetchInfoDoc = async (id, fallback) => { try { - updatedInfoDoc = await db.sentinel.get(getInfoDocId(id)); - updatedInfoDoc[property] = (infodoc && infodoc[property]) || defaultValue; + return await db.sentinel.get(getInfoDocId(id)); } catch (err) { - if (err.status !== 404) { - throw err; + if (err.status === 404 && fallback) { + return fallback; } - updatedInfoDoc = infodoc; + throw err; } +}; + +// Fetch the infodoc, apply `modify`, and save, retrying on conflict +const modifyInfoDoc = async (id, modify, fallback) => { + const infoDoc = await fetchInfoDoc(id, fallback); + + modify(infoDoc); try { - return await db.sentinel.put(updatedInfoDoc); + return await db.sentinel.put(infoDoc); } catch (err) { if (err.status !== 409) { throw err; } - return saveProperty(id, infodoc, property, defaultValue); + return modifyInfoDoc(id, modify, fallback); } }; @@ -283,6 +312,8 @@ module.exports = { bulkUpdate: bulkUpdate, saveTransitions: saveTransitions, saveCompletedTasks: saveCompletedTasks, + markTransitionsStarted: markTransitionsStarted, + clearTransitionsStarted: clearTransitionsStarted, // Used to update infodoc metadata that occurs at write time. A delete does not count as a write // in this instance, as deletes resolve as infodoc cleanups once sentinel's background-cleanup diff --git a/shared-libs/infodoc/test/infodoc.js b/shared-libs/infodoc/test/infodoc.js index dbf713c81c0..c8d1aef51a0 100644 --- a/shared-libs/infodoc/test/infodoc.js +++ b/shared-libs/infodoc/test/infodoc.js @@ -604,6 +604,80 @@ describe('infodoc', () => { assert.deepEqual(db.sentinel.put.args[20], [{ ...info, transitions: change.info.transitions }]); }); }); + + it('clears transitions_started when clearStarted is set', () => { + const info = { _id: 'some-info', doc_id: 'some', transitions_started: '2026-01-01T00:00:00.000Z' }; + const change = { id: 'some', info: { transitions: { one: { ok: true } } } }; + sinon.stub(db.sentinel, 'get').resolves(info); + sinon.stub(db.sentinel, 'put').resolves(); + + return lib.saveTransitions(change, true).then(() => { + const saved = db.sentinel.put.args[0][0]; + // transitions are written and the mid-write marker removed; nothing else is touched + assert.deepEqual(saved, { + _id: 'some-info', + doc_id: 'some', + transitions: { one: { ok: true } }, + }); + }); + }); + }); + + describe('markTransitionsStarted / clearTransitionsStarted', () => { + it('markTransitionsStarted marks the infodoc mid-write with a timestamp', () => { + const info = { _id: 'some-info', doc_id: 'some' }; + sinon.stub(db.sentinel, 'get').resolves(info); + sinon.stub(db.sentinel, 'put').resolves(); + + return lib.markTransitionsStarted('some').then(() => { + assert.deepEqual(db.sentinel.get.args[0], ['some-info']); + const saved = db.sentinel.put.args[0][0]; + assert.isString(saved.transitions_started); + assert.isNotNaN(Date.parse(saved.transitions_started)); + // only the marker is added, no other fields are changed + assert.deepEqual(saved, { + _id: 'some-info', + doc_id: 'some', + transitions_started: saved.transitions_started, + }); + }); + }); + + it('clearTransitionsStarted removes the mid-write marker', () => { + const info = { _id: 'some-info', doc_id: 'some', transitions_started: '2026-01-01T00:00:00.000Z' }; + sinon.stub(db.sentinel, 'get').resolves(info); + sinon.stub(db.sentinel, 'put').resolves(); + + return lib.clearTransitionsStarted('some').then(() => { + const saved = db.sentinel.put.args[0][0]; + // only the marker is removed, no other fields are changed + assert.deepEqual(saved, { _id: 'some-info', doc_id: 'some' }); + }); + }); + + it('retries on 409 conflict indefinitely (no retry limit)', () => { + const info = { _id: 'some-info', doc_id: 'some' }; + sinon.stub(db.sentinel, 'get').resolves(info); + const put = sinon.stub(db.sentinel, 'put'); + // conflict on the first 100 attempts, succeed on the 101st - a retry limit below this would fail + for (let i = 0; i < 100; i++) { + put.onCall(i).rejects({ status: 409 }); + } + put.onCall(100).resolves(); + + return lib.markTransitionsStarted('some').then(() => { + assert.equal(db.sentinel.put.callCount, 101); + }); + }); + + it('throws non-409 errors', () => { + sinon.stub(db.sentinel, 'get').resolves({ _id: 'some-info', doc_id: 'some' }); + sinon.stub(db.sentinel, 'put').rejects({ status: 500 }); + + return lib.markTransitionsStarted('some') + .then(() => assert.fail('should have thrown')) + .catch(err => assert.equal(err.status, 500)); + }); }); describe('saveCompletedTasks', () => { diff --git a/shared-libs/transitions/src/transitions/index.js b/shared-libs/transitions/src/transitions/index.js index 4b9db7f101d..869cb8eb5a1 100644 --- a/shared-libs/transitions/src/transitions/index.js +++ b/shared-libs/transitions/src/transitions/index.js @@ -45,13 +45,33 @@ const AVAILABLE_TRANSITIONS = [ const transitions = []; let loadErrors = false; +const MAX_INFODOC_WAIT = 5; +const INFODOC_WAIT_INTERVAL = 100; + +const isInfoDocMidWrite = infoDoc => infoDoc?.transitions_started !== undefined; + +const getConsistentInfoDoc = async (change, retriesLeft) => { + const infoDoc = await infodoc.get(change); + if (!isInfoDocMidWrite(infoDoc) || retriesLeft <= 0) { + return infoDoc; + } + await new Promise(resolve => setTimeout(resolve, INFODOC_WAIT_INTERVAL)); + return getConsistentInfoDoc(change, retriesLeft - 1); +}; + // applies all loaded transitions over a change const processChange = (change, callback) => { lineage .fetchHydratedDoc(change.id) .then(doc => { change.doc = doc; - return infodoc.get(change).then(infoDoc => { + return getConsistentInfoDoc(change, MAX_INFODOC_WAIT).then(infoDoc => { + if (isInfoDocMidWrite(infoDoc)) { + logger.warn( + `transitions: infodoc for ${change.id} still mid-write after ${MAX_INFODOC_WAIT} retries, skipping` + ); + return callback(); + } change.info = infoDoc; change.initialProcessing = !infoDoc.transitions; // Remove transitions from doc since those @@ -107,10 +127,11 @@ const processDocs = docs => { // doc was not changed by any transition, so we save the original doc change.doc = docs.find(doc => doc._id === change.id); - saveDoc(change, (err, result) => { - callback(null, err || result); - }); - }); + saveDoc(change).then( + result => callback(null, result), + err => callback(null, err) + ); + }, { markStarted: true }); }); async.series(operations, (err, results) => { return err ? reject(err) : resolve(results); @@ -235,46 +256,61 @@ const canRun = ({ key, change, transition }) => { * did nothing and saving is unnecessary. If results has a true value in * it then a change was made. */ -const finalize = ({ change, results }, callback) => { +const finalize = ({ change, results, markStarted = false }, callback) => { + finalizeChange({ change, results, markStarted }) + .then(result => callback(null, result)) + .catch(err => { + logger.error(`error saving changes on doc ${change.id} seq ${change.seq}: %o`, err); + callback(err); + }); +}; + +const finalizeChange = async ({ change, results, markStarted }) => { logger.debug(`transition results: ${JSON.stringify(results)}`); - const changed = _.some(results, i => Boolean(i)); - if (!changed) { - logger.debug( - `nothing changed skipping saveDoc for doc ${change.id} seq ${change.seq}` - ); - // info.transitions is how we know if a doc has been processed by Sentinel before. Even if no transitions ran, - // we still want to save transitions, so we know it's been processed. - return Promise - .resolve() - .then(() => { - if (change.initialProcessing) { - return infodoc.saveTransitions(change); - } - }) - .then(() => callback()) - .catch(err => callback(err)); + if (!_.some(results, Boolean)) { + logger.debug(`nothing changed skipping saveDoc for doc ${change.id} seq ${change.seq}`); + // info.transitions is how we know Sentinel has processed a doc; record it even when nothing ran. + if (change.initialProcessing) { + await infodoc.saveTransitions(change); + } + return; } + logger.debug(`calling saveDoc on doc ${change.id} seq ${change.seq}`); + return markStarted ? saveForApi(change) : saveForSentinel(change); +}; - saveDoc(change, (err, result) => { - // todo: how to handle a failed save? for now just - // waiting until next change and try again. - if (err) { - logger.error(`error saving changes on doc ${change.id} seq ${change.seq}: %o`, err); - return callback(err); - } +// Sentinel processing: save the doc, then record transitions. Sentinel never writes the +// transitions_started marker; it only reads it (see getConsistentInfoDoc) to skip docs API is writing. +const saveForSentinel = async change => { + const result = await saveDoc(change); + logger.info(`saved changes on doc ${change.id} seq ${change.seq}`); + await infodoc.saveTransitions(change); + return result; +}; +// API processing: mark the infodoc mid-write (transitions_started) around the doc write so a concurrent +// sentinel read detects it and waits. The marker is cleared as part of the transitions write on +// success, or rolled back on any failure after it's set. +const saveForApi = async change => { + await infodoc.markTransitionsStarted(change.id); + try { + const result = await saveDoc(change); logger.info(`saved changes on doc ${change.id} seq ${change.seq}`); - infodoc.saveTransitions(change) - .then(() => callback(null, result)) - .catch(err => callback(err)); - }); + await infodoc.saveTransitions(change, true); + return result; + } catch (err) { + await infodoc + .clearTransitionsStarted(change.id) + .catch(clearErr => logger.error(`error clearing transitions_started on doc ${change.id}: %o`, clearErr)); + throw err; + } }; -const saveDoc = (change, callback) => { +const saveDoc = change => { lineage.minify(change.doc); - db.medic.put(change.doc, callback); + return db.medic.put(change.doc); }; /* @@ -335,7 +371,7 @@ const applyTransition = ({ key, change, transition, force }, callback) => { .then(changed => callback(null, changed)); // return the promise instead }; -const applyTransitions = (change, callback) => { +const applyTransitions = (change, callback, { markStarted = false } = {}) => { const operations = transitions .map(transition => { const opts = { @@ -353,7 +389,7 @@ const applyTransitions = (change, callback) => { * function. All we care about are results and whether we need to * save or not. */ - async.series(operations, (err, results) => finalize({ change, results }, callback)); + async.series(operations, (err, results) => finalize({ change, results, markStarted }, callback)); }; const availableTransitions = () => { diff --git a/shared-libs/transitions/test/integration/transitions.js b/shared-libs/transitions/test/integration/transitions.js index 30e4ed31366..5bf30a495e2 100644 --- a/shared-libs/transitions/test/integration/transitions.js +++ b/shared-libs/transitions/test/integration/transitions.js @@ -5,6 +5,7 @@ const chaiExclude = require('chai-exclude'); const db = require('../../src/db'); const config = require('../../src/config'); const infodoc = require('@medic/infodoc'); +const logger = require('@medic/logger'); const dataContext = require('../../src/data-context'); const { Contact } = require('@medic/cht-datasource'); const { DOC_TYPES, CONTACT_TYPES } = require('@medic/constants'); @@ -41,8 +42,10 @@ describe('functional transitions', () => { }, }); const infoDocSave = sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); sinon.stub(db.medic, 'get').rejects({ status: 404 }); - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); transitions.loadTransitions(); const change1 = { @@ -97,8 +100,10 @@ describe('functional transitions', () => { }, }); - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); const infoDoc = sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); transitions.loadTransitions(); const change1 = { @@ -164,8 +169,10 @@ describe('functional transitions', () => { }); configGet.withArgs('forms').returns({ V: { }}); - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true }); const infoDoc = sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); transitions.loadTransitions(); const change1 = { @@ -264,7 +271,7 @@ describe('functional transitions', () => { transitions.loadTransitions(); transitions.processChange({ id: doc._id}, (err, result) => { - assert.isUndefined(err); + assert.isNull(err); assert.isUndefined(result); assert.equal(infodoc.get.callCount, 1); @@ -293,8 +300,10 @@ describe('functional transitions', () => { sinon.stub(infodoc, 'get').resolves(info); sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); - sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + sinon.stub(db.medic, 'put').resolves({ ok: true }); sinon.spy(transitions, 'applyTransitions'); const doc = { @@ -351,7 +360,9 @@ describe('functional transitions', () => { sinon.stub(infodoc, 'get').resolves({}); sinon.stub(infodoc, 'saveTransitions').resolves(); - sinon.stub(db.medic, 'put').callsArgWith(1, { error: 'something' }); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); + sinon.stub(db.medic, 'put').rejects({ error: 'something' }); const doc = { _id: 'my_id', @@ -378,6 +389,76 @@ describe('functional transitions', () => { done(); }); }); + + // fake timers so the 100ms retry waits don't cost real time or flake near mocha's timeout; + // tickAsync advances the clock and flushes the awaited infodoc.get between each retry + const RETRY_INTERVAL = 100; + const MAX_RETRIES = 5; + const processChangeWithFakeTimers = async (change) => { + const clock = sinon.useFakeTimers(); + const processed = new Promise((resolve, reject) => { + transitions.processChange(change, (err, result) => (err ? reject(err) : resolve(result))); + }); + // one extra tick past MAX_RETRIES is harmless and guarantees the change settles either way + for (let i = 0; i <= MAX_RETRIES; i++) { + await clock.tickAsync(RETRY_INTERVAL); + } + return processed; + }; + + it('should wait for transitions_started to clear before processing the change', async () => { + configGet.withArgs('transitions').returns({ conditional_alerts: {} }); + configGet.withArgs('forms').returns({ V: { } }); + + // infodoc is mid-write (API is running transitions) on the first two reads, then clears + const get = sinon.stub(infodoc, 'get'); + get.onCall(0).resolves({ _id: 'my_id-info', transitions: {}, transitions_started: '2026-01-01T00:00:00.000Z' }); + get.onCall(1).resolves({ _id: 'my_id-info', transitions: {}, transitions_started: '2026-01-01T00:00:00.000Z' }); + get.onCall(2).resolves({ _id: 'my_id-info', transitions: {} }); + const saveTransitions = sinon.stub(infodoc, 'saveTransitions').resolves(); + const put = sinon.stub(db.medic, 'put').resolves({ ok: true }); + + const doc = { _id: 'my_id', _rev: '1-abc', reported_date: 1 }; + sinon.stub(transitions._lineage, 'fetchHydratedDoc').resolves(doc); + + transitions.loadTransitions(); + const result = await processChangeWithFakeTimers({ id: doc._id }); + + assert.isUndefined(result); + // initial read + 2 reads where the marker was still set + assert.equal(infodoc.get.callCount, 3); + // no transition matched, so nothing is saved, but the change was processed (not skipped) + assert.equal(saveTransitions.callCount, 0); + assert.equal(put.callCount, 0); + }); + + it('should skip processing the change when transitions_started never clears', async () => { + configGet.withArgs('transitions').returns({ conditional_alerts: {} }); + configGet.withArgs('forms').returns({ V: { } }); + + // infodoc stays mid-write forever + sinon.stub(infodoc, 'get') + .resolves({ _id: 'my_id-info', transitions: {}, transitions_started: '2026-01-01T00:00:00.000Z' }); + const saveTransitions = sinon.stub(infodoc, 'saveTransitions').resolves(); + const put = sinon.stub(db.medic, 'put').resolves({ ok: true }); + + const doc = { _id: 'my_id', _rev: '1-abc', reported_date: 1 }; + sinon.stub(transitions._lineage, 'fetchHydratedDoc').resolves(doc); + + transitions.loadTransitions(); + // stub after loadTransitions so we only capture the skip warning, not the "disabled transition" ones + const warn = sinon.stub(logger, 'warn'); + const result = await processChangeWithFakeTimers({ id: doc._id }); + + assert.isUndefined(result); + // initial read + 5 retries, then it gives up + assert.equal(infodoc.get.callCount, 6); + // the change is skipped: no transitions run and nothing is saved + assert.equal(saveTransitions.callCount, 0); + assert.equal(put.callCount, 0); + assert.equal(warn.callCount, 1); + assert.match(warn.args[0][0], /still mid-write after 5 retries, skipping/); + }); }); describe('processDocs', () => { @@ -522,7 +603,7 @@ describe('functional transitions', () => { sinon.stub(db.sentinel, 'get').callsFake(id => Promise.resolve({ id, doc_id: id.replace('-info', '') })); sinon.stub(db.sentinel, 'put').resolves(); - sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + sinon.stub(db.medic, 'put').resolves({ ok: true }); sinon.stub(db.medic, 'query') // update_clinics @@ -568,10 +649,25 @@ describe('functional transitions', () => { assert.deepEqualExcluding(savedDocs[0], originalDocs[0], ['_id', 'errors', 'contact', 'sent_by', 'tasks']); // first doc is updated by 3 transitions infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[0]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.update_clinics.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_sent_by.ok, true); - assert.equal(infodocSaves[0][0].transitions.conditional_alerts.ok, true); + assert.equal(infodocSaves.length, 2); + // the first write only marks the infodoc as mid-write, with no transitions yet + let startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[0]._id}-info`, + doc_id: savedDocs[0]._id, + transitions_started: startedSave.transitions_started, + }); + // the second write commits the transitions and removes the mid-write marker + let txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers( + Object.keys(txnSave.transitions), + ['update_clinics', 'update_sent_by', 'conditional_alerts'] + ); + assert.equal(txnSave.transitions.update_clinics.ok, true); + assert.equal(txnSave.transitions.update_sent_by.ok, true); + assert.equal(txnSave.transitions.conditional_alerts.ok, true); assert.equal(savedDocs[1].id, 'has default response'); assert.equal(savedDocs[1]._id.length, 36); @@ -581,21 +677,43 @@ describe('functional transitions', () => { assert.equal(savedDocs[1].errors[0].code, 'sys.facility_not_found'); assert.deepEqualExcluding(savedDocs[1], originalDocs[1], ['_id', 'tasks', 'errors']); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[1]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.default_responses.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_clinics.ok, true); + assert.equal(infodocSaves.length, 2); + startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[1]._id}-info`, + doc_id: savedDocs[1]._id, + transitions_started: startedSave.transitions_started, + }); + txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers(Object.keys(txnSave.transitions), ['default_responses', 'update_clinics']); + assert.equal(txnSave.transitions.default_responses.ok, true); + assert.equal(txnSave.transitions.update_clinics.ok, true); assert.deepEqualExcluding(savedDocs[2], originalDocs[2], '_id'); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[2]._id); + // no transition changed this doc, so it is saved as-is with a single infodoc write (no marker) assert.equal(infodocSaves.length, 1); + assert.isUndefined(infodocSaves[0][0].transitions_started); assert.equal(savedDocs[3].id, 'random form with contact'); assert.equal(savedDocs[3].sent_by, 'Angela'); assert.deepEqualExcluding(savedDocs[3], originalDocs[3], 'sent_by'); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[3]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.default_responses.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_sent_by.ok, true); + assert.equal(infodocSaves.length, 2); + startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[3]._id}-info`, + doc_id: savedDocs[3]._id, + transitions_started: startedSave.transitions_started, + }); + txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers(Object.keys(txnSave.transitions), ['default_responses', 'update_sent_by']); + assert.equal(txnSave.transitions.default_responses.ok, true); + assert.equal(txnSave.transitions.update_sent_by.ok, true); assert.equal(savedDocs[4].id, 'will have errors'); assert.equal(savedDocs[4].sent_by, 'Angela'); @@ -609,11 +727,24 @@ describe('functional transitions', () => { assert.equal(savedDocs[4].tasks[1].messages[0].message, 'too much randomness'); assert.deepEqualExcluding(savedDocs[4], originalDocs[4], ['_id', 'sent_by', 'errors', 'tasks']); infodocSaves = db.sentinel.put.args.filter(args => args[0].doc_id === savedDocs[4]._id); - assert.equal(infodocSaves.length, 1); - assert.equal(infodocSaves[0][0].transitions.default_responses.ok, true); - assert.equal(infodocSaves[0][0].transitions.update_sent_by.ok, true); - assert.equal(infodocSaves[0][0].transitions.accept_patient_reports.ok, true); - assert.equal(infodocSaves[0][0].transitions.conditional_alerts.ok, true); + assert.equal(infodocSaves.length, 2); + startedSave = infodocSaves.find(args => args[0].transitions_started)[0]; + assert.isString(startedSave.transitions_started); + assert.deepEqual(startedSave, { + id: `${savedDocs[4]._id}-info`, + doc_id: savedDocs[4]._id, + transitions_started: startedSave.transitions_started, + }); + txnSave = infodocSaves.find(args => args[0].transitions)[0]; + assert.isUndefined(txnSave.transitions_started); + assert.sameMembers( + Object.keys(txnSave.transitions), + ['default_responses', 'update_sent_by', 'accept_patient_reports', 'conditional_alerts'] + ); + assert.equal(txnSave.transitions.default_responses.ok, true); + assert.equal(txnSave.transitions.update_sent_by.ok, true); + assert.equal(txnSave.transitions.accept_patient_reports.ok, true); + assert.equal(txnSave.transitions.conditional_alerts.ok, true); }); }); }); diff --git a/shared-libs/transitions/test/unit/finalize-transition.js b/shared-libs/transitions/test/unit/finalize-transition.js index b2751759066..1eb3e1ec183 100644 --- a/shared-libs/transitions/test/unit/finalize-transition.js +++ b/shared-libs/transitions/test/unit/finalize-transition.js @@ -22,41 +22,54 @@ describe('finalize transition', () => { ); }); - it('save is called if transition results have changes', done => { + it('save is called if transition results have changes (API branch marks transitions started)', done => { const doc = { _rev: '1' }; - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + const saveDoc = sinon.stub(db.medic, 'put').resolves({ ok: true, rev: '2' }); + const markTransitionsStarted = sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + const clearTransitionsStarted = sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); sinon.stub(infodoc, 'saveTransitions').resolves(); transitions.finalize( { - change: { doc: doc }, + change: { id: 'abc', doc: doc }, results: [null, null, true], + markStarted: true, }, (err, result) => { assert.equal(saveDoc.callCount, 1); assert(saveDoc.args[0][0]._rev); assert(!err); - assert.deepEqual(result, { ok: true }); + assert.deepEqual(result, { ok: true, rev: '2' }); + assert.equal(markTransitionsStarted.callCount, 1); + assert.deepEqual(markTransitionsStarted.args[0], ['abc']); assert.equal(infodoc.saveTransitions.callCount, 1); + assert.strictEqual(infodoc.saveTransitions.args[0][1], true); + assert.equal(clearTransitionsStarted.callCount, 0); done(); } ); }); - it('should callback with save errors', done => { + it('should callback with save errors and clear the mid-write marker (API branch)', done => { const doc = { _rev: '1' }; - const saveDoc = sinon.stub(db.medic, 'put').callsArgWith(1, { error: 'something' }); + const saveDoc = sinon.stub(db.medic, 'put').rejects({ error: 'something' }); + const markTransitionsStarted = sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + const clearTransitionsStarted = sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); sinon.stub(infodoc, 'saveTransitions').resolves(); transitions.finalize( { - change: { doc: doc }, + change: { id: 'abc', doc: doc }, results: [null, null, true], + markStarted: true, }, (err, result) => { assert.deepEqual(err, { error: 'something' }); assert.equal(saveDoc.callCount, 1); assert(saveDoc.args[0][0]._rev); assert(!result); + assert.equal(markTransitionsStarted.callCount, 1); assert.equal(infodoc.saveTransitions.callCount, 0); + assert.equal(clearTransitionsStarted.callCount, 1); + assert.deepEqual(clearTransitionsStarted.args[0], ['abc']); done(); } ); diff --git a/shared-libs/transitions/test/unit/process_docs.js b/shared-libs/transitions/test/unit/process_docs.js index 2f6e7493007..fe844862d67 100644 --- a/shared-libs/transitions/test/unit/process_docs.js +++ b/shared-libs/transitions/test/unit/process_docs.js @@ -99,8 +99,10 @@ describe('processDocs', () => { sinon.stub(infodoc, 'bulkGet').resolves(infoDocs); sinon.stub(infodoc, 'bulkUpdate').resolves(); sinon.stub(transitions, 'applyTransition'); - sinon.stub(db.medic, 'put').callsArgWith(1, null, { ok: true }); + sinon.stub(db.medic, 'put').resolves({ ok: true }); sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); // first doc is updated by at least one transition transitions.applyTransition @@ -148,6 +150,8 @@ describe('processDocs', () => { chai.expect(infodoc.saveTransitions.callCount).to.equal(3); chai.expect(infodoc.saveTransitions.calledWithMatch({ id: '1' })).to.equal(true); + chai.expect(infodoc.markTransitionsStarted.callCount).to.equal(1); + chai.expect(infodoc.markTransitionsStarted.calledWith('1')).to.equal(true); }); }); @@ -173,11 +177,13 @@ describe('processDocs', () => { sinon.stub(infodoc, 'bulkUpdate').resolves(); sinon.stub(transitions, 'applyTransition'); sinon.stub(db.medic, 'put') - .withArgs(sinon.match({ _id: '1' })).callsArgWith(1, null, { ok: true }) - .withArgs(sinon.match({ _id: '2' })).callsArgWith(1, { error: 'error' }) - .withArgs(sinon.match({ _id: '3' })).callsArgWith(1, null, { ok: true }) - .withArgs(sinon.match({ _id: '4' })).callsArgWith(1, { error: 'error' }); + .withArgs(sinon.match({ _id: '1' })).resolves({ ok: true }) + .withArgs(sinon.match({ _id: '2' })).rejects({ error: 'error' }) + .withArgs(sinon.match({ _id: '3' })).resolves({ ok: true }) + .withArgs(sinon.match({ _id: '4' })).rejects({ error: 'error' }); sinon.stub(infodoc, 'saveTransitions').resolves(); + sinon.stub(infodoc, 'markTransitionsStarted').resolves(); + sinon.stub(infodoc, 'clearTransitionsStarted').resolves(); // first doc is updated by at least one transition transitions.applyTransition @@ -231,6 +237,9 @@ describe('processDocs', () => { chai.expect(infodoc.saveTransitions.callCount).to.equal(3); chai.expect(infodoc.saveTransitions.calledWithMatch({ id: '1' })).to.equal(true); + chai.expect(infodoc.markTransitionsStarted.callCount).to.equal(2); + chai.expect(infodoc.clearTransitionsStarted.callCount).to.equal(1); + chai.expect(infodoc.clearTransitionsStarted.calledWith('2')).to.equal(true); }); }); }); From 74d54a3906ce1e8dd0b7ff3dfb3726f838dece25 Mon Sep 17 00:00:00 2001 From: Tom Wier Date: Thu, 18 Jun 2026 15:44:21 +0300 Subject: [PATCH 5/5] feat(#10748): remove docs_by_lineage view --- .../services/lineage-model-generator.spec.js | 99 +++++---- config/default/app_settings.json | 4 +- .../views/docs_by_id_lineage/map.js | 20 -- .../cht-datasource/src/local/libs/lineage.ts | 33 ++- .../test/local/libs/doc.spec.ts | 12 +- .../test/local/libs/lineage.spec.ts | 22 +- shared-libs/lineage/src/hydration.js | 27 ++- shared-libs/lineage/test/hydration.spec.js | 19 +- tests/integration/api/server.spec.js | 9 +- .../lineage-model-generator.service.spec.ts | 124 +++++------ .../unit/views/docs_by_id_lineage.spec.js | 198 ------------------ 11 files changed, 209 insertions(+), 358 deletions(-) delete mode 100644 ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js delete mode 100644 webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js diff --git a/admin/tests/unit/services/lineage-model-generator.spec.js b/admin/tests/unit/services/lineage-model-generator.spec.js index dd308a15294..fd1178822a6 100644 --- a/admin/tests/unit/services/lineage-model-generator.spec.js +++ b/admin/tests/unit/services/lineage-model-generator.spec.js @@ -5,14 +5,16 @@ describe('LineageModelGenerator service', () => { let service; let dbQuery; let dbAllDocs; + let dbGet; beforeEach(() => { module('adminApp'); module($provide => { dbQuery = sinon.stub(); dbAllDocs = sinon.stub(); + dbGet = sinon.stub(); $provide.value('$q', Q); // bypass $q so we don't have to digest - $provide.factory('DB', KarmaUtils.mockDB({ query: dbQuery, allDocs: dbAllDocs })); + $provide.factory('DB', KarmaUtils.mockDB({ query: dbQuery, allDocs: dbAllDocs, get: dbGet })); }); inject(_LineageModelGenerator_ => service = _LineageModelGenerator_); }); @@ -20,7 +22,7 @@ describe('LineageModelGenerator service', () => { describe('contact', () => { it('handles not found', done => { - dbQuery.returns(Promise.resolve({ rows: [] })); + dbGet.returns(Promise.reject({ status: 404 })); service.contact('a') .then(() => { done(new Error('expected error to be thrown')); @@ -34,9 +36,7 @@ describe('LineageModelGenerator service', () => { it('handles no lineage', () => { const contact = { _id: 'a', _rev: '1' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact } - ] })); + dbGet.returns(Promise.resolve(contact)); return service.contact('a').then(model => { chai.expect(model._id).to.equal('a'); chai.expect(model.doc).to.deep.equal(contact); @@ -44,22 +44,21 @@ describe('LineageModelGenerator service', () => { }); it('binds lineage', () => { - const contact = { _id: 'a', _rev: '1' }; - const parent = { _id: 'b', _rev: '1' }; + const contact = { _id: 'a', _rev: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.withArgs('a').returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); return service.contact('a').then(model => { - chai.expect(dbQuery.callCount).to.equal(1); - chai.expect(dbQuery.args[0][0]).to.equal('medic-client/docs_by_id_lineage'); - chai.expect(dbQuery.args[0][1]).to.deep.equal({ - startkey: [ 'a' ], - endkey: [ 'a', {} ], - include_docs: true - }); + chai.expect(dbGet.callCount).to.equal(1); + chai.expect(dbAllDocs.callCount).to.equal(1); + chai.expect(dbAllDocs.args[0][0].keys).to.deep.equal(['b', 'c']); chai.expect(model._id).to.equal('a'); chai.expect(model.doc).to.deep.equal(contact); chai.expect(model.lineage).to.deep.equal([ parent, grandparent ]); @@ -67,17 +66,23 @@ describe('LineageModelGenerator service', () => { }); it('binds contacts', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'b', parent: { _id: 'c' } } }; const contactsContact = { _id: 'd', name: 'dave' }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' }, parent: { _id: 'c' } }; const parentsContact = { _id: 'e', name: 'eliza' }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); - dbAllDocs.returns(Promise.resolve({ rows: [ + dbAllDocs.withArgs({ + keys: sinon.match.array.deepEquals(['d', 'e']), + include_docs: true + }).returns(Promise.resolve({ rows: [ { doc: contactsContact }, { doc: parentsContact } ] })); @@ -89,23 +94,35 @@ describe('LineageModelGenerator service', () => { }); it('hydrates lineage contacts - #3812', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' } }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' }, parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; const parentContact = { _id: 'd', name: 'donny' }; const grandparentContact = { _id: 'e', name: 'erica' }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + const xContact = { _id: 'x', name: 'xavier' }; + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); - dbAllDocs.returns(Promise.resolve({ rows: [ + dbAllDocs.withArgs({ + keys: sinon.match.array.deepEquals(['x', 'd', 'e']), + include_docs: true + }).returns(Promise.resolve({ rows: [ + { doc: xContact }, { doc: parentContact }, { doc: grandparentContact } ] })); return service.contact('a').then(model => { - chai.expect(dbAllDocs.callCount).to.equal(1); + chai.expect(dbAllDocs.callCount).to.equal(2); chai.expect(dbAllDocs.args[0][0]).to.deep.equal({ + keys: [ 'b', 'c' ], + include_docs: true + }); + chai.expect(dbAllDocs.args[1][0]).to.deep.equal({ keys: [ 'x', 'd', 'e' ], include_docs: true }); @@ -116,7 +133,7 @@ describe('LineageModelGenerator service', () => { it('merges lineage when merge passed', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; - const parent = { _id: 'b', name: '2' }; + const parent = { _id: 'b', name: '2', parent: { _id: 'c' } }; const grandparent = { _id: 'c', name: '3' }; const expected = { _id: 'a', @@ -147,8 +164,11 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.withArgs(sinon.match({ + keys: sinon.match.array.deepEquals(['b', 'c']), + include_docs: true + })).returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); @@ -160,8 +180,9 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ rows: - [{ doc: contact, key: ['a', 0] }, { doc: parent, key: ['a', 1] }, { key: ['a', 2] }, { key: ['a', 3] }] + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: + [{ doc: parent, id: 'b' }, { id: 'c' }, { id: 'd' }] }); const expected = { _id: 'a', @@ -180,11 +201,11 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members v2', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ rows: [ - { doc: contact, key: ['a', 0] }, - { doc: parent, key: ['a', 1] }, - { key: ['a', 2] }, - { key: ['a', 3], doc: { _id: 'd', name: '4' } } + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ + { doc: parent, id: 'b' }, + { id: 'c' }, + { id: 'd', doc: { _id: 'd', name: '4' } } ] }); const expected = { _id: 'a', @@ -229,8 +250,8 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.returns(Promise.resolve({ rows: [ - { doc: contact }, + dbGet.returns(Promise.resolve(contact)); + dbAllDocs.returns(Promise.resolve({ rows: [ { doc: parent }, { doc: grandparent } ] })); diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 1d91b088dbb..01117c717ed 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -365,9 +365,9 @@ "person": true } ], - "contact_summary": "var ContactSummary = {}; /*! For license information please see contact-summary.js.LICENSE.txt */\n!function(e,t){if('object'==typeof exports&&'object'==typeof module)module.exports=t();else if('function'==typeof define&&define.amd)define([],t);else{var n=t();for(var r in n)('object'==typeof exports?exports:e)[r]=n[r]}}(ContactSummary,(()=>(()=>{var e={344:(e,t,n)=>{var r=n(972),i=n(597);e.exports=i(r,contact,reports)},420:function(e,t,n){(e=n.nmd(e)).exports=function(){'use strict';var t,n;function r(){return t.apply(null,arguments)}function i(e){t=e}function s(e){return e instanceof Array||'[object Array]'===Object.prototype.toString.call(e)}function a(e){return null!=e&&'[object Object]'===Object.prototype.toString.call(e)}function o(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function l(e){if(Object.getOwnPropertyNames)return 0===Object.getOwnPropertyNames(e).length;var t;for(t in e)if(o(e,t))return!1;return!0}function u(e){return void 0===e}function d(e){return'number'==typeof e||'[object Number]'===Object.prototype.toString.call(e)}function c(e){return e instanceof Date||'[object Date]'===Object.prototype.toString.call(e)}function h(e,t){var n,r=[],i=e.length;for(n=0;n>>0;for(t=0;t0)for(n=0;n=0?n?'+':'':'-')+Math.pow(10,Math.max(0,i)).toString().substr(1)+r}var U=/(\\[[^\\[]*\\])|(\\\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,H=/(\\[[^\\[]*\\])|(\\\\)?(LTS|LT|LL?L?L?|l{1,4})/g,A={},E={};function L(e,t,n,r){var i=r;'string'==typeof r&&(i=function(){return this[r]()}),e&&(E[e]=i),t&&(E[t[0]]=function(){return F(i.apply(this,arguments),t[1],t[2])}),n&&(E[n]=function(){return this.localeData().ordinal(i.apply(this,arguments),e)})}function V(e){return e.match(/\\[[\\s\\S]/)?e.replace(/^\\[|\\]$/g,''):e.replace(/\\\\/g,'')}function I(e){var t,n,r=e.match(U);for(t=0,n=r.length;t=0&&H.test(e);)e=e.replace(H,r),H.lastIndex=0,n-=1;return e}var Z={LTS:'h:mm:ss A',LT:'h:mm A',L:'MM/DD/YYYY',LL:'MMMM D, YYYY',LLL:'MMMM D, YYYY h:mm A',LLLL:'dddd, MMMM D, YYYY h:mm A'};function z(e){var t=this._longDateFormat[e],n=this._longDateFormat[e.toUpperCase()];return t||!n?t:(this._longDateFormat[e]=n.match(U).map((function(e){return'MMMM'===e||'MM'===e||'DD'===e||'dddd'===e?e.slice(1):e})).join(''),this._longDateFormat[e])}var q='Invalid date';function $(){return this._invalidDate}var B='%d',J=/\\d{1,2}/;function Q(e){return this._ordinal.replace('%d',e)}var X={future:'in %s',past:'%s ago',s:'a few seconds',ss:'%d seconds',m:'a minute',mm:'%d minutes',h:'an hour',hh:'%d hours',d:'a day',dd:'%d days',w:'a week',ww:'%d weeks',M:'a month',MM:'%d months',y:'a year',yy:'%d years'};function K(e,t,n,r){var i=this._relativeTime[n];return x(i)?i(e,t,n,r):i.replace(/%d/i,e)}function ee(e,t){var n=this._relativeTime[e>0?'future':'past'];return x(n)?n(t):n.replace(/%s/i,t)}var te={D:'date',dates:'date',date:'date',d:'day',days:'day',day:'day',e:'weekday',weekdays:'weekday',weekday:'weekday',E:'isoWeekday',isoweekdays:'isoWeekday',isoweekday:'isoWeekday',DDD:'dayOfYear',dayofyears:'dayOfYear',dayofyear:'dayOfYear',h:'hour',hours:'hour',hour:'hour',ms:'millisecond',milliseconds:'millisecond',millisecond:'millisecond',m:'minute',minutes:'minute',minute:'minute',M:'month',months:'month',month:'month',Q:'quarter',quarters:'quarter',quarter:'quarter',s:'second',seconds:'second',second:'second',gg:'weekYear',weekyears:'weekYear',weekyear:'weekYear',GG:'isoWeekYear',isoweekyears:'isoWeekYear',isoweekyear:'isoWeekYear',w:'week',weeks:'week',week:'week',W:'isoWeek',isoweeks:'isoWeek',isoweek:'isoWeek',y:'year',years:'year',year:'year'};function ne(e){return'string'==typeof e?te[e]||te[e.toLowerCase()]:void 0}function re(e){var t,n,r={};for(n in e)o(e,n)&&(t=ne(n))&&(r[t]=e[n]);return r}var ie={date:9,day:11,weekday:11,isoWeekday:11,dayOfYear:4,hour:13,millisecond:16,minute:14,month:8,quarter:7,second:15,weekYear:1,isoWeekYear:1,week:5,isoWeek:5,year:1};function se(e){var t,n=[];for(t in e)o(e,t)&&n.push({unit:t,priority:ie[t]});return n.sort((function(e,t){return e.priority-t.priority})),n}var ae,oe=/\\d/,le=/\\d\\d/,ue=/\\d{3}/,de=/\\d{4}/,ce=/[+-]?\\d{6}/,he=/\\d\\d?/,fe=/\\d\\d\\d\\d?/,_e=/\\d\\d\\d\\d\\d\\d?/,me=/\\d{1,3}/,pe=/\\d{1,4}/,ye=/[+-]?\\d{1,6}/,ge=/\\d+/,ve=/[+-]?\\d+/,we=/Z|[+-]\\d\\d:?\\d\\d/gi,ke=/Z|[+-]\\d\\d(?::?\\d\\d)?/gi,De=/[+-]?\\d+(\\.\\d{1,3})?/,Me=/[0-9]{0,256}['a-z\\u00A0-\\u05FF\\u0700-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFF07\\uFF10-\\uFFEF]{1,256}|[\\u0600-\\u06FF\\/]{1,256}(\\s*?[\\u0600-\\u06FF]{1,256}){1,2}/i,Se=/^[1-9]\\d?/,Ye=/^([1-9]\\d|\\d)/;function be(e,t,n){ae[e]=x(t)?t:function(e,r){return e&&n?n:t}}function Oe(e,t){return o(ae,e)?ae[e](t._strict,t._locale):new RegExp(Te(e))}function Te(e){return xe(e.replace('\\\\','').replace(/\\\\(\\[)|\\\\(\\])|\\[([^\\]\\[]*)\\]|\\\\(.)/g,(function(e,t,n,r,i){return t||n||r||i})))}function xe(e){return e.replace(/[-\\/\\\\^$*+?.()|[\\]{}]/g,'\\\\$&')}function Ne(e){return e<0?Math.ceil(e)||0:Math.floor(e)}function Pe(e){var t=+e,n=0;return 0!==t&&isFinite(t)&&(n=Ne(t)),n}ae={};var Re={};function Ce(e,t){var n,r,i=t;for('string'==typeof e&&(e=[e]),d(t)&&(i=function(e,n){n[t]=Pe(e)}),r=e.length,n=0;n68?1900:2e3)};var qe,$e=Je('FullYear',!0);function Be(){return Ue(this.year())}function Je(e,t){return function(n){return null!=n?(Xe(this,e,n),r.updateOffset(this,t),this):Qe(this,e)}}function Qe(e,t){if(!e.isValid())return NaN;var n=e._d,r=e._isUTC;switch(t){case'Milliseconds':return r?n.getUTCMilliseconds():n.getMilliseconds();case'Seconds':return r?n.getUTCSeconds():n.getSeconds();case'Minutes':return r?n.getUTCMinutes():n.getMinutes();case'Hours':return r?n.getUTCHours():n.getHours();case'Date':return r?n.getUTCDate():n.getDate();case'Day':return r?n.getUTCDay():n.getDay();case'Month':return r?n.getUTCMonth():n.getMonth();case'FullYear':return r?n.getUTCFullYear():n.getFullYear();default:return NaN}}function Xe(e,t,n){var r,i,s,a,o;if(e.isValid()&&!isNaN(n)){switch(r=e._d,i=e._isUTC,t){case'Milliseconds':return void(i?r.setUTCMilliseconds(n):r.setMilliseconds(n));case'Seconds':return void(i?r.setUTCSeconds(n):r.setSeconds(n));case'Minutes':return void(i?r.setUTCMinutes(n):r.setMinutes(n));case'Hours':return void(i?r.setUTCHours(n):r.setHours(n));case'Date':return void(i?r.setUTCDate(n):r.setDate(n));case'FullYear':break;default:return}s=n,a=e.month(),o=29!==(o=e.date())||1!==a||Ue(s)?o:28,i?r.setUTCFullYear(s,a,o):r.setFullYear(s,a,o)}}function Ke(e){return x(this[e=ne(e)])?this[e]():this}function et(e,t){if('object'==typeof e){var n,r=se(e=re(e)),i=r.length;for(n=0;n=0?(o=new Date(e+400,t,n,r,i,s,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,r,i,s,a),o}function vt(e){var t,n;return e<100&&e>=0?((n=Array.prototype.slice.call(arguments))[0]=e+400,t=new Date(Date.UTC.apply(null,n)),isFinite(t.getUTCFullYear())&&t.setUTCFullYear(e)):t=new Date(Date.UTC.apply(null,arguments)),t}function wt(e,t,n){var r=7+t-n;return-(7+vt(e,0,r).getUTCDay()-t)%7+r-1}function kt(e,t,n,r,i){var s,a,o=1+7*(t-1)+(7+n-r)%7+wt(e,r,i);return o<=0?a=ze(s=e-1)+o:o>ze(e)?(s=e+1,a=o-ze(e)):(s=e,a=o),{year:s,dayOfYear:a}}function Dt(e,t,n){var r,i,s=wt(e.year(),t,n),a=Math.floor((e.dayOfYear()-s-1)/7)+1;return a<1?r=a+Mt(i=e.year()-1,t,n):a>Mt(e.year(),t,n)?(r=a-Mt(e.year(),t,n),i=e.year()+1):(i=e.year(),r=a),{week:r,year:i}}function Mt(e,t,n){var r=wt(e,t,n),i=wt(e+1,t,n);return(ze(e)-r+i)/7}function St(e){return Dt(e,this._week.dow,this._week.doy).week}L('w',['ww',2],'wo','week'),L('W',['WW',2],'Wo','isoWeek'),be('w',he,Se),be('ww',he,le),be('W',he,Se),be('WW',he,le),We(['w','ww','W','WW'],(function(e,t,n,r){t[r.substr(0,1)]=Pe(e)}));var Yt={dow:0,doy:6};function bt(){return this._week.dow}function Ot(){return this._week.doy}function Tt(e){var t=this.localeData().week(this);return null==e?t:this.add(7*(e-t),'d')}function xt(e){var t=Dt(this,1,4).week;return null==e?t:this.add(7*(e-t),'d')}function Nt(e,t){return'string'!=typeof e?e:isNaN(e)?'number'==typeof(e=t.weekdaysParse(e))?e:null:parseInt(e,10)}function Pt(e,t){return'string'==typeof e?t.weekdaysParse(e)%7||7:isNaN(e)?null:e}function Rt(e,t){return e.slice(t,7).concat(e.slice(0,t))}L('d',0,'do','day'),L('dd',0,0,(function(e){return this.localeData().weekdaysMin(this,e)})),L('ddd',0,0,(function(e){return this.localeData().weekdaysShort(this,e)})),L('dddd',0,0,(function(e){return this.localeData().weekdays(this,e)})),L('e',0,0,'weekday'),L('E',0,0,'isoWeekday'),be('d',he),be('e',he),be('E',he),be('dd',(function(e,t){return t.weekdaysMinRegex(e)})),be('ddd',(function(e,t){return t.weekdaysShortRegex(e)})),be('dddd',(function(e,t){return t.weekdaysRegex(e)})),We(['dd','ddd','dddd'],(function(e,t,n,r){var i=n._locale.weekdaysParse(e,r,n._strict);null!=i?t.d=i:p(n).invalidWeekday=e})),We(['d','e','E'],(function(e,t,n,r){t[r]=Pe(e)}));var Ct='Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'),Wt='Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'),Ft='Su_Mo_Tu_We_Th_Fr_Sa'.split('_'),Ut=Me,Ht=Me,At=Me;function Et(e,t){var n=s(this._weekdays)?this._weekdays:this._weekdays[e&&!0!==e&&this._weekdays.isFormat.test(t)?'format':'standalone'];return!0===e?Rt(n,this._week.dow):e?n[e.day()]:n}function Lt(e){return!0===e?Rt(this._weekdaysShort,this._week.dow):e?this._weekdaysShort[e.day()]:this._weekdaysShort}function Vt(e){return!0===e?Rt(this._weekdaysMin,this._week.dow):e?this._weekdaysMin[e.day()]:this._weekdaysMin}function It(e,t,n){var r,i,s,a=e.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],r=0;r<7;++r)s=_([2e3,1]).day(r),this._minWeekdaysParse[r]=this.weekdaysMin(s,'').toLocaleLowerCase(),this._shortWeekdaysParse[r]=this.weekdaysShort(s,'').toLocaleLowerCase(),this._weekdaysParse[r]=this.weekdays(s,'').toLocaleLowerCase();return n?'dddd'===t?-1!==(i=qe.call(this._weekdaysParse,a))?i:null:'ddd'===t?-1!==(i=qe.call(this._shortWeekdaysParse,a))?i:null:-1!==(i=qe.call(this._minWeekdaysParse,a))?i:null:'dddd'===t?-1!==(i=qe.call(this._weekdaysParse,a))||-1!==(i=qe.call(this._shortWeekdaysParse,a))||-1!==(i=qe.call(this._minWeekdaysParse,a))?i:null:'ddd'===t?-1!==(i=qe.call(this._shortWeekdaysParse,a))||-1!==(i=qe.call(this._weekdaysParse,a))||-1!==(i=qe.call(this._minWeekdaysParse,a))?i:null:-1!==(i=qe.call(this._minWeekdaysParse,a))||-1!==(i=qe.call(this._weekdaysParse,a))||-1!==(i=qe.call(this._shortWeekdaysParse,a))?i:null}function Gt(e,t,n){var r,i,s;if(this._weekdaysParseExact)return It.call(this,e,t,n);for(this._weekdaysParse||(this._weekdaysParse=[],this._minWeekdaysParse=[],this._shortWeekdaysParse=[],this._fullWeekdaysParse=[]),r=0;r<7;r++){if(i=_([2e3,1]).day(r),n&&!this._fullWeekdaysParse[r]&&(this._fullWeekdaysParse[r]=new RegExp('^'+this.weekdays(i,'').replace('.','\\\\.?')+'$','i'),this._shortWeekdaysParse[r]=new RegExp('^'+this.weekdaysShort(i,'').replace('.','\\\\.?')+'$','i'),this._minWeekdaysParse[r]=new RegExp('^'+this.weekdaysMin(i,'').replace('.','\\\\.?')+'$','i')),this._weekdaysParse[r]||(s='^'+this.weekdays(i,'')+'|^'+this.weekdaysShort(i,'')+'|^'+this.weekdaysMin(i,''),this._weekdaysParse[r]=new RegExp(s.replace('.',''),'i')),n&&'dddd'===t&&this._fullWeekdaysParse[r].test(e))return r;if(n&&'ddd'===t&&this._shortWeekdaysParse[r].test(e))return r;if(n&&'dd'===t&&this._minWeekdaysParse[r].test(e))return r;if(!n&&this._weekdaysParse[r].test(e))return r}}function jt(e){if(!this.isValid())return null!=e?this:NaN;var t=Qe(this,'Day');return null!=e?(e=Nt(e,this.localeData()),this.add(e-t,'d')):t}function Zt(e){if(!this.isValid())return null!=e?this:NaN;var t=(this.day()+7-this.localeData()._week.dow)%7;return null==e?t:this.add(e-t,'d')}function zt(e){if(!this.isValid())return null!=e?this:NaN;if(null!=e){var t=Pt(e,this.localeData());return this.day(this.day()%7?t:t-7)}return this.day()||7}function qt(e){return this._weekdaysParseExact?(o(this,'_weekdaysRegex')||Jt.call(this),e?this._weekdaysStrictRegex:this._weekdaysRegex):(o(this,'_weekdaysRegex')||(this._weekdaysRegex=Ut),this._weekdaysStrictRegex&&e?this._weekdaysStrictRegex:this._weekdaysRegex)}function $t(e){return this._weekdaysParseExact?(o(this,'_weekdaysRegex')||Jt.call(this),e?this._weekdaysShortStrictRegex:this._weekdaysShortRegex):(o(this,'_weekdaysShortRegex')||(this._weekdaysShortRegex=Ht),this._weekdaysShortStrictRegex&&e?this._weekdaysShortStrictRegex:this._weekdaysShortRegex)}function Bt(e){return this._weekdaysParseExact?(o(this,'_weekdaysRegex')||Jt.call(this),e?this._weekdaysMinStrictRegex:this._weekdaysMinRegex):(o(this,'_weekdaysMinRegex')||(this._weekdaysMinRegex=At),this._weekdaysMinStrictRegex&&e?this._weekdaysMinStrictRegex:this._weekdaysMinRegex)}function Jt(){function e(e,t){return t.length-e.length}var t,n,r,i,s,a=[],o=[],l=[],u=[];for(t=0;t<7;t++)n=_([2e3,1]).day(t),r=xe(this.weekdaysMin(n,'')),i=xe(this.weekdaysShort(n,'')),s=xe(this.weekdays(n,'')),a.push(r),o.push(i),l.push(s),u.push(r),u.push(i),u.push(s);a.sort(e),o.sort(e),l.sort(e),u.sort(e),this._weekdaysRegex=new RegExp('^('+u.join('|')+')','i'),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp('^('+l.join('|')+')','i'),this._weekdaysShortStrictRegex=new RegExp('^('+o.join('|')+')','i'),this._weekdaysMinStrictRegex=new RegExp('^('+a.join('|')+')','i')}function Qt(){return this.hours()%12||12}function Xt(){return this.hours()||24}function Kt(e,t){L(e,0,0,(function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)}))}function en(e,t){return t._meridiemParse}function tn(e){return'p'===(e+'').toLowerCase().charAt(0)}L('H',['HH',2],0,'hour'),L('h',['hh',2],0,Qt),L('k',['kk',2],0,Xt),L('hmm',0,0,(function(){return''+Qt.apply(this)+F(this.minutes(),2)})),L('hmmss',0,0,(function(){return''+Qt.apply(this)+F(this.minutes(),2)+F(this.seconds(),2)})),L('Hmm',0,0,(function(){return''+this.hours()+F(this.minutes(),2)})),L('Hmmss',0,0,(function(){return''+this.hours()+F(this.minutes(),2)+F(this.seconds(),2)})),Kt('a',!0),Kt('A',!1),be('a',en),be('A',en),be('H',he,Ye),be('h',he,Se),be('k',he,Se),be('HH',he,le),be('hh',he,le),be('kk',he,le),be('hmm',fe),be('hmmss',_e),be('Hmm',fe),be('Hmmss',_e),Ce(['H','HH'],Le),Ce(['k','kk'],(function(e,t,n){var r=Pe(e);t[Le]=24===r?0:r})),Ce(['a','A'],(function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e})),Ce(['h','hh'],(function(e,t,n){t[Le]=Pe(e),p(n).bigHour=!0})),Ce('hmm',(function(e,t,n){var r=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r)),p(n).bigHour=!0})),Ce('hmmss',(function(e,t,n){var r=e.length-4,i=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r,2)),t[Ie]=Pe(e.substr(i)),p(n).bigHour=!0})),Ce('Hmm',(function(e,t,n){var r=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r))})),Ce('Hmmss',(function(e,t,n){var r=e.length-4,i=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r,2)),t[Ie]=Pe(e.substr(i))}));var nn=/[ap]\\.?m?\\.?/i,rn=Je('Hours',!0);function sn(e,t,n){return e>11?n?'pm':'PM':n?'am':'AM'}var an,on={calendar:C,longDateFormat:Z,invalidDate:q,ordinal:B,dayOfMonthOrdinalParse:J,relativeTime:X,months:rt,monthsShort:it,week:Yt,weekdays:Ct,weekdaysMin:Ft,weekdaysShort:Wt,meridiemParse:nn},ln={},un={};function dn(e,t){var n,r=Math.min(e.length,t.length);for(n=0;n0;){if(r=_n(i.slice(0,t).join('-')))return r;if(n&&n.length>=t&&dn(i,n)>=t-1)break;t--}s++}return an}function fn(e){return!(!e||!e.match('^[^/\\\\\\\\]*$'))}function _n(t){var n=null;if(void 0===ln[t]&&e&&e.exports&&fn(t))try{n=an._abbr,Object(function(){var e=new Error('Cannot find module \\'undefined\\'');throw e.code='MODULE_NOT_FOUND',e}()),mn(n)}catch(e){ln[t]=null}return ln[t]}function mn(e,t){var n;return e&&((n=u(t)?gn(e):pn(e,t))?an=n:'undefined'!=typeof console&&console.warn&&console.warn('Locale '+e+' not found. Did you forget to load it?')),an._abbr}function pn(e,t){if(null!==t){var n,r=on;if(t.abbr=e,null!=ln[e])T('defineLocaleOverride','use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info.'),r=ln[e]._config;else if(null!=t.parentLocale)if(null!=ln[t.parentLocale])r=ln[t.parentLocale]._config;else{if(null==(n=_n(t.parentLocale)))return un[t.parentLocale]||(un[t.parentLocale]=[]),un[t.parentLocale].push({name:e,config:t}),null;r=n._config}return ln[e]=new R(P(r,t)),un[e]&&un[e].forEach((function(e){pn(e.name,e.config)})),mn(e),ln[e]}return delete ln[e],null}function yn(e,t){if(null!=t){var n,r,i=on;null!=ln[e]&&null!=ln[e].parentLocale?ln[e].set(P(ln[e]._config,t)):(null!=(r=_n(e))&&(i=r._config),t=P(i,t),null==r&&(t.abbr=e),(n=new R(t)).parentLocale=ln[e],ln[e]=n),mn(e)}else null!=ln[e]&&(null!=ln[e].parentLocale?(ln[e]=ln[e].parentLocale,e===mn()&&mn(e)):null!=ln[e]&&delete ln[e]);return ln[e]}function gn(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return an;if(!s(e)){if(t=_n(e))return t;e=[e]}return hn(e)}function vn(){return b(ln)}function wn(e){var t,n=e._a;return n&&-2===p(e).overflow&&(t=n[Ae]<0||n[Ae]>11?Ae:n[Ee]<1||n[Ee]>nt(n[He],n[Ae])?Ee:n[Le]<0||n[Le]>24||24===n[Le]&&(0!==n[Ve]||0!==n[Ie]||0!==n[Ge])?Le:n[Ve]<0||n[Ve]>59?Ve:n[Ie]<0||n[Ie]>59?Ie:n[Ge]<0||n[Ge]>999?Ge:-1,p(e)._overflowDayOfYear&&(tEe)&&(t=Ee),p(e)._overflowWeeks&&-1===t&&(t=je),p(e)._overflowWeekday&&-1===t&&(t=Ze),p(e).overflow=t),e}var kn=/^\\s*((?:[+-]\\d{6}|\\d{4})-(?:\\d\\d-\\d\\d|W\\d\\d-\\d|W\\d\\d|\\d\\d\\d|\\d\\d))(?:(T| )(\\d\\d(?::\\d\\d(?::\\d\\d(?:[.,]\\d+)?)?)?)([+-]\\d\\d(?::?\\d\\d)?|\\s*Z)?)?$/,Dn=/^\\s*((?:[+-]\\d{6}|\\d{4})(?:\\d\\d\\d\\d|W\\d\\d\\d|W\\d\\d|\\d\\d\\d|\\d\\d|))(?:(T| )(\\d\\d(?:\\d\\d(?:\\d\\d(?:[.,]\\d+)?)?)?)([+-]\\d\\d(?::?\\d\\d)?|\\s*Z)?)?$/,Mn=/Z|[+-]\\d\\d(?::?\\d\\d)?/,Sn=[['YYYYYY-MM-DD',/[+-]\\d{6}-\\d\\d-\\d\\d/],['YYYY-MM-DD',/\\d{4}-\\d\\d-\\d\\d/],['GGGG-[W]WW-E',/\\d{4}-W\\d\\d-\\d/],['GGGG-[W]WW',/\\d{4}-W\\d\\d/,!1],['YYYY-DDD',/\\d{4}-\\d{3}/],['YYYY-MM',/\\d{4}-\\d\\d/,!1],['YYYYYYMMDD',/[+-]\\d{10}/],['YYYYMMDD',/\\d{8}/],['GGGG[W]WWE',/\\d{4}W\\d{3}/],['GGGG[W]WW',/\\d{4}W\\d{2}/,!1],['YYYYDDD',/\\d{7}/],['YYYYMM',/\\d{6}/,!1],['YYYY',/\\d{4}/,!1]],Yn=[['HH:mm:ss.SSSS',/\\d\\d:\\d\\d:\\d\\d\\.\\d+/],['HH:mm:ss,SSSS',/\\d\\d:\\d\\d:\\d\\d,\\d+/],['HH:mm:ss',/\\d\\d:\\d\\d:\\d\\d/],['HH:mm',/\\d\\d:\\d\\d/],['HHmmss.SSSS',/\\d\\d\\d\\d\\d\\d\\.\\d+/],['HHmmss,SSSS',/\\d\\d\\d\\d\\d\\d,\\d+/],['HHmmss',/\\d\\d\\d\\d\\d\\d/],['HHmm',/\\d\\d\\d\\d/],['HH',/\\d\\d/]],bn=/^\\/?Date\\((-?\\d+)/i,On=/^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\\s)?(\\d{1,2})\\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s(\\d{2,4})\\s(\\d\\d):(\\d\\d)(?::(\\d\\d))?\\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\\d{4}))$/,Tn={UT:0,GMT:0,EDT:-240,EST:-300,CDT:-300,CST:-360,MDT:-360,MST:-420,PDT:-420,PST:-480};function xn(e){var t,n,r,i,s,a,o=e._i,l=kn.exec(o)||Dn.exec(o),u=Sn.length,d=Yn.length;if(l){for(p(e).iso=!0,t=0,n=u;tze(s)||0===e._dayOfYear)&&(p(e)._overflowDayOfYear=!0),n=vt(s,0,e._dayOfYear),e._a[Ae]=n.getUTCMonth(),e._a[Ee]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=r[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[Le]&&0===e._a[Ve]&&0===e._a[Ie]&&0===e._a[Ge]&&(e._nextDay=!0,e._a[Le]=0),e._d=(e._useUTC?vt:gt).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[Le]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(p(e).weekdayMismatch=!0)}}function Ln(e){var t,n,r,i,s,a,o,l,u;null!=(t=e._w).GG||null!=t.W||null!=t.E?(s=1,a=4,n=Hn(t.GG,e._a[He],Dt(Bn(),1,4).year),r=Hn(t.W,1),((i=Hn(t.E,1))<1||i>7)&&(l=!0)):(s=e._locale._week.dow,a=e._locale._week.doy,u=Dt(Bn(),s,a),n=Hn(t.gg,e._a[He],u.year),r=Hn(t.w,u.week),null!=t.d?((i=t.d)<0||i>6)&&(l=!0):null!=t.e?(i=t.e+s,(t.e<0||t.e>6)&&(l=!0)):i=s),r<1||r>Mt(n,s,a)?p(e)._overflowWeeks=!0:null!=l?p(e)._overflowWeekday=!0:(o=kt(n,r,i,s,a),e._a[He]=o.year,e._dayOfYear=o.dayOfYear)}function Vn(e){if(e._f!==r.ISO_8601)if(e._f!==r.RFC_2822){e._a=[],p(e).empty=!0;var t,n,i,s,a,o,l,u=''+e._i,d=u.length,c=0;for(l=(i=j(e._f,e._locale).match(U)||[]).length,t=0;t0&&p(e).unusedInput.push(a),u=u.slice(u.indexOf(n)+n.length),c+=n.length),E[s]?(n?p(e).empty=!1:p(e).unusedTokens.push(s),Fe(s,n,e)):e._strict&&!n&&p(e).unusedTokens.push(s);p(e).charsLeftOver=d-c,u.length>0&&p(e).unusedInput.push(u),e._a[Le]<=12&&!0===p(e).bigHour&&e._a[Le]>0&&(p(e).bigHour=void 0),p(e).parsedDateParts=e._a.slice(0),p(e).meridiem=e._meridiem,e._a[Le]=In(e._locale,e._a[Le],e._meridiem),null!==(o=p(e).era)&&(e._a[He]=e._locale.erasConvertYear(o,e._a[He])),En(e),wn(e)}else Fn(e);else xn(e)}function In(e,t,n){var r;return null==n?t:null!=e.meridiemHour?e.meridiemHour(t,n):null!=e.isPM?((r=e.isPM(n))&&t<12&&(t+=12),r||12!==t||(t=0),t):t}function Gn(e){var t,n,r,i,s,a,o=!1,l=e._f.length;if(0===l)return p(e).invalidFormat=!0,void(e._d=new Date(NaN));for(i=0;ithis?this:e:g()}));function Xn(e,t){var n,r;if(1===t.length&&s(t[0])&&(t=t[0]),!t.length)return Bn();for(n=t[0],r=1;rthis.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()}function Dr(){if(!u(this._isDSTShifted))return this._isDSTShifted;var e,t={};return k(t,this),(t=zn(t))._a?(e=t._isUTC?_(t._a):Bn(t._a),this._isDSTShifted=this.isValid()&&ur(t._a,e.toArray())>0):this._isDSTShifted=!1,this._isDSTShifted}function Mr(){return!!this.isValid()&&!this._isUTC}function Sr(){return!!this.isValid()&&this._isUTC}function Yr(){return!!this.isValid()&&this._isUTC&&0===this._offset}r.updateOffset=function(){};var br=/^(-|\\+)?(?:(\\d*)[. ])?(\\d+):(\\d+)(?::(\\d+)(\\.\\d*)?)?$/,Or=/^(-|\\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/;function Tr(e,t){var n,r,i,s=e,a=null;return or(e)?s={ms:e._milliseconds,d:e._days,M:e._months}:d(e)||!isNaN(+e)?(s={},t?s[t]=+e:s.milliseconds=+e):(a=br.exec(e))?(n='-'===a[1]?-1:1,s={y:0,d:Pe(a[Ee])*n,h:Pe(a[Le])*n,m:Pe(a[Ve])*n,s:Pe(a[Ie])*n,ms:Pe(lr(1e3*a[Ge]))*n}):(a=Or.exec(e))?(n='-'===a[1]?-1:1,s={y:xr(a[2],n),M:xr(a[3],n),w:xr(a[4],n),d:xr(a[5],n),h:xr(a[6],n),m:xr(a[7],n),s:xr(a[8],n)}):null==s?s={}:'object'==typeof s&&('from'in s||'to'in s)&&(i=Pr(Bn(s.from),Bn(s.to)),(s={}).ms=i.milliseconds,s.M=i.months),r=new ar(s),or(e)&&o(e,'_locale')&&(r._locale=e._locale),or(e)&&o(e,'_isValid')&&(r._isValid=e._isValid),r}function xr(e,t){var n=e&&parseFloat(e.replace(',','.'));return(isNaN(n)?0:n)*t}function Nr(e,t){var n={};return n.months=t.month()-e.month()+12*(t.year()-e.year()),e.clone().add(n.months,'M').isAfter(t)&&--n.months,n.milliseconds=+t-+e.clone().add(n.months,'M'),n}function Pr(e,t){var n;return e.isValid()&&t.isValid()?(t=fr(t,e),e.isBefore(t)?n=Nr(e,t):((n=Nr(t,e)).milliseconds=-n.milliseconds,n.months=-n.months),n):{milliseconds:0,months:0}}function Rr(e,t){return function(n,r){var i;return null===r||isNaN(+r)||(T(t,'moment().'+t+'(period, number) is deprecated. Please use moment().'+t+'(number, period). See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info.'),i=n,n=r,r=i),Cr(this,Tr(n,r),e),this}}function Cr(e,t,n,i){var s=t._milliseconds,a=lr(t._days),o=lr(t._months);e.isValid()&&(i=null==i||i,o&&ht(e,Qe(e,'Month')+o*n),a&&Xe(e,'Date',Qe(e,'Date')+a*n),s&&e._d.setTime(e._d.valueOf()+s*n),i&&r.updateOffset(e,a||o))}Tr.fn=ar.prototype,Tr.invalid=sr;var Wr=Rr(1,'add'),Fr=Rr(-1,'subtract');function Ur(e){return'string'==typeof e||e instanceof String}function Hr(e){return M(e)||c(e)||Ur(e)||d(e)||Er(e)||Ar(e)||null==e}function Ar(e){var t,n,r=a(e)&&!l(e),i=!1,s=['years','year','y','months','month','M','days','day','d','dates','date','D','hours','hour','h','minutes','minute','m','seconds','second','s','milliseconds','millisecond','ms'],u=s.length;for(t=0;tn.valueOf():n.valueOf()9999?G(n,t?'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]':'YYYYYY-MM-DD[T]HH:mm:ss.SSSZ'):x(Date.prototype.toISOString)?t?this.toDate().toISOString():new Date(this.valueOf()+60*this.utcOffset()*1e3).toISOString().replace('Z',G(n,'Z')):G(n,t?'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]':'YYYY-MM-DD[T]HH:mm:ss.SSSZ')}function ei(){if(!this.isValid())return'moment.invalid(/* '+this._i+' */)';var e,t,n,r,i='moment',s='';return this.isLocal()||(i=0===this.utcOffset()?'moment.utc':'moment.parseZone',s='Z'),e='['+i+'(\"]',t=0<=this.year()&&this.year()<=9999?'YYYY':'YYYYYY',n='-MM-DD[T]HH:mm:ss.SSS',r=s+'[\")]',this.format(e+t+n+r)}function ti(e){e||(e=this.isUtc()?r.defaultFormatUtc:r.defaultFormat);var t=G(this,e);return this.localeData().postformat(t)}function ni(e,t){return this.isValid()&&(M(e)&&e.isValid()||Bn(e).isValid())?Tr({to:this,from:e}).locale(this.locale()).humanize(!t):this.localeData().invalidDate()}function ri(e){return this.from(Bn(),e)}function ii(e,t){return this.isValid()&&(M(e)&&e.isValid()||Bn(e).isValid())?Tr({from:this,to:e}).locale(this.locale()).humanize(!t):this.localeData().invalidDate()}function si(e){return this.to(Bn(),e)}function ai(e){var t;return void 0===e?this._locale._abbr:(null!=(t=gn(e))&&(this._locale=t),this)}r.defaultFormat='YYYY-MM-DDTHH:mm:ssZ',r.defaultFormatUtc='YYYY-MM-DDTHH:mm:ss[Z]';var oi=Y('moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.',(function(e){return void 0===e?this.localeData():this.locale(e)}));function li(){return this._locale}var ui=1e3,di=60*ui,ci=60*di,hi=3506328*ci;function fi(e,t){return(e%t+t)%t}function _i(e,t,n){return e<100&&e>=0?new Date(e+400,t,n)-hi:new Date(e,t,n).valueOf()}function mi(e,t,n){return e<100&&e>=0?Date.UTC(e+400,t,n)-hi:Date.UTC(e,t,n)}function pi(e){var t,n;if(void 0===(e=ne(e))||'millisecond'===e||!this.isValid())return this;switch(n=this._isUTC?mi:_i,e){case'year':t=n(this.year(),0,1);break;case'quarter':t=n(this.year(),this.month()-this.month()%3,1);break;case'month':t=n(this.year(),this.month(),1);break;case'week':t=n(this.year(),this.month(),this.date()-this.weekday());break;case'isoWeek':t=n(this.year(),this.month(),this.date()-(this.isoWeekday()-1));break;case'day':case'date':t=n(this.year(),this.month(),this.date());break;case'hour':t=this._d.valueOf(),t-=fi(t+(this._isUTC?0:this.utcOffset()*di),ci);break;case'minute':t=this._d.valueOf(),t-=fi(t,di);break;case'second':t=this._d.valueOf(),t-=fi(t,ui)}return this._d.setTime(t),r.updateOffset(this,!0),this}function yi(e){var t,n;if(void 0===(e=ne(e))||'millisecond'===e||!this.isValid())return this;switch(n=this._isUTC?mi:_i,e){case'year':t=n(this.year()+1,0,1)-1;break;case'quarter':t=n(this.year(),this.month()-this.month()%3+3,1)-1;break;case'month':t=n(this.year(),this.month()+1,1)-1;break;case'week':t=n(this.year(),this.month(),this.date()-this.weekday()+7)-1;break;case'isoWeek':t=n(this.year(),this.month(),this.date()-(this.isoWeekday()-1)+7)-1;break;case'day':case'date':t=n(this.year(),this.month(),this.date()+1)-1;break;case'hour':t=this._d.valueOf(),t+=ci-fi(t+(this._isUTC?0:this.utcOffset()*di),ci)-1;break;case'minute':t=this._d.valueOf(),t+=di-fi(t,di)-1;break;case'second':t=this._d.valueOf(),t+=ui-fi(t,ui)-1}return this._d.setTime(t),r.updateOffset(this,!0),this}function gi(){return this._d.valueOf()-6e4*(this._offset||0)}function vi(){return Math.floor(this.valueOf()/1e3)}function wi(){return new Date(this.valueOf())}function ki(){var e=this;return[e.year(),e.month(),e.date(),e.hour(),e.minute(),e.second(),e.millisecond()]}function Di(){var e=this;return{years:e.year(),months:e.month(),date:e.date(),hours:e.hours(),minutes:e.minutes(),seconds:e.seconds(),milliseconds:e.milliseconds()}}function Mi(){return this.isValid()?this.toISOString():null}function Si(){return y(this)}function Yi(){return f({},p(this))}function bi(){return p(this).overflow}function Oi(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}}function Ti(e,t){var n,i,s,a=this._eras||gn('en')._eras;for(n=0,i=a.length;n=0)return l[r]}function Ni(e,t){var n=e.since<=e.until?1:-1;return void 0===t?r(e.since).year():r(e.since).year()+(t-e.offset)*n}function Pi(){var e,t,n,r=this.localeData().eras();for(e=0,t=r.length;e(s=Mt(e,r,i))&&(t=s),Qi.call(this,e,t,n,r,i))}function Qi(e,t,n,r,i){var s=kt(e,t,n,r,i),a=vt(s.year,0,s.dayOfYear);return this.year(a.getUTCFullYear()),this.month(a.getUTCMonth()),this.date(a.getUTCDate()),this}function Xi(e){return null==e?Math.ceil((this.month()+1)/3):this.month(3*(e-1)+this.month()%3)}L('N',0,0,'eraAbbr'),L('NN',0,0,'eraAbbr'),L('NNN',0,0,'eraAbbr'),L('NNNN',0,0,'eraName'),L('NNNNN',0,0,'eraNarrow'),L('y',['y',1],'yo','eraYear'),L('y',['yy',2],0,'eraYear'),L('y',['yyy',3],0,'eraYear'),L('y',['yyyy',4],0,'eraYear'),be('N',Ai),be('NN',Ai),be('NNN',Ai),be('NNNN',Ei),be('NNNNN',Li),Ce(['N','NN','NNN','NNNN','NNNNN'],(function(e,t,n,r){var i=n._locale.erasParse(e,r,n._strict);i?p(n).era=i:p(n).invalidEra=e})),be('y',ge),be('yy',ge),be('yyy',ge),be('yyyy',ge),be('yo',Vi),Ce(['y','yy','yyy','yyyy'],He),Ce(['yo'],(function(e,t,n,r){var i;n._locale._eraYearOrdinalRegex&&(i=e.match(n._locale._eraYearOrdinalRegex)),n._locale.eraYearOrdinalParse?t[He]=n._locale.eraYearOrdinalParse(e,i):t[He]=parseInt(e,10)})),L(0,['gg',2],0,(function(){return this.weekYear()%100})),L(0,['GG',2],0,(function(){return this.isoWeekYear()%100})),Gi('gggg','weekYear'),Gi('ggggg','weekYear'),Gi('GGGG','isoWeekYear'),Gi('GGGGG','isoWeekYear'),be('G',ve),be('g',ve),be('GG',he,le),be('gg',he,le),be('GGGG',pe,de),be('gggg',pe,de),be('GGGGG',ye,ce),be('ggggg',ye,ce),We(['gggg','ggggg','GGGG','GGGGG'],(function(e,t,n,r){t[r.substr(0,2)]=Pe(e)})),We(['gg','GG'],(function(e,t,n,i){t[i]=r.parseTwoDigitYear(e)})),L('Q',0,'Qo','quarter'),be('Q',oe),Ce('Q',(function(e,t){t[Ae]=3*(Pe(e)-1)})),L('D',['DD',2],'Do','date'),be('D',he,Se),be('DD',he,le),be('Do',(function(e,t){return e?t._dayOfMonthOrdinalParse||t._ordinalParse:t._dayOfMonthOrdinalParseLenient})),Ce(['D','DD'],Ee),Ce('Do',(function(e,t){t[Ee]=Pe(e.match(he)[0])}));var Ki=Je('Date',!0);function es(e){var t=Math.round((this.clone().startOf('day')-this.clone().startOf('year'))/864e5)+1;return null==e?t:this.add(e-t,'d')}L('DDD',['DDDD',3],'DDDo','dayOfYear'),be('DDD',me),be('DDDD',ue),Ce(['DDD','DDDD'],(function(e,t,n){n._dayOfYear=Pe(e)})),L('m',['mm',2],0,'minute'),be('m',he,Ye),be('mm',he,le),Ce(['m','mm'],Ve);var ts=Je('Minutes',!1);L('s',['ss',2],0,'second'),be('s',he,Ye),be('ss',he,le),Ce(['s','ss'],Ie);var ns,rs,is=Je('Seconds',!1);for(L('S',0,0,(function(){return~~(this.millisecond()/100)})),L(0,['SS',2],0,(function(){return~~(this.millisecond()/10)})),L(0,['SSS',3],0,'millisecond'),L(0,['SSSS',4],0,(function(){return 10*this.millisecond()})),L(0,['SSSSS',5],0,(function(){return 100*this.millisecond()})),L(0,['SSSSSS',6],0,(function(){return 1e3*this.millisecond()})),L(0,['SSSSSSS',7],0,(function(){return 1e4*this.millisecond()})),L(0,['SSSSSSSS',8],0,(function(){return 1e5*this.millisecond()})),L(0,['SSSSSSSSS',9],0,(function(){return 1e6*this.millisecond()})),be('S',me,oe),be('SS',me,le),be('SSS',me,ue),ns='SSSS';ns.length<=9;ns+='S')be(ns,ge);function ss(e,t){t[Ge]=Pe(1e3*('0.'+e))}for(ns='S';ns.length<=9;ns+='S')Ce(ns,ss);function as(){return this._isUTC?'UTC':''}function os(){return this._isUTC?'Coordinated Universal Time':''}rs=Je('Milliseconds',!1),L('z',0,0,'zoneAbbr'),L('zz',0,0,'zoneName');var ls=D.prototype;function us(e){return Bn(1e3*e)}function ds(){return Bn.apply(null,arguments).parseZone()}function cs(e){return e}ls.add=Wr,ls.calendar=Ir,ls.clone=Gr,ls.diff=Jr,ls.endOf=yi,ls.format=ti,ls.from=ni,ls.fromNow=ri,ls.to=ii,ls.toNow=si,ls.get=Ke,ls.invalidAt=bi,ls.isAfter=jr,ls.isBefore=Zr,ls.isBetween=zr,ls.isSame=qr,ls.isSameOrAfter=$r,ls.isSameOrBefore=Br,ls.isValid=Si,ls.lang=oi,ls.locale=ai,ls.localeData=li,ls.max=Qn,ls.min=Jn,ls.parsingFlags=Yi,ls.set=et,ls.startOf=pi,ls.subtract=Fr,ls.toArray=ki,ls.toObject=Di,ls.toDate=wi,ls.toISOString=Kr,ls.inspect=ei,'undefined'!=typeof Symbol&&null!=Symbol.for&&(ls[Symbol.for('nodejs.util.inspect.custom')]=function(){return'Moment<'+this.format()+'>'}),ls.toJSON=Mi,ls.toString=Xr,ls.unix=vi,ls.valueOf=gi,ls.creationData=Oi,ls.eraName=Pi,ls.eraNarrow=Ri,ls.eraAbbr=Ci,ls.eraYear=Wi,ls.year=$e,ls.isLeapYear=Be,ls.weekYear=ji,ls.isoWeekYear=Zi,ls.quarter=ls.quarters=Xi,ls.month=ft,ls.daysInMonth=_t,ls.week=ls.weeks=Tt,ls.isoWeek=ls.isoWeeks=xt,ls.weeksInYear=$i,ls.weeksInWeekYear=Bi,ls.isoWeeksInYear=zi,ls.isoWeeksInISOWeekYear=qi,ls.date=Ki,ls.day=ls.days=jt,ls.weekday=Zt,ls.isoWeekday=zt,ls.dayOfYear=es,ls.hour=ls.hours=rn,ls.minute=ls.minutes=ts,ls.second=ls.seconds=is,ls.millisecond=ls.milliseconds=rs,ls.utcOffset=mr,ls.utc=yr,ls.local=gr,ls.parseZone=vr,ls.hasAlignedHourOffset=wr,ls.isDST=kr,ls.isLocal=Mr,ls.isUtcOffset=Sr,ls.isUtc=Yr,ls.isUTC=Yr,ls.zoneAbbr=as,ls.zoneName=os,ls.dates=Y('dates accessor is deprecated. Use date instead.',Ki),ls.months=Y('months accessor is deprecated. Use month instead',ft),ls.years=Y('years accessor is deprecated. Use year instead',$e),ls.zone=Y('moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/',pr),ls.isDSTShifted=Y('isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information',Dr);var hs=R.prototype;function fs(e,t,n,r){var i=gn(),s=_().set(r,t);return i[n](s,e)}function _s(e,t,n){if(d(e)&&(t=e,e=void 0),e=e||'',null!=t)return fs(e,t,n,'month');var r,i=[];for(r=0;r<12;r++)i[r]=fs(e,r,n,'month');return i}function ms(e,t,n,r){'boolean'==typeof e?(d(t)&&(n=t,t=void 0),t=t||''):(n=t=e,e=!1,d(t)&&(n=t,t=void 0),t=t||'');var i,s=gn(),a=e?s._week.dow:0,o=[];if(null!=n)return fs(t,(n+a)%7,r,'day');for(i=0;i<7;i++)o[i]=fs(t,(i+a)%7,r,'day');return o}function ps(e,t){return _s(e,t,'months')}function ys(e,t){return _s(e,t,'monthsShort')}function gs(e,t,n){return ms(e,t,n,'weekdays')}function vs(e,t,n){return ms(e,t,n,'weekdaysShort')}function ws(e,t,n){return ms(e,t,n,'weekdaysMin')}hs.calendar=W,hs.longDateFormat=z,hs.invalidDate=$,hs.ordinal=Q,hs.preparse=cs,hs.postformat=cs,hs.relativeTime=K,hs.pastFuture=ee,hs.set=N,hs.eras=Ti,hs.erasParse=xi,hs.erasConvertYear=Ni,hs.erasAbbrRegex=Ui,hs.erasNameRegex=Fi,hs.erasNarrowRegex=Hi,hs.months=lt,hs.monthsShort=ut,hs.monthsParse=ct,hs.monthsRegex=pt,hs.monthsShortRegex=mt,hs.week=St,hs.firstDayOfYear=Ot,hs.firstDayOfWeek=bt,hs.weekdays=Et,hs.weekdaysMin=Vt,hs.weekdaysShort=Lt,hs.weekdaysParse=Gt,hs.weekdaysRegex=qt,hs.weekdaysShortRegex=$t,hs.weekdaysMinRegex=Bt,hs.isPM=tn,hs.meridiem=sn,mn('en',{eras:[{since:'0001-01-01',until:1/0,offset:1,name:'Anno Domini',narrow:'AD',abbr:'AD'},{since:'0000-12-31',until:-1/0,offset:1,name:'Before Christ',narrow:'BC',abbr:'BC'}],dayOfMonthOrdinalParse:/\\d{1,2}(th|st|nd|rd)/,ordinal:function(e){var t=e%10;return e+(1===Pe(e%100/10)?'th':1===t?'st':2===t?'nd':3===t?'rd':'th')}}),r.lang=Y('moment.lang is deprecated. Use moment.locale instead.',mn),r.langData=Y('moment.langData is deprecated. Use moment.localeData instead.',gn);var ks=Math.abs;function Ds(){var e=this._data;return this._milliseconds=ks(this._milliseconds),this._days=ks(this._days),this._months=ks(this._months),e.milliseconds=ks(e.milliseconds),e.seconds=ks(e.seconds),e.minutes=ks(e.minutes),e.hours=ks(e.hours),e.months=ks(e.months),e.years=ks(e.years),this}function Ms(e,t,n,r){var i=Tr(t,n);return e._milliseconds+=r*i._milliseconds,e._days+=r*i._days,e._months+=r*i._months,e._bubble()}function Ss(e,t){return Ms(this,e,t,1)}function Ys(e,t){return Ms(this,e,t,-1)}function bs(e){return e<0?Math.floor(e):Math.ceil(e)}function Os(){var e,t,n,r,i,s=this._milliseconds,a=this._days,o=this._months,l=this._data;return s>=0&&a>=0&&o>=0||s<=0&&a<=0&&o<=0||(s+=864e5*bs(xs(o)+a),a=0,o=0),l.milliseconds=s%1e3,e=Ne(s/1e3),l.seconds=e%60,t=Ne(e/60),l.minutes=t%60,n=Ne(t/60),l.hours=n%24,a+=Ne(n/24),o+=i=Ne(Ts(a)),a-=bs(xs(i)),r=Ne(o/12),o%=12,l.days=a,l.months=o,l.years=r,this}function Ts(e){return 4800*e/146097}function xs(e){return 146097*e/4800}function Ns(e){if(!this.isValid())return NaN;var t,n,r=this._milliseconds;if('month'===(e=ne(e))||'quarter'===e||'year'===e)switch(t=this._days+r/864e5,n=this._months+Ts(t),e){case'month':return n;case'quarter':return n/3;case'year':return n/12}else switch(t=this._days+Math.round(xs(this._months)),e){case'week':return t/7+r/6048e5;case'day':return t+r/864e5;case'hour':return 24*t+r/36e5;case'minute':return 1440*t+r/6e4;case'second':return 86400*t+r/1e3;case'millisecond':return Math.floor(864e5*t)+r;default:throw new Error('Unknown unit '+e)}}function Ps(e){return function(){return this.as(e)}}var Rs=Ps('ms'),Cs=Ps('s'),Ws=Ps('m'),Fs=Ps('h'),Us=Ps('d'),Hs=Ps('w'),As=Ps('M'),Es=Ps('Q'),Ls=Ps('y'),Vs=Rs;function Is(){return Tr(this)}function Gs(e){return e=ne(e),this.isValid()?this[e+'s']():NaN}function js(e){return function(){return this.isValid()?this._data[e]:NaN}}var Zs=js('milliseconds'),zs=js('seconds'),qs=js('minutes'),$s=js('hours'),Bs=js('days'),Js=js('months'),Qs=js('years');function Xs(){return Ne(this.days()/7)}var Ks=Math.round,ea={ss:44,s:45,m:45,h:22,d:26,w:null,M:11};function ta(e,t,n,r,i){return i.relativeTime(t||1,!!n,e,r)}function na(e,t,n,r){var i=Tr(e).abs(),s=Ks(i.as('s')),a=Ks(i.as('m')),o=Ks(i.as('h')),l=Ks(i.as('d')),u=Ks(i.as('M')),d=Ks(i.as('w')),c=Ks(i.as('y')),h=s<=n.ss&&['s',s]||s0,h[4]=r,ta.apply(null,h)}function ra(e){return void 0===e?Ks:'function'==typeof e&&(Ks=e,!0)}function ia(e,t){return void 0!==ea[e]&&(void 0===t?ea[e]:(ea[e]=t,'s'===e&&(ea.ss=t-1),!0))}function sa(e,t){if(!this.isValid())return this.localeData().invalidDate();var n,r,i=!1,s=ea;return'object'==typeof e&&(t=e,e=!1),'boolean'==typeof e&&(i=e),'object'==typeof t&&(s=Object.assign({},ea,t),null!=t.s&&null==t.ss&&(s.ss=t.s-1)),r=na(this,!i,s,n=this.localeData()),i&&(r=n.pastFuture(+this,r)),n.postformat(r)}var aa=Math.abs;function oa(e){return(e>0)-(e<0)||+e}function la(){if(!this.isValid())return this.localeData().invalidDate();var e,t,n,r,i,s,a,o,l=aa(this._milliseconds)/1e3,u=aa(this._days),d=aa(this._months),c=this.asSeconds();return c?(e=Ne(l/60),t=Ne(e/60),l%=60,e%=60,n=Ne(d/12),d%=12,r=l?l.toFixed(3).replace(/\\.?0+$/,''):'',i=c<0?'-':'',s=oa(this._months)!==oa(c)?'-':'',a=oa(this._days)!==oa(c)?'-':'',o=oa(this._milliseconds)!==oa(c)?'-':'',i+'P'+(n?s+n+'Y':'')+(d?s+d+'M':'')+(u?a+u+'D':'')+(t||e||l?'T':'')+(t?o+t+'H':'')+(e?o+e+'M':'')+(l?o+r+'S':'')):'P0D'}var ua=ar.prototype;return ua.isValid=ir,ua.abs=Ds,ua.add=Ss,ua.subtract=Ys,ua.as=Ns,ua.asMilliseconds=Rs,ua.asSeconds=Cs,ua.asMinutes=Ws,ua.asHours=Fs,ua.asDays=Us,ua.asWeeks=Hs,ua.asMonths=As,ua.asQuarters=Es,ua.asYears=Ls,ua.valueOf=Vs,ua._bubble=Os,ua.clone=Is,ua.get=Gs,ua.milliseconds=Zs,ua.seconds=zs,ua.minutes=qs,ua.hours=$s,ua.days=Bs,ua.weeks=Xs,ua.months=Js,ua.years=Qs,ua.humanize=sa,ua.toISOString=la,ua.toString=la,ua.toJSON=la,ua.locale=ai,ua.localeData=li,ua.toIsoString=Y('toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)',la),ua.lang=oi,L('X',0,0,'unix'),L('x',0,0,'valueOf'),be('x',ve),be('X',De),Ce('X',(function(e,t,n){n._d=new Date(1e3*parseFloat(e))})),Ce('x',(function(e,t,n){n._d=new Date(Pe(e))})),r.version='2.30.1',i(Bn),r.fn=ls,r.min=Kn,r.max=er,r.now=tr,r.utc=_,r.unix=us,r.months=ps,r.isDate=c,r.locale=mn,r.invalid=g,r.duration=Tr,r.isMoment=M,r.weekdays=gs,r.parseZone=ds,r.localeData=gn,r.isDuration=or,r.monthsShort=ys,r.weekdaysMin=ws,r.defineLocale=pn,r.updateLocale=yn,r.locales=vn,r.weekdaysShort=vs,r.normalizeUnits=ne,r.relativeTimeRounding=ra,r.relativeTimeThreshold=ia,r.calendarFormat=Vr,r.prototype=ls,r.HTML5_FMT={DATETIME_LOCAL:'YYYY-MM-DDTHH:mm',DATETIME_LOCAL_SECONDS:'YYYY-MM-DDTHH:mm:ss',DATETIME_LOCAL_MS:'YYYY-MM-DDTHH:mm:ss.SSS',DATE:'YYYY-MM-DD',TIME:'HH:mm',TIME_SECONDS:'HH:mm:ss',TIME_MS:'HH:mm:ss.SSS',WEEK:'GGGG-[W]WW',MONTH:'YYYY-MM'},r}()},597:e=>{function t(e){return e?Array.isArray(e)?e:[e]:[]}function n(e,t){switch(typeof e){case'undefined':return!0;case'function':return e(t);default:return e}}function r(e,t,r){if(n(e.appliesIf,r)){var i='function'==typeof e.fields?e.fields(r):e.fields.filter((function(e){return n(e.appliesIf,r)})).map((function(e){var t={};return s(e,t,'label'),s(e,t,'value'),s(e,t,'translate'),s(e,t,'filter'),s(e,t,'width'),s(e,t,'icon'),e.context&&(t.context={},s(e.context,t.context,'count'),s(e.context,t.context,'total')),t}));return e.modifyContext&&e.modifyContext(t,r),{label:e.label,fields:i}}function s(e,t,n){switch(typeof e[n]){case'undefined':return;case'function':t[n]=e[n](r);break;default:t[n]=e[n]}}}e.exports=function(e,n,i){var s=e.fields||[],a=e.context||{},o=e.cards||[],l=n&&('contact'===n.type?n.contact_type:n.type),u={cards:[],fields:s.filter((function(e){var n=t(e.appliesToType),r=n.filter((function(e){return e&&'!'===e.charAt(0)}));if((0===n.length||n.includes(l)||r.length>0&&!r.includes('!'+l))&&(!e.appliesIf||e.appliesIf()))return delete e.appliesToType,delete e.appliesIf,!0}))};return o.forEach((function(e){var n,s,o,d,c=t(e.appliesToType);if(c.includes('report')&&c.length>1)throw new Error('You cannot set appliesToType to an array which includes the type \\'report\\' and another type.');if(c.includes('report'))for(n=0;n0)return;(o=r(e,a))&&u.cards.push(o)}})),u.context=a,u}},766:(e,t,n)=>{const r=n(420),i=r().startOf('day'),s=['pregnancy'],a=['pregnancy_home_visit'],o=['delivery'],l=['pregnancy','pregnancy_home_visit','pregnancy_danger_sign','pregannacy_danger_sign_follow_up'],u=294,d=(e,t)=>['fields',...(t||'').split('.')].reduce(((e,t)=>{if(void 0!==e)return e[t]}),e);function c(e,t,n,r){return e.filter((function(e){return t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r}))}function h(e,t){let n;return e.forEach((function(e){(function(e){return!!(e.form&&e.fields&&e.reported_date)})(e)&&t.includes(e.form)&&(!n||e.reported_date>n.reported_date)&&(n=e)})),n}function f(e){return M(e)&&d(e,'lmp_date_8601')&&r(d(e,'lmp_date_8601'))}function _(e,t){let n=f(t),i=t.reported_date;return x(e,t).forEach((function(e){const t=S(s=e)&&d(s,'lmp_date_8601')&&r(d(s,'lmp_date_8601'));var s;e.reported_date>i&&'yes'===d(e,'lmp_updated')&&(i=e.reported_date,n=t)})),n}function m(e,t){const n=_(e,t);if(n)return n.clone().add(280,'days')}function p(e){return Y(e)&&d(e,'delivery_outcome.delivery_date')&&r(d(e,'delivery_outcome.delivery_date'))}function y(e){const t=[];if('yes'===d(e,'t_danger_signs_referral_follow_up')){const n=d(e,'danger_signs');if(n)for(const e in n)'yes'===n[e]&&'r_danger_sign_present'!==e&&t.push(e)}return t}function g(e){const t=[];if(!M(e))return[];if('yes'===d(e,'risk_factors.r_risk_factor_present')){'yes'===d(e,'risk_factors.risk_factors_history.first_pregnancy')&&t.push('first_pregnancy'),'yes'===d(e,'risk_factors.risk_factors_history.previous_miscarriage')&&t.push('previous_miscarriage');const n=d(e,'risk_factors.risk_factors_present.primary_condition'),r=d(e,'risk_factors.risk_factors_present.secondary_condition');n&&t.push(...n.split(' ')),r&&t.push(...r.split(' '))}return t}function v(e,t){const n=g(t);return x(e,t).forEach((function(e){n.push(...function(e){const t=[];if(!S(e))return[];if('yes'===d(e,'anc_visits_hf.risk_factors.r_risk_factor_present')){const n=d(e,'anc_visits_hf.risk_factors.new_risks');n&&t.push(...n.split(' '))}return t}(e))})),n}function w(e){let t;return e&&M(e)?t=d(e,'risk_factors.risk_factors_present.additional_risk'):e&&S(e)&&(t=d(e,'anc_visits_hf.risk_factors.additional_risk')),t}function k(e,t){const n=[],r=w(t);r&&n.push(r);return x(e,t).forEach((function(e){const t=w(e);t&&n.push(t)})),n}function D(e){return e&&!e.date_of_death}function M(e){return e&&s.includes(e.form)}function S(e){return e&&a.includes(e.form)}function Y(e){return e&&o.includes(e.form)}function b(e,t,n){if('person'!==e.type||!D(e)||!M(n))return!1;const r=(_(t,n)||n.reported_date)>i.clone().subtract(u,'day'),s=T(t,n,42).length>0,a=function(e,t){return e.filter((function(e){return M(e)&&e.reported_date>t.reported_date}))}(t,n).length>0;return r&&!s&&!a&&!O(t,n,'abortion')&&!O(t,n,'miscarriage')}function O(e,t,n){const r=h(x(e,t),a);if(r&&d(r,'pregnancy_summary.visit_option')===n)return r}function T(e,t,n){return e.filter((function(e){return Y(e)&&e.reported_date>t.reported_date&&(!n||e.reported_date>=i.clone().subtract(n,'days'))}))}function x(e,t){let n=f(t);n||(n=r(t.reported_date));return e.filter((function(e){return S(e)&&e.reported_date>t.reported_date&&r(e.reported_date)b(e)))},isActivePregnancy:b,countANCFacilityVisits:function(e,t){let n=0;const r=x(e,t);return d(t,'anc_visits_hf.anc_visits_hf_past')&&!isNaN(d(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))&&(n+=parseInt(d(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))),n+=r.reduce((function(e,t){const n=d(t,'anc_visits_hf.anc_visits_hf_past');return n?(e+='yes'===n.last_visit_attended&&1,isNaN(n.visited_hf_count)?e:e+('yes'===n.report_other_visits&&parseInt(n.visited_hf_count))):0}),0),n},knowsHIVStatusInPast3Months:function(e){let t=!1;return c(e,s,i.clone().subtract(3,'months'),i).forEach((function(e){'yes'===d(e,'pregnancy_new_or_current.hiv_status.hiv_status_know')&&(t=!0)})),t},getAllRiskFactors:v,getAllRiskFactorExtra:k,getDangerSignCodes:y,getLatestDangerSignsForPregnancy:function(e,t){if(!t)return[];let n=_(e,t);n||(n=r(t.reported_date));const i=c(e,l,n.toDate(),n.clone().add(u,'days').toDate()),s=[];i.forEach((e=>{S(e)?'yes'===d(e,'pregnancy_summary.visit_option')&&s.push(e):s.push(e)}));const a=h(s,l);return a?y(a):[]},getNextANCVisitDate:function(e,t){let n=d(t,'t_pregnancy_follow_up_date'),i=t.reported_date;return x(e,t).forEach((function(e){e.reported_date>i&&d(e,'t_pregnancy_follow_up_date')&&(i=e.reported_date,n=d(e,'t_pregnancy_follow_up_date'))})),r(n)},isReadyForNewPregnancy:function(e,t){if('person'!==e.type)return!1;const n=h(t,s),a=h(t,o);if(!n&&!a)return!0;if(n){if(!a||a.reported_daten.reported_date))return p(a){const r=n(420),i=n(766),{today:s,MAX_DAYS_IN_PREGNANCY:a,isHighRiskPregnancy:o,getNewestReport:l,getSubsequentPregnancyFollowUps:u,getSubsequentDeliveries:d,isAlive:c,isReadyForNewPregnancy:h,isReadyForDelivery:f,isActivePregnancy:_,countANCFacilityVisits:m,getAllRiskFactors:p,getLatestDangerSignsForPregnancy:y,getNextANCVisitDate:g,getMostRecentLMPDateForPregnancy:v,getMostRecentEDDForPregnancy:w,getDeliveryDate:k,getFormArraySubmittedInWindow:D,getRecentANCVisitWithEvent:M,getAllRiskFactorExtra:S,getField:Y}=i,b=contact,O=lineage,T=reports,x={alive:c(b),muted:!1,show_pregnancy_form:h(b,T),show_delivery_form:f(b,T)},N=[{appliesToType:'person',label:'patient_id',value:b.patient_id,width:4},{appliesToType:'person',label:'contact.age',value:b.date_of_birth,width:4,filter:'age'},{appliesToType:'person',label:'contact.sex',value:'contact.sex.'+b.sex,translate:!0,width:4},{appliesToType:'person',label:'person.field.phone',value:b.phone,width:4},{appliesToType:'person',label:'person.field.alternate_phone',value:b.phone_alternate,width:4},{appliesToType:'person',label:'External ID',value:b.external_id,width:4},{appliesToType:'person',label:'contact.parent',value:O,filter:'lineage'},{appliesToType:'!person',label:'contact',value:b.contact&&b.contact.name,width:4},{appliesToType:'!person',label:'contact.phone',value:b.contact&&b.contact.phone,width:4},{appliesToType:'!person',label:'External ID',value:b.external_id,width:4},{appliesToType:'!person',appliesIf:function(){return b.parent&&O[0]},label:'contact.parent',value:O,filter:'lineage'},{appliesToType:'person',label:'contact.notes',value:b.notes,width:12},{appliesToType:'!person',label:'contact.notes',value:b.notes,width:12}];b.short_name&&N.unshift({appliesToType:'person',label:'contact.short_name',value:b.short_name,width:4});const P=[{label:'contact.profile.pregnancy.active',appliesToType:'report',appliesIf:function(e){return _(b,T,e)},fields:function(e){const t=[],n=p(T,e),i=S(T,e),a=y(T,e),d=o(T,e),c=l(T,['pregnancy','pregnancy_home_visit']),h=r(c.reported_date),f=v(T,e),_=w(T,e),k=g(T,e),D=f?s.diff(f,'weeks'):null;let b=Y(e,'lmp_approx'),O=e.reported_date;u(T,e).forEach((function(e){e.reported_date>O&&'yes'===Y(e,'lmp_updated')&&(O=e.reported_date,Y(e,'lmp_method_approx')&&(b=Y(e,'lmp_method_approx')))}));const x=M(T,e,'migrated'),N=M(T,e,'refused'),P=x||N;if(P){const e='clear_all'===Y(P,'pregnancy_ended.clear_option');t.push({label:'contact.profile.change_care',value:x?'Migrated out of area':'Refusing care',width:6},{label:'contact.profile.tasks_on_off',value:e?'Off':'On',width:6})}if(t.push({label:'Weeks Pregnant',value:D||0===D?{number:D,approximate:'yes'===b}:'contact.profile.value.unknown',translate:!D&&0!==D,filter:D||0===D?'weeksPregnant':'',width:6},{label:'contact.profile.edd',value:_?_.valueOf():'contact.profile.value.unknown',translate:!_,filter:_?'simpleDate':'',width:6}),d){let e='';e=!n&&i?i.join(', '):n.length>1||n&&i?'contact.profile.risk.multiple':'contact.profile.danger_sign.'+n[0],t.push({label:'contact.profile.risk.high',value:e,translate:!0,icon:'icon-risk',width:6})}return a.length>0&&t.push({label:'contact.profile.danger_signs.current',value:a.length>1?'contact.profile.danger_sign.multiple':'contact.profile.danger_sign.'+a[0],translate:!0,width:6}),t.push({label:'contact.profile.visit',value:'contact.profile.visits.of',context:{count:m(T,e),total:8},translate:!0,width:6},{label:'contact.profile.last_visited',value:h.valueOf(),filter:'relativeDay',width:6}),k&&k.isSameOrAfter(s)&&t.push({label:'contact.profile.anc.next',value:k.valueOf(),filter:'simpleDate',width:6}),t},modifyContext:function(e,t){let n=Y(t,'lmp_date_8601'),r=Y(t,'lmp_method_approx'),i=Y(t,'hiv_status_known'),s=Y(t,'deworming_med_received'),a=Y(t,'tt_received');const o=p(T,t),l=S(T,t);let d=Y(t,'t_pregnancy_follow_up_date');u(T,t).forEach((function(e){'yes'===Y(e,'lmp_updated')&&(n=Y(e,'lmp_date_8601'),r=Y(e,'lmp_method_approx')),i=Y(e,'hiv_status_known'),s=Y(e,'deworming_med_received'),a=Y(e,'tt_received'),'yes'===Y(e,'t_pregnancy_follow_up')&&(d=Y(e,'t_pregnancy_follow_up_date'))})),e.lmp_date_8601=n,e.lmp_method_approx=r,e.is_active_pregnancy=!0,e.deworming_med_received=s,e.hiv_tested_past=i,e.tt_received_past=a,e.risk_factor_codes=o.join(' '),e.risk_factor_extra=l.join('; '),e.pregnancy_follow_up_date_recent=d,e.pregnancy_uuid=t._id}},{label:'contact.profile.death.title',appliesToType:'person',appliesIf:function(){return!c(b)},fields:function(){const e=[];let t,n;const r=l(T,['death_report']);if(r){const e=Y(r,'death_details');e&&(t=e.date_of_death,n=e.place_of_death)}else b.date_of_death&&(t=b.date_of_death);return e.push({label:'contact.profile.death.date',value:t||'contact.profile.value.unknown',filter:t?'simpleDate':'',translate:!t,width:6},{label:'contact.profile.death.place',value:n||'contact.profile.value.unknown',translate:!0,width:6}),e}},{label:'contact.profile.pregnancy.past',appliesToType:'report',appliesIf:function(e){if('person'!==b.type)return!1;if('delivery'===e.form)return!0;if('pregnancy'===e.form){if(M(T,e,'abortion')||M(T,e,'miscarriage'))return!0;const t=v(T,e);return t&&s.isSameOrAfter(t.clone().add(42,'weeks'))&&0===d(T,e,a).length}return!1},fields:function(e){const t=[];let n,i,l='',u=0,c=0,h=0;if('delivery'===e.form){const s=r(e.reported_date);n=D(T,['pregnancy'],s.clone().subtract(a,'days').toDate(),s.toDate())[0],Y(e,'delivery_outcome')&&(i=k(e),l=Y(e,'delivery_outcome.delivery_place'),u=Y(e,'delivery_outcome.babies_delivered_num'),c=Y(e,'delivery_outcome.babies_deceased_num'),t.push({label:'contact.profile.delivery_date',value:i?i.valueOf():'',filter:'simpleDate',width:6},{label:'contact.profile.delivery_place',value:l,translate:!0,width:6},{label:'contact.profile.delivered_babies',value:u,width:6}))}else if('pregnancy'===e.form){n=e;const o=v(T,n),l=M(T,n,'abortion'),u=M(T,n,'miscarriage');if(l||u){let e='',n=r(0),i=0;l?(e='abortion',n=r(Y(l,'pregnancy_ended.abortion_date'))):(e='miscarriage',n=r(Y(u,'pregnancy_ended.miscarriage_date'))),i=n.diff(o,'weeks'),t.push({label:'contact.profile.pregnancy.end_early',value:e,translate:!0,width:6},{label:'contact.profile.pregnancy.end_date',value:n.valueOf(),filter:'simpleDate',width:6},{label:'contact.profile.pregnancy.end_weeks',value:i>0?i:'contact.profile.value.unknown',translate:i<=0,width:6})}else o&&s.isSameOrAfter(o.clone().add(42,'weeks'))&&0===d(T,e,a).length&&(i=w(T,e),t.push({label:'contact.profile.delivery_date',value:i?i.valueOf():'contact.profile.value.unknown',filter:'simpleDate',translate:!i,width:6}))}if(c>0&&Y(e,'baby_death')){t.push({label:'contact.profile.deceased_babies',value:c,width:6});let n=Y(e,'baby_death.baby_death_repeat');n||(n=[]);let r=0;n.forEach((function(e){r>0&&t.push({label:'',value:'',width:6}),t.push({label:'contact.profile.newborn.death_date',value:e.baby_death_date,filter:'simpleDate',width:6},{label:'contact.profile.newborn.death_place',value:e.baby_death_place,translate:!0,width:6},{label:'contact.profile.delivery.stillbirthQ',value:e.stillbirth,translate:!0,width:6}),r++,r===n.length&&t.push({label:'',value:'',width:6})}))}if(n){h=m(T,n),t.push({label:'contact.profile.anc_visit',value:h,width:3});if(o(T,n)){let e='';const r=p(T,n),i=S(T,n);e=!r&&i?i.join(', '):r.length>1||r&&i?'contact.profile.risk.multiple':'contact.profile.danger_sign.'+r[0],t.push({label:'contact.profile.risk.high',value:e,translate:!0,icon:'icon-risk',width:6})}}return t}}];e.exports={context:x,cards:P,fields:N}}},t={};function n(r){var i=t[r];if(void 0!==i)return i.exports;var s=t[r]={id:r,loaded:!1,exports:{}};return e[r].call(s.exports,s,s.exports,n),s.loaded=!0,s.exports}return n.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),n(344)})())); return ContactSummary;", + "contact_summary": "var ContactSummary = {}; /*! For license information please see contact-summary.js.LICENSE.txt */\n!function(e,t){if('object'==typeof exports&&'object'==typeof module)module.exports=t();else if('function'==typeof define&&define.amd)define([],t);else{var n=t();for(var r in n)('object'==typeof exports?exports:e)[r]=n[r]}}(ContactSummary,(()=>(()=>{var e={597(e){function t(e){return e?Array.isArray(e)?e:[e]:[]}function n(e,t){switch(typeof e){case'undefined':return!0;case'function':return e(t);default:return e}}function r(e,t,r){if(!n(e.appliesIf,r))return;function i(e,t,n){switch(typeof e[n]){case'undefined':return;case'function':t[n]=e[n](r);break;default:t[n]=e[n]}}var s='function'==typeof e.fields?e.fields(r):e.fields.filter((function(e){return n(e.appliesIf,r)})).map((function(e){var t={};return i(e,t,'label'),i(e,t,'value'),i(e,t,'translate'),i(e,t,'filter'),i(e,t,'width'),i(e,t,'icon'),e.context&&(t.context={},i(e.context,t.context,'count'),i(e.context,t.context,'total')),t}));e.modifyContext&&e.modifyContext(t,r);const a={label:e.label,fields:s};return void 0!==e.collapsed&&(a.collapsed=e.collapsed),a}e.exports=function(e,n,i){var s=e.fields||[],a=e.context||{},o=e.cards||[],l=n&&('contact'===n.type?n.contact_type:n.type),u={cards:[],fields:s.filter((function(e){var n=t(e.appliesToType),r=n.filter((function(e){return e&&'!'===e.charAt(0)}));if((0===n.length||n.includes(l)||r.length>0&&!r.includes('!'+l))&&(!e.appliesIf||e.appliesIf()))return delete e.appliesToType,delete e.appliesIf,!0}))};return o.forEach((function(e){var n,s,o,d,c=t(e.appliesToType);if(c.includes('report')&&c.length>1)throw new Error('You cannot set appliesToType to an array which includes the type \\'report\\' and another type.');if(c.includes('report'))for(n=0;n0)return;(o=r(e,a))&&u.cards.push(o)}})),u.context=a,u}},344(e,t,n){var r=n(972),i=n(597);e.exports=i(r,contact,reports)},420(e,t,n){(e=n.nmd(e)).exports=function(){'use strict';var t,n;function r(){return t.apply(null,arguments)}function i(e){t=e}function s(e){return e instanceof Array||'[object Array]'===Object.prototype.toString.call(e)}function a(e){return null!=e&&'[object Object]'===Object.prototype.toString.call(e)}function o(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function l(e){if(Object.getOwnPropertyNames)return 0===Object.getOwnPropertyNames(e).length;var t;for(t in e)if(o(e,t))return!1;return!0}function u(e){return void 0===e}function d(e){return'number'==typeof e||'[object Number]'===Object.prototype.toString.call(e)}function c(e){return e instanceof Date||'[object Date]'===Object.prototype.toString.call(e)}function h(e,t){var n,r=[],i=e.length;for(n=0;n>>0;for(t=0;t0)for(n=0;n=0?n?'+':'':'-')+Math.pow(10,Math.max(0,i)).toString().substr(1)+r}var U=/(\\[[^\\[]*\\])|(\\\\)?([Hh]mm(ss)?|Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Qo?|N{1,5}|YYYYYY|YYYYY|YYYY|YY|y{2,4}|yo?|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|kk?|mm?|ss?|S{1,9}|x|X|zz?|ZZ?|.)/g,H=/(\\[[^\\[]*\\])|(\\\\)?(LTS|LT|LL?L?L?|l{1,4})/g,A={},E={};function L(e,t,n,r){var i=r;'string'==typeof r&&(i=function(){return this[r]()}),e&&(E[e]=i),t&&(E[t[0]]=function(){return F(i.apply(this,arguments),t[1],t[2])}),n&&(E[n]=function(){return this.localeData().ordinal(i.apply(this,arguments),e)})}function V(e){return e.match(/\\[[\\s\\S]/)?e.replace(/^\\[|\\]$/g,''):e.replace(/\\\\/g,'')}function I(e){var t,n,r=e.match(U);for(t=0,n=r.length;t=0&&H.test(e);)e=e.replace(H,r),H.lastIndex=0,n-=1;return e}var Z={LTS:'h:mm:ss A',LT:'h:mm A',L:'MM/DD/YYYY',LL:'MMMM D, YYYY',LLL:'MMMM D, YYYY h:mm A',LLLL:'dddd, MMMM D, YYYY h:mm A'};function z(e){var t=this._longDateFormat[e],n=this._longDateFormat[e.toUpperCase()];return t||!n?t:(this._longDateFormat[e]=n.match(U).map((function(e){return'MMMM'===e||'MM'===e||'DD'===e||'dddd'===e?e.slice(1):e})).join(''),this._longDateFormat[e])}var q='Invalid date';function $(){return this._invalidDate}var B='%d',J=/\\d{1,2}/;function Q(e){return this._ordinal.replace('%d',e)}var X={future:'in %s',past:'%s ago',s:'a few seconds',ss:'%d seconds',m:'a minute',mm:'%d minutes',h:'an hour',hh:'%d hours',d:'a day',dd:'%d days',w:'a week',ww:'%d weeks',M:'a month',MM:'%d months',y:'a year',yy:'%d years'};function K(e,t,n,r){var i=this._relativeTime[n];return x(i)?i(e,t,n,r):i.replace(/%d/i,e)}function ee(e,t){var n=this._relativeTime[e>0?'future':'past'];return x(n)?n(t):n.replace(/%s/i,t)}var te={D:'date',dates:'date',date:'date',d:'day',days:'day',day:'day',e:'weekday',weekdays:'weekday',weekday:'weekday',E:'isoWeekday',isoweekdays:'isoWeekday',isoweekday:'isoWeekday',DDD:'dayOfYear',dayofyears:'dayOfYear',dayofyear:'dayOfYear',h:'hour',hours:'hour',hour:'hour',ms:'millisecond',milliseconds:'millisecond',millisecond:'millisecond',m:'minute',minutes:'minute',minute:'minute',M:'month',months:'month',month:'month',Q:'quarter',quarters:'quarter',quarter:'quarter',s:'second',seconds:'second',second:'second',gg:'weekYear',weekyears:'weekYear',weekyear:'weekYear',GG:'isoWeekYear',isoweekyears:'isoWeekYear',isoweekyear:'isoWeekYear',w:'week',weeks:'week',week:'week',W:'isoWeek',isoweeks:'isoWeek',isoweek:'isoWeek',y:'year',years:'year',year:'year'};function ne(e){return'string'==typeof e?te[e]||te[e.toLowerCase()]:void 0}function re(e){var t,n,r={};for(n in e)o(e,n)&&(t=ne(n))&&(r[t]=e[n]);return r}var ie={date:9,day:11,weekday:11,isoWeekday:11,dayOfYear:4,hour:13,millisecond:16,minute:14,month:8,quarter:7,second:15,weekYear:1,isoWeekYear:1,week:5,isoWeek:5,year:1};function se(e){var t,n=[];for(t in e)o(e,t)&&n.push({unit:t,priority:ie[t]});return n.sort((function(e,t){return e.priority-t.priority})),n}var ae,oe=/\\d/,le=/\\d\\d/,ue=/\\d{3}/,de=/\\d{4}/,ce=/[+-]?\\d{6}/,he=/\\d\\d?/,fe=/\\d\\d\\d\\d?/,_e=/\\d\\d\\d\\d\\d\\d?/,me=/\\d{1,3}/,pe=/\\d{1,4}/,ye=/[+-]?\\d{1,6}/,ge=/\\d+/,ve=/[+-]?\\d+/,we=/Z|[+-]\\d\\d:?\\d\\d/gi,ke=/Z|[+-]\\d\\d(?::?\\d\\d)?/gi,De=/[+-]?\\d+(\\.\\d{1,3})?/,Me=/[0-9]{0,256}['a-z\\u00A0-\\u05FF\\u0700-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFF07\\uFF10-\\uFFEF]{1,256}|[\\u0600-\\u06FF\\/]{1,256}(\\s*?[\\u0600-\\u06FF]{1,256}){1,2}/i,Se=/^[1-9]\\d?/,Ye=/^([1-9]\\d|\\d)/;function be(e,t,n){ae[e]=x(t)?t:function(e,r){return e&&n?n:t}}function Oe(e,t){return o(ae,e)?ae[e](t._strict,t._locale):new RegExp(Te(e))}function Te(e){return xe(e.replace('\\\\','').replace(/\\\\(\\[)|\\\\(\\])|\\[([^\\]\\[]*)\\]|\\\\(.)/g,(function(e,t,n,r,i){return t||n||r||i})))}function xe(e){return e.replace(/[-\\/\\\\^$*+?.()|[\\]{}]/g,'\\\\$&')}function Ne(e){return e<0?Math.ceil(e)||0:Math.floor(e)}function Pe(e){var t=+e,n=0;return 0!==t&&isFinite(t)&&(n=Ne(t)),n}ae={};var Re={};function Ce(e,t){var n,r,i=t;for('string'==typeof e&&(e=[e]),d(t)&&(i=function(e,n){n[t]=Pe(e)}),r=e.length,n=0;n68?1900:2e3)};var qe,$e=Je('FullYear',!0);function Be(){return Ue(this.year())}function Je(e,t){return function(n){return null!=n?(Xe(this,e,n),r.updateOffset(this,t),this):Qe(this,e)}}function Qe(e,t){if(!e.isValid())return NaN;var n=e._d,r=e._isUTC;switch(t){case'Milliseconds':return r?n.getUTCMilliseconds():n.getMilliseconds();case'Seconds':return r?n.getUTCSeconds():n.getSeconds();case'Minutes':return r?n.getUTCMinutes():n.getMinutes();case'Hours':return r?n.getUTCHours():n.getHours();case'Date':return r?n.getUTCDate():n.getDate();case'Day':return r?n.getUTCDay():n.getDay();case'Month':return r?n.getUTCMonth():n.getMonth();case'FullYear':return r?n.getUTCFullYear():n.getFullYear();default:return NaN}}function Xe(e,t,n){var r,i,s,a,o;if(e.isValid()&&!isNaN(n)){switch(r=e._d,i=e._isUTC,t){case'Milliseconds':return void(i?r.setUTCMilliseconds(n):r.setMilliseconds(n));case'Seconds':return void(i?r.setUTCSeconds(n):r.setSeconds(n));case'Minutes':return void(i?r.setUTCMinutes(n):r.setMinutes(n));case'Hours':return void(i?r.setUTCHours(n):r.setHours(n));case'Date':return void(i?r.setUTCDate(n):r.setDate(n));case'FullYear':break;default:return}s=n,a=e.month(),o=29!==(o=e.date())||1!==a||Ue(s)?o:28,i?r.setUTCFullYear(s,a,o):r.setFullYear(s,a,o)}}function Ke(e){return x(this[e=ne(e)])?this[e]():this}function et(e,t){if('object'==typeof e){var n,r=se(e=re(e)),i=r.length;for(n=0;n=0?(o=new Date(e+400,t,n,r,i,s,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,r,i,s,a),o}function vt(e){var t,n;return e<100&&e>=0?((n=Array.prototype.slice.call(arguments))[0]=e+400,t=new Date(Date.UTC.apply(null,n)),isFinite(t.getUTCFullYear())&&t.setUTCFullYear(e)):t=new Date(Date.UTC.apply(null,arguments)),t}function wt(e,t,n){var r=7+t-n;return-(7+vt(e,0,r).getUTCDay()-t)%7+r-1}function kt(e,t,n,r,i){var s,a,o=1+7*(t-1)+(7+n-r)%7+wt(e,r,i);return o<=0?a=ze(s=e-1)+o:o>ze(e)?(s=e+1,a=o-ze(e)):(s=e,a=o),{year:s,dayOfYear:a}}function Dt(e,t,n){var r,i,s=wt(e.year(),t,n),a=Math.floor((e.dayOfYear()-s-1)/7)+1;return a<1?r=a+Mt(i=e.year()-1,t,n):a>Mt(e.year(),t,n)?(r=a-Mt(e.year(),t,n),i=e.year()+1):(i=e.year(),r=a),{week:r,year:i}}function Mt(e,t,n){var r=wt(e,t,n),i=wt(e+1,t,n);return(ze(e)-r+i)/7}function St(e){return Dt(e,this._week.dow,this._week.doy).week}L('w',['ww',2],'wo','week'),L('W',['WW',2],'Wo','isoWeek'),be('w',he,Se),be('ww',he,le),be('W',he,Se),be('WW',he,le),We(['w','ww','W','WW'],(function(e,t,n,r){t[r.substr(0,1)]=Pe(e)}));var Yt={dow:0,doy:6};function bt(){return this._week.dow}function Ot(){return this._week.doy}function Tt(e){var t=this.localeData().week(this);return null==e?t:this.add(7*(e-t),'d')}function xt(e){var t=Dt(this,1,4).week;return null==e?t:this.add(7*(e-t),'d')}function Nt(e,t){return'string'!=typeof e?e:isNaN(e)?'number'==typeof(e=t.weekdaysParse(e))?e:null:parseInt(e,10)}function Pt(e,t){return'string'==typeof e?t.weekdaysParse(e)%7||7:isNaN(e)?null:e}function Rt(e,t){return e.slice(t,7).concat(e.slice(0,t))}L('d',0,'do','day'),L('dd',0,0,(function(e){return this.localeData().weekdaysMin(this,e)})),L('ddd',0,0,(function(e){return this.localeData().weekdaysShort(this,e)})),L('dddd',0,0,(function(e){return this.localeData().weekdays(this,e)})),L('e',0,0,'weekday'),L('E',0,0,'isoWeekday'),be('d',he),be('e',he),be('E',he),be('dd',(function(e,t){return t.weekdaysMinRegex(e)})),be('ddd',(function(e,t){return t.weekdaysShortRegex(e)})),be('dddd',(function(e,t){return t.weekdaysRegex(e)})),We(['dd','ddd','dddd'],(function(e,t,n,r){var i=n._locale.weekdaysParse(e,r,n._strict);null!=i?t.d=i:p(n).invalidWeekday=e})),We(['d','e','E'],(function(e,t,n,r){t[r]=Pe(e)}));var Ct='Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'),Wt='Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'),Ft='Su_Mo_Tu_We_Th_Fr_Sa'.split('_'),Ut=Me,Ht=Me,At=Me;function Et(e,t){var n=s(this._weekdays)?this._weekdays:this._weekdays[e&&!0!==e&&this._weekdays.isFormat.test(t)?'format':'standalone'];return!0===e?Rt(n,this._week.dow):e?n[e.day()]:n}function Lt(e){return!0===e?Rt(this._weekdaysShort,this._week.dow):e?this._weekdaysShort[e.day()]:this._weekdaysShort}function Vt(e){return!0===e?Rt(this._weekdaysMin,this._week.dow):e?this._weekdaysMin[e.day()]:this._weekdaysMin}function It(e,t,n){var r,i,s,a=e.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],r=0;r<7;++r)s=_([2e3,1]).day(r),this._minWeekdaysParse[r]=this.weekdaysMin(s,'').toLocaleLowerCase(),this._shortWeekdaysParse[r]=this.weekdaysShort(s,'').toLocaleLowerCase(),this._weekdaysParse[r]=this.weekdays(s,'').toLocaleLowerCase();return n?'dddd'===t?-1!==(i=qe.call(this._weekdaysParse,a))?i:null:'ddd'===t?-1!==(i=qe.call(this._shortWeekdaysParse,a))?i:null:-1!==(i=qe.call(this._minWeekdaysParse,a))?i:null:'dddd'===t?-1!==(i=qe.call(this._weekdaysParse,a))||-1!==(i=qe.call(this._shortWeekdaysParse,a))||-1!==(i=qe.call(this._minWeekdaysParse,a))?i:null:'ddd'===t?-1!==(i=qe.call(this._shortWeekdaysParse,a))||-1!==(i=qe.call(this._weekdaysParse,a))||-1!==(i=qe.call(this._minWeekdaysParse,a))?i:null:-1!==(i=qe.call(this._minWeekdaysParse,a))||-1!==(i=qe.call(this._weekdaysParse,a))||-1!==(i=qe.call(this._shortWeekdaysParse,a))?i:null}function Gt(e,t,n){var r,i,s;if(this._weekdaysParseExact)return It.call(this,e,t,n);for(this._weekdaysParse||(this._weekdaysParse=[],this._minWeekdaysParse=[],this._shortWeekdaysParse=[],this._fullWeekdaysParse=[]),r=0;r<7;r++){if(i=_([2e3,1]).day(r),n&&!this._fullWeekdaysParse[r]&&(this._fullWeekdaysParse[r]=new RegExp('^'+this.weekdays(i,'').replace('.','\\\\.?')+'$','i'),this._shortWeekdaysParse[r]=new RegExp('^'+this.weekdaysShort(i,'').replace('.','\\\\.?')+'$','i'),this._minWeekdaysParse[r]=new RegExp('^'+this.weekdaysMin(i,'').replace('.','\\\\.?')+'$','i')),this._weekdaysParse[r]||(s='^'+this.weekdays(i,'')+'|^'+this.weekdaysShort(i,'')+'|^'+this.weekdaysMin(i,''),this._weekdaysParse[r]=new RegExp(s.replace('.',''),'i')),n&&'dddd'===t&&this._fullWeekdaysParse[r].test(e))return r;if(n&&'ddd'===t&&this._shortWeekdaysParse[r].test(e))return r;if(n&&'dd'===t&&this._minWeekdaysParse[r].test(e))return r;if(!n&&this._weekdaysParse[r].test(e))return r}}function jt(e){if(!this.isValid())return null!=e?this:NaN;var t=Qe(this,'Day');return null!=e?(e=Nt(e,this.localeData()),this.add(e-t,'d')):t}function Zt(e){if(!this.isValid())return null!=e?this:NaN;var t=(this.day()+7-this.localeData()._week.dow)%7;return null==e?t:this.add(e-t,'d')}function zt(e){if(!this.isValid())return null!=e?this:NaN;if(null!=e){var t=Pt(e,this.localeData());return this.day(this.day()%7?t:t-7)}return this.day()||7}function qt(e){return this._weekdaysParseExact?(o(this,'_weekdaysRegex')||Jt.call(this),e?this._weekdaysStrictRegex:this._weekdaysRegex):(o(this,'_weekdaysRegex')||(this._weekdaysRegex=Ut),this._weekdaysStrictRegex&&e?this._weekdaysStrictRegex:this._weekdaysRegex)}function $t(e){return this._weekdaysParseExact?(o(this,'_weekdaysRegex')||Jt.call(this),e?this._weekdaysShortStrictRegex:this._weekdaysShortRegex):(o(this,'_weekdaysShortRegex')||(this._weekdaysShortRegex=Ht),this._weekdaysShortStrictRegex&&e?this._weekdaysShortStrictRegex:this._weekdaysShortRegex)}function Bt(e){return this._weekdaysParseExact?(o(this,'_weekdaysRegex')||Jt.call(this),e?this._weekdaysMinStrictRegex:this._weekdaysMinRegex):(o(this,'_weekdaysMinRegex')||(this._weekdaysMinRegex=At),this._weekdaysMinStrictRegex&&e?this._weekdaysMinStrictRegex:this._weekdaysMinRegex)}function Jt(){function e(e,t){return t.length-e.length}var t,n,r,i,s,a=[],o=[],l=[],u=[];for(t=0;t<7;t++)n=_([2e3,1]).day(t),r=xe(this.weekdaysMin(n,'')),i=xe(this.weekdaysShort(n,'')),s=xe(this.weekdays(n,'')),a.push(r),o.push(i),l.push(s),u.push(r),u.push(i),u.push(s);a.sort(e),o.sort(e),l.sort(e),u.sort(e),this._weekdaysRegex=new RegExp('^('+u.join('|')+')','i'),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp('^('+l.join('|')+')','i'),this._weekdaysShortStrictRegex=new RegExp('^('+o.join('|')+')','i'),this._weekdaysMinStrictRegex=new RegExp('^('+a.join('|')+')','i')}function Qt(){return this.hours()%12||12}function Xt(){return this.hours()||24}function Kt(e,t){L(e,0,0,(function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)}))}function en(e,t){return t._meridiemParse}function tn(e){return'p'===(e+'').toLowerCase().charAt(0)}L('H',['HH',2],0,'hour'),L('h',['hh',2],0,Qt),L('k',['kk',2],0,Xt),L('hmm',0,0,(function(){return''+Qt.apply(this)+F(this.minutes(),2)})),L('hmmss',0,0,(function(){return''+Qt.apply(this)+F(this.minutes(),2)+F(this.seconds(),2)})),L('Hmm',0,0,(function(){return''+this.hours()+F(this.minutes(),2)})),L('Hmmss',0,0,(function(){return''+this.hours()+F(this.minutes(),2)+F(this.seconds(),2)})),Kt('a',!0),Kt('A',!1),be('a',en),be('A',en),be('H',he,Ye),be('h',he,Se),be('k',he,Se),be('HH',he,le),be('hh',he,le),be('kk',he,le),be('hmm',fe),be('hmmss',_e),be('Hmm',fe),be('Hmmss',_e),Ce(['H','HH'],Le),Ce(['k','kk'],(function(e,t,n){var r=Pe(e);t[Le]=24===r?0:r})),Ce(['a','A'],(function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e})),Ce(['h','hh'],(function(e,t,n){t[Le]=Pe(e),p(n).bigHour=!0})),Ce('hmm',(function(e,t,n){var r=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r)),p(n).bigHour=!0})),Ce('hmmss',(function(e,t,n){var r=e.length-4,i=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r,2)),t[Ie]=Pe(e.substr(i)),p(n).bigHour=!0})),Ce('Hmm',(function(e,t,n){var r=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r))})),Ce('Hmmss',(function(e,t,n){var r=e.length-4,i=e.length-2;t[Le]=Pe(e.substr(0,r)),t[Ve]=Pe(e.substr(r,2)),t[Ie]=Pe(e.substr(i))}));var nn=/[ap]\\.?m?\\.?/i,rn=Je('Hours',!0);function sn(e,t,n){return e>11?n?'pm':'PM':n?'am':'AM'}var an,on={calendar:C,longDateFormat:Z,invalidDate:q,ordinal:B,dayOfMonthOrdinalParse:J,relativeTime:X,months:rt,monthsShort:it,week:Yt,weekdays:Ct,weekdaysMin:Ft,weekdaysShort:Wt,meridiemParse:nn},ln={},un={};function dn(e,t){var n,r=Math.min(e.length,t.length);for(n=0;n0;){if(r=_n(i.slice(0,t).join('-')))return r;if(n&&n.length>=t&&dn(i,n)>=t-1)break;t--}s++}return an}function fn(e){return!(!e||!e.match('^[^/\\\\\\\\]*$'))}function _n(t){var n=null;if(void 0===ln[t]&&e&&e.exports&&fn(t))try{n=an._abbr,Object(function(){var e=new Error('Cannot find module \\'undefined\\'');throw e.code='MODULE_NOT_FOUND',e}()),mn(n)}catch(e){ln[t]=null}return ln[t]}function mn(e,t){var n;return e&&((n=u(t)?gn(e):pn(e,t))?an=n:'undefined'!=typeof console&&console.warn&&console.warn('Locale '+e+' not found. Did you forget to load it?')),an._abbr}function pn(e,t){if(null!==t){var n,r=on;if(t.abbr=e,null!=ln[e])T('defineLocaleOverride','use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info.'),r=ln[e]._config;else if(null!=t.parentLocale)if(null!=ln[t.parentLocale])r=ln[t.parentLocale]._config;else{if(null==(n=_n(t.parentLocale)))return un[t.parentLocale]||(un[t.parentLocale]=[]),un[t.parentLocale].push({name:e,config:t}),null;r=n._config}return ln[e]=new R(P(r,t)),un[e]&&un[e].forEach((function(e){pn(e.name,e.config)})),mn(e),ln[e]}return delete ln[e],null}function yn(e,t){if(null!=t){var n,r,i=on;null!=ln[e]&&null!=ln[e].parentLocale?ln[e].set(P(ln[e]._config,t)):(null!=(r=_n(e))&&(i=r._config),t=P(i,t),null==r&&(t.abbr=e),(n=new R(t)).parentLocale=ln[e],ln[e]=n),mn(e)}else null!=ln[e]&&(null!=ln[e].parentLocale?(ln[e]=ln[e].parentLocale,e===mn()&&mn(e)):null!=ln[e]&&delete ln[e]);return ln[e]}function gn(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return an;if(!s(e)){if(t=_n(e))return t;e=[e]}return hn(e)}function vn(){return b(ln)}function wn(e){var t,n=e._a;return n&&-2===p(e).overflow&&(t=n[Ae]<0||n[Ae]>11?Ae:n[Ee]<1||n[Ee]>nt(n[He],n[Ae])?Ee:n[Le]<0||n[Le]>24||24===n[Le]&&(0!==n[Ve]||0!==n[Ie]||0!==n[Ge])?Le:n[Ve]<0||n[Ve]>59?Ve:n[Ie]<0||n[Ie]>59?Ie:n[Ge]<0||n[Ge]>999?Ge:-1,p(e)._overflowDayOfYear&&(tEe)&&(t=Ee),p(e)._overflowWeeks&&-1===t&&(t=je),p(e)._overflowWeekday&&-1===t&&(t=Ze),p(e).overflow=t),e}var kn=/^\\s*((?:[+-]\\d{6}|\\d{4})-(?:\\d\\d-\\d\\d|W\\d\\d-\\d|W\\d\\d|\\d\\d\\d|\\d\\d))(?:(T| )(\\d\\d(?::\\d\\d(?::\\d\\d(?:[.,]\\d+)?)?)?)([+-]\\d\\d(?::?\\d\\d)?|\\s*Z)?)?$/,Dn=/^\\s*((?:[+-]\\d{6}|\\d{4})(?:\\d\\d\\d\\d|W\\d\\d\\d|W\\d\\d|\\d\\d\\d|\\d\\d|))(?:(T| )(\\d\\d(?:\\d\\d(?:\\d\\d(?:[.,]\\d+)?)?)?)([+-]\\d\\d(?::?\\d\\d)?|\\s*Z)?)?$/,Mn=/Z|[+-]\\d\\d(?::?\\d\\d)?/,Sn=[['YYYYYY-MM-DD',/[+-]\\d{6}-\\d\\d-\\d\\d/],['YYYY-MM-DD',/\\d{4}-\\d\\d-\\d\\d/],['GGGG-[W]WW-E',/\\d{4}-W\\d\\d-\\d/],['GGGG-[W]WW',/\\d{4}-W\\d\\d/,!1],['YYYY-DDD',/\\d{4}-\\d{3}/],['YYYY-MM',/\\d{4}-\\d\\d/,!1],['YYYYYYMMDD',/[+-]\\d{10}/],['YYYYMMDD',/\\d{8}/],['GGGG[W]WWE',/\\d{4}W\\d{3}/],['GGGG[W]WW',/\\d{4}W\\d{2}/,!1],['YYYYDDD',/\\d{7}/],['YYYYMM',/\\d{6}/,!1],['YYYY',/\\d{4}/,!1]],Yn=[['HH:mm:ss.SSSS',/\\d\\d:\\d\\d:\\d\\d\\.\\d+/],['HH:mm:ss,SSSS',/\\d\\d:\\d\\d:\\d\\d,\\d+/],['HH:mm:ss',/\\d\\d:\\d\\d:\\d\\d/],['HH:mm',/\\d\\d:\\d\\d/],['HHmmss.SSSS',/\\d\\d\\d\\d\\d\\d\\.\\d+/],['HHmmss,SSSS',/\\d\\d\\d\\d\\d\\d,\\d+/],['HHmmss',/\\d\\d\\d\\d\\d\\d/],['HHmm',/\\d\\d\\d\\d/],['HH',/\\d\\d/]],bn=/^\\/?Date\\((-?\\d+)/i,On=/^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\\s)?(\\d{1,2})\\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s(\\d{2,4})\\s(\\d\\d):(\\d\\d)(?::(\\d\\d))?\\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\\d{4}))$/,Tn={UT:0,GMT:0,EDT:-240,EST:-300,CDT:-300,CST:-360,MDT:-360,MST:-420,PDT:-420,PST:-480};function xn(e){var t,n,r,i,s,a,o=e._i,l=kn.exec(o)||Dn.exec(o),u=Sn.length,d=Yn.length;if(l){for(p(e).iso=!0,t=0,n=u;tze(s)||0===e._dayOfYear)&&(p(e)._overflowDayOfYear=!0),n=vt(s,0,e._dayOfYear),e._a[Ae]=n.getUTCMonth(),e._a[Ee]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=r[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[Le]&&0===e._a[Ve]&&0===e._a[Ie]&&0===e._a[Ge]&&(e._nextDay=!0,e._a[Le]=0),e._d=(e._useUTC?vt:gt).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[Le]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(p(e).weekdayMismatch=!0)}}function Ln(e){var t,n,r,i,s,a,o,l,u;null!=(t=e._w).GG||null!=t.W||null!=t.E?(s=1,a=4,n=Hn(t.GG,e._a[He],Dt(Bn(),1,4).year),r=Hn(t.W,1),((i=Hn(t.E,1))<1||i>7)&&(l=!0)):(s=e._locale._week.dow,a=e._locale._week.doy,u=Dt(Bn(),s,a),n=Hn(t.gg,e._a[He],u.year),r=Hn(t.w,u.week),null!=t.d?((i=t.d)<0||i>6)&&(l=!0):null!=t.e?(i=t.e+s,(t.e<0||t.e>6)&&(l=!0)):i=s),r<1||r>Mt(n,s,a)?p(e)._overflowWeeks=!0:null!=l?p(e)._overflowWeekday=!0:(o=kt(n,r,i,s,a),e._a[He]=o.year,e._dayOfYear=o.dayOfYear)}function Vn(e){if(e._f!==r.ISO_8601)if(e._f!==r.RFC_2822){e._a=[],p(e).empty=!0;var t,n,i,s,a,o,l,u=''+e._i,d=u.length,c=0;for(l=(i=j(e._f,e._locale).match(U)||[]).length,t=0;t0&&p(e).unusedInput.push(a),u=u.slice(u.indexOf(n)+n.length),c+=n.length),E[s]?(n?p(e).empty=!1:p(e).unusedTokens.push(s),Fe(s,n,e)):e._strict&&!n&&p(e).unusedTokens.push(s);p(e).charsLeftOver=d-c,u.length>0&&p(e).unusedInput.push(u),e._a[Le]<=12&&!0===p(e).bigHour&&e._a[Le]>0&&(p(e).bigHour=void 0),p(e).parsedDateParts=e._a.slice(0),p(e).meridiem=e._meridiem,e._a[Le]=In(e._locale,e._a[Le],e._meridiem),null!==(o=p(e).era)&&(e._a[He]=e._locale.erasConvertYear(o,e._a[He])),En(e),wn(e)}else Fn(e);else xn(e)}function In(e,t,n){var r;return null==n?t:null!=e.meridiemHour?e.meridiemHour(t,n):null!=e.isPM?((r=e.isPM(n))&&t<12&&(t+=12),r||12!==t||(t=0),t):t}function Gn(e){var t,n,r,i,s,a,o=!1,l=e._f.length;if(0===l)return p(e).invalidFormat=!0,void(e._d=new Date(NaN));for(i=0;ithis?this:e:g()}));function Xn(e,t){var n,r;if(1===t.length&&s(t[0])&&(t=t[0]),!t.length)return Bn();for(n=t[0],r=1;rthis.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()}function Dr(){if(!u(this._isDSTShifted))return this._isDSTShifted;var e,t={};return k(t,this),(t=zn(t))._a?(e=t._isUTC?_(t._a):Bn(t._a),this._isDSTShifted=this.isValid()&&ur(t._a,e.toArray())>0):this._isDSTShifted=!1,this._isDSTShifted}function Mr(){return!!this.isValid()&&!this._isUTC}function Sr(){return!!this.isValid()&&this._isUTC}function Yr(){return!!this.isValid()&&this._isUTC&&0===this._offset}r.updateOffset=function(){};var br=/^(-|\\+)?(?:(\\d*)[. ])?(\\d+):(\\d+)(?::(\\d+)(\\.\\d*)?)?$/,Or=/^(-|\\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/;function Tr(e,t){var n,r,i,s=e,a=null;return or(e)?s={ms:e._milliseconds,d:e._days,M:e._months}:d(e)||!isNaN(+e)?(s={},t?s[t]=+e:s.milliseconds=+e):(a=br.exec(e))?(n='-'===a[1]?-1:1,s={y:0,d:Pe(a[Ee])*n,h:Pe(a[Le])*n,m:Pe(a[Ve])*n,s:Pe(a[Ie])*n,ms:Pe(lr(1e3*a[Ge]))*n}):(a=Or.exec(e))?(n='-'===a[1]?-1:1,s={y:xr(a[2],n),M:xr(a[3],n),w:xr(a[4],n),d:xr(a[5],n),h:xr(a[6],n),m:xr(a[7],n),s:xr(a[8],n)}):null==s?s={}:'object'==typeof s&&('from'in s||'to'in s)&&(i=Pr(Bn(s.from),Bn(s.to)),(s={}).ms=i.milliseconds,s.M=i.months),r=new ar(s),or(e)&&o(e,'_locale')&&(r._locale=e._locale),or(e)&&o(e,'_isValid')&&(r._isValid=e._isValid),r}function xr(e,t){var n=e&&parseFloat(e.replace(',','.'));return(isNaN(n)?0:n)*t}function Nr(e,t){var n={};return n.months=t.month()-e.month()+12*(t.year()-e.year()),e.clone().add(n.months,'M').isAfter(t)&&--n.months,n.milliseconds=+t-+e.clone().add(n.months,'M'),n}function Pr(e,t){var n;return e.isValid()&&t.isValid()?(t=fr(t,e),e.isBefore(t)?n=Nr(e,t):((n=Nr(t,e)).milliseconds=-n.milliseconds,n.months=-n.months),n):{milliseconds:0,months:0}}function Rr(e,t){return function(n,r){var i;return null===r||isNaN(+r)||(T(t,'moment().'+t+'(period, number) is deprecated. Please use moment().'+t+'(number, period). See http://momentjs.com/guides/#/warnings/add-inverted-param/ for more info.'),i=n,n=r,r=i),Cr(this,Tr(n,r),e),this}}function Cr(e,t,n,i){var s=t._milliseconds,a=lr(t._days),o=lr(t._months);e.isValid()&&(i=null==i||i,o&&ht(e,Qe(e,'Month')+o*n),a&&Xe(e,'Date',Qe(e,'Date')+a*n),s&&e._d.setTime(e._d.valueOf()+s*n),i&&r.updateOffset(e,a||o))}Tr.fn=ar.prototype,Tr.invalid=sr;var Wr=Rr(1,'add'),Fr=Rr(-1,'subtract');function Ur(e){return'string'==typeof e||e instanceof String}function Hr(e){return M(e)||c(e)||Ur(e)||d(e)||Er(e)||Ar(e)||null==e}function Ar(e){var t,n,r=a(e)&&!l(e),i=!1,s=['years','year','y','months','month','M','days','day','d','dates','date','D','hours','hour','h','minutes','minute','m','seconds','second','s','milliseconds','millisecond','ms'],u=s.length;for(t=0;tn.valueOf():n.valueOf()9999?G(n,t?'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]':'YYYYYY-MM-DD[T]HH:mm:ss.SSSZ'):x(Date.prototype.toISOString)?t?this.toDate().toISOString():new Date(this.valueOf()+60*this.utcOffset()*1e3).toISOString().replace('Z',G(n,'Z')):G(n,t?'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]':'YYYY-MM-DD[T]HH:mm:ss.SSSZ')}function ei(){if(!this.isValid())return'moment.invalid(/* '+this._i+' */)';var e,t,n,r,i='moment',s='';return this.isLocal()||(i=0===this.utcOffset()?'moment.utc':'moment.parseZone',s='Z'),e='['+i+'(\"]',t=0<=this.year()&&this.year()<=9999?'YYYY':'YYYYYY',n='-MM-DD[T]HH:mm:ss.SSS',r=s+'[\")]',this.format(e+t+n+r)}function ti(e){e||(e=this.isUtc()?r.defaultFormatUtc:r.defaultFormat);var t=G(this,e);return this.localeData().postformat(t)}function ni(e,t){return this.isValid()&&(M(e)&&e.isValid()||Bn(e).isValid())?Tr({to:this,from:e}).locale(this.locale()).humanize(!t):this.localeData().invalidDate()}function ri(e){return this.from(Bn(),e)}function ii(e,t){return this.isValid()&&(M(e)&&e.isValid()||Bn(e).isValid())?Tr({from:this,to:e}).locale(this.locale()).humanize(!t):this.localeData().invalidDate()}function si(e){return this.to(Bn(),e)}function ai(e){var t;return void 0===e?this._locale._abbr:(null!=(t=gn(e))&&(this._locale=t),this)}r.defaultFormat='YYYY-MM-DDTHH:mm:ssZ',r.defaultFormatUtc='YYYY-MM-DDTHH:mm:ss[Z]';var oi=Y('moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.',(function(e){return void 0===e?this.localeData():this.locale(e)}));function li(){return this._locale}var ui=1e3,di=60*ui,ci=60*di,hi=3506328*ci;function fi(e,t){return(e%t+t)%t}function _i(e,t,n){return e<100&&e>=0?new Date(e+400,t,n)-hi:new Date(e,t,n).valueOf()}function mi(e,t,n){return e<100&&e>=0?Date.UTC(e+400,t,n)-hi:Date.UTC(e,t,n)}function pi(e){var t,n;if(void 0===(e=ne(e))||'millisecond'===e||!this.isValid())return this;switch(n=this._isUTC?mi:_i,e){case'year':t=n(this.year(),0,1);break;case'quarter':t=n(this.year(),this.month()-this.month()%3,1);break;case'month':t=n(this.year(),this.month(),1);break;case'week':t=n(this.year(),this.month(),this.date()-this.weekday());break;case'isoWeek':t=n(this.year(),this.month(),this.date()-(this.isoWeekday()-1));break;case'day':case'date':t=n(this.year(),this.month(),this.date());break;case'hour':t=this._d.valueOf(),t-=fi(t+(this._isUTC?0:this.utcOffset()*di),ci);break;case'minute':t=this._d.valueOf(),t-=fi(t,di);break;case'second':t=this._d.valueOf(),t-=fi(t,ui)}return this._d.setTime(t),r.updateOffset(this,!0),this}function yi(e){var t,n;if(void 0===(e=ne(e))||'millisecond'===e||!this.isValid())return this;switch(n=this._isUTC?mi:_i,e){case'year':t=n(this.year()+1,0,1)-1;break;case'quarter':t=n(this.year(),this.month()-this.month()%3+3,1)-1;break;case'month':t=n(this.year(),this.month()+1,1)-1;break;case'week':t=n(this.year(),this.month(),this.date()-this.weekday()+7)-1;break;case'isoWeek':t=n(this.year(),this.month(),this.date()-(this.isoWeekday()-1)+7)-1;break;case'day':case'date':t=n(this.year(),this.month(),this.date()+1)-1;break;case'hour':t=this._d.valueOf(),t+=ci-fi(t+(this._isUTC?0:this.utcOffset()*di),ci)-1;break;case'minute':t=this._d.valueOf(),t+=di-fi(t,di)-1;break;case'second':t=this._d.valueOf(),t+=ui-fi(t,ui)-1}return this._d.setTime(t),r.updateOffset(this,!0),this}function gi(){return this._d.valueOf()-6e4*(this._offset||0)}function vi(){return Math.floor(this.valueOf()/1e3)}function wi(){return new Date(this.valueOf())}function ki(){var e=this;return[e.year(),e.month(),e.date(),e.hour(),e.minute(),e.second(),e.millisecond()]}function Di(){var e=this;return{years:e.year(),months:e.month(),date:e.date(),hours:e.hours(),minutes:e.minutes(),seconds:e.seconds(),milliseconds:e.milliseconds()}}function Mi(){return this.isValid()?this.toISOString():null}function Si(){return y(this)}function Yi(){return f({},p(this))}function bi(){return p(this).overflow}function Oi(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}}function Ti(e,t){var n,i,s,a=this._eras||gn('en')._eras;for(n=0,i=a.length;n=0)return l[r]}function Ni(e,t){var n=e.since<=e.until?1:-1;return void 0===t?r(e.since).year():r(e.since).year()+(t-e.offset)*n}function Pi(){var e,t,n,r=this.localeData().eras();for(e=0,t=r.length;e(s=Mt(e,r,i))&&(t=s),Qi.call(this,e,t,n,r,i))}function Qi(e,t,n,r,i){var s=kt(e,t,n,r,i),a=vt(s.year,0,s.dayOfYear);return this.year(a.getUTCFullYear()),this.month(a.getUTCMonth()),this.date(a.getUTCDate()),this}function Xi(e){return null==e?Math.ceil((this.month()+1)/3):this.month(3*(e-1)+this.month()%3)}L('N',0,0,'eraAbbr'),L('NN',0,0,'eraAbbr'),L('NNN',0,0,'eraAbbr'),L('NNNN',0,0,'eraName'),L('NNNNN',0,0,'eraNarrow'),L('y',['y',1],'yo','eraYear'),L('y',['yy',2],0,'eraYear'),L('y',['yyy',3],0,'eraYear'),L('y',['yyyy',4],0,'eraYear'),be('N',Ai),be('NN',Ai),be('NNN',Ai),be('NNNN',Ei),be('NNNNN',Li),Ce(['N','NN','NNN','NNNN','NNNNN'],(function(e,t,n,r){var i=n._locale.erasParse(e,r,n._strict);i?p(n).era=i:p(n).invalidEra=e})),be('y',ge),be('yy',ge),be('yyy',ge),be('yyyy',ge),be('yo',Vi),Ce(['y','yy','yyy','yyyy'],He),Ce(['yo'],(function(e,t,n,r){var i;n._locale._eraYearOrdinalRegex&&(i=e.match(n._locale._eraYearOrdinalRegex)),n._locale.eraYearOrdinalParse?t[He]=n._locale.eraYearOrdinalParse(e,i):t[He]=parseInt(e,10)})),L(0,['gg',2],0,(function(){return this.weekYear()%100})),L(0,['GG',2],0,(function(){return this.isoWeekYear()%100})),Gi('gggg','weekYear'),Gi('ggggg','weekYear'),Gi('GGGG','isoWeekYear'),Gi('GGGGG','isoWeekYear'),be('G',ve),be('g',ve),be('GG',he,le),be('gg',he,le),be('GGGG',pe,de),be('gggg',pe,de),be('GGGGG',ye,ce),be('ggggg',ye,ce),We(['gggg','ggggg','GGGG','GGGGG'],(function(e,t,n,r){t[r.substr(0,2)]=Pe(e)})),We(['gg','GG'],(function(e,t,n,i){t[i]=r.parseTwoDigitYear(e)})),L('Q',0,'Qo','quarter'),be('Q',oe),Ce('Q',(function(e,t){t[Ae]=3*(Pe(e)-1)})),L('D',['DD',2],'Do','date'),be('D',he,Se),be('DD',he,le),be('Do',(function(e,t){return e?t._dayOfMonthOrdinalParse||t._ordinalParse:t._dayOfMonthOrdinalParseLenient})),Ce(['D','DD'],Ee),Ce('Do',(function(e,t){t[Ee]=Pe(e.match(he)[0])}));var Ki=Je('Date',!0);function es(e){var t=Math.round((this.clone().startOf('day')-this.clone().startOf('year'))/864e5)+1;return null==e?t:this.add(e-t,'d')}L('DDD',['DDDD',3],'DDDo','dayOfYear'),be('DDD',me),be('DDDD',ue),Ce(['DDD','DDDD'],(function(e,t,n){n._dayOfYear=Pe(e)})),L('m',['mm',2],0,'minute'),be('m',he,Ye),be('mm',he,le),Ce(['m','mm'],Ve);var ts=Je('Minutes',!1);L('s',['ss',2],0,'second'),be('s',he,Ye),be('ss',he,le),Ce(['s','ss'],Ie);var ns,rs,is=Je('Seconds',!1);for(L('S',0,0,(function(){return~~(this.millisecond()/100)})),L(0,['SS',2],0,(function(){return~~(this.millisecond()/10)})),L(0,['SSS',3],0,'millisecond'),L(0,['SSSS',4],0,(function(){return 10*this.millisecond()})),L(0,['SSSSS',5],0,(function(){return 100*this.millisecond()})),L(0,['SSSSSS',6],0,(function(){return 1e3*this.millisecond()})),L(0,['SSSSSSS',7],0,(function(){return 1e4*this.millisecond()})),L(0,['SSSSSSSS',8],0,(function(){return 1e5*this.millisecond()})),L(0,['SSSSSSSSS',9],0,(function(){return 1e6*this.millisecond()})),be('S',me,oe),be('SS',me,le),be('SSS',me,ue),ns='SSSS';ns.length<=9;ns+='S')be(ns,ge);function ss(e,t){t[Ge]=Pe(1e3*('0.'+e))}for(ns='S';ns.length<=9;ns+='S')Ce(ns,ss);function as(){return this._isUTC?'UTC':''}function os(){return this._isUTC?'Coordinated Universal Time':''}rs=Je('Milliseconds',!1),L('z',0,0,'zoneAbbr'),L('zz',0,0,'zoneName');var ls=D.prototype;function us(e){return Bn(1e3*e)}function ds(){return Bn.apply(null,arguments).parseZone()}function cs(e){return e}ls.add=Wr,ls.calendar=Ir,ls.clone=Gr,ls.diff=Jr,ls.endOf=yi,ls.format=ti,ls.from=ni,ls.fromNow=ri,ls.to=ii,ls.toNow=si,ls.get=Ke,ls.invalidAt=bi,ls.isAfter=jr,ls.isBefore=Zr,ls.isBetween=zr,ls.isSame=qr,ls.isSameOrAfter=$r,ls.isSameOrBefore=Br,ls.isValid=Si,ls.lang=oi,ls.locale=ai,ls.localeData=li,ls.max=Qn,ls.min=Jn,ls.parsingFlags=Yi,ls.set=et,ls.startOf=pi,ls.subtract=Fr,ls.toArray=ki,ls.toObject=Di,ls.toDate=wi,ls.toISOString=Kr,ls.inspect=ei,'undefined'!=typeof Symbol&&null!=Symbol.for&&(ls[Symbol.for('nodejs.util.inspect.custom')]=function(){return'Moment<'+this.format()+'>'}),ls.toJSON=Mi,ls.toString=Xr,ls.unix=vi,ls.valueOf=gi,ls.creationData=Oi,ls.eraName=Pi,ls.eraNarrow=Ri,ls.eraAbbr=Ci,ls.eraYear=Wi,ls.year=$e,ls.isLeapYear=Be,ls.weekYear=ji,ls.isoWeekYear=Zi,ls.quarter=ls.quarters=Xi,ls.month=ft,ls.daysInMonth=_t,ls.week=ls.weeks=Tt,ls.isoWeek=ls.isoWeeks=xt,ls.weeksInYear=$i,ls.weeksInWeekYear=Bi,ls.isoWeeksInYear=zi,ls.isoWeeksInISOWeekYear=qi,ls.date=Ki,ls.day=ls.days=jt,ls.weekday=Zt,ls.isoWeekday=zt,ls.dayOfYear=es,ls.hour=ls.hours=rn,ls.minute=ls.minutes=ts,ls.second=ls.seconds=is,ls.millisecond=ls.milliseconds=rs,ls.utcOffset=mr,ls.utc=yr,ls.local=gr,ls.parseZone=vr,ls.hasAlignedHourOffset=wr,ls.isDST=kr,ls.isLocal=Mr,ls.isUtcOffset=Sr,ls.isUtc=Yr,ls.isUTC=Yr,ls.zoneAbbr=as,ls.zoneName=os,ls.dates=Y('dates accessor is deprecated. Use date instead.',Ki),ls.months=Y('months accessor is deprecated. Use month instead',ft),ls.years=Y('years accessor is deprecated. Use year instead',$e),ls.zone=Y('moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/',pr),ls.isDSTShifted=Y('isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information',Dr);var hs=R.prototype;function fs(e,t,n,r){var i=gn(),s=_().set(r,t);return i[n](s,e)}function _s(e,t,n){if(d(e)&&(t=e,e=void 0),e=e||'',null!=t)return fs(e,t,n,'month');var r,i=[];for(r=0;r<12;r++)i[r]=fs(e,r,n,'month');return i}function ms(e,t,n,r){'boolean'==typeof e?(d(t)&&(n=t,t=void 0),t=t||''):(n=t=e,e=!1,d(t)&&(n=t,t=void 0),t=t||'');var i,s=gn(),a=e?s._week.dow:0,o=[];if(null!=n)return fs(t,(n+a)%7,r,'day');for(i=0;i<7;i++)o[i]=fs(t,(i+a)%7,r,'day');return o}function ps(e,t){return _s(e,t,'months')}function ys(e,t){return _s(e,t,'monthsShort')}function gs(e,t,n){return ms(e,t,n,'weekdays')}function vs(e,t,n){return ms(e,t,n,'weekdaysShort')}function ws(e,t,n){return ms(e,t,n,'weekdaysMin')}hs.calendar=W,hs.longDateFormat=z,hs.invalidDate=$,hs.ordinal=Q,hs.preparse=cs,hs.postformat=cs,hs.relativeTime=K,hs.pastFuture=ee,hs.set=N,hs.eras=Ti,hs.erasParse=xi,hs.erasConvertYear=Ni,hs.erasAbbrRegex=Ui,hs.erasNameRegex=Fi,hs.erasNarrowRegex=Hi,hs.months=lt,hs.monthsShort=ut,hs.monthsParse=ct,hs.monthsRegex=pt,hs.monthsShortRegex=mt,hs.week=St,hs.firstDayOfYear=Ot,hs.firstDayOfWeek=bt,hs.weekdays=Et,hs.weekdaysMin=Vt,hs.weekdaysShort=Lt,hs.weekdaysParse=Gt,hs.weekdaysRegex=qt,hs.weekdaysShortRegex=$t,hs.weekdaysMinRegex=Bt,hs.isPM=tn,hs.meridiem=sn,mn('en',{eras:[{since:'0001-01-01',until:1/0,offset:1,name:'Anno Domini',narrow:'AD',abbr:'AD'},{since:'0000-12-31',until:-1/0,offset:1,name:'Before Christ',narrow:'BC',abbr:'BC'}],dayOfMonthOrdinalParse:/\\d{1,2}(th|st|nd|rd)/,ordinal:function(e){var t=e%10;return e+(1===Pe(e%100/10)?'th':1===t?'st':2===t?'nd':3===t?'rd':'th')}}),r.lang=Y('moment.lang is deprecated. Use moment.locale instead.',mn),r.langData=Y('moment.langData is deprecated. Use moment.localeData instead.',gn);var ks=Math.abs;function Ds(){var e=this._data;return this._milliseconds=ks(this._milliseconds),this._days=ks(this._days),this._months=ks(this._months),e.milliseconds=ks(e.milliseconds),e.seconds=ks(e.seconds),e.minutes=ks(e.minutes),e.hours=ks(e.hours),e.months=ks(e.months),e.years=ks(e.years),this}function Ms(e,t,n,r){var i=Tr(t,n);return e._milliseconds+=r*i._milliseconds,e._days+=r*i._days,e._months+=r*i._months,e._bubble()}function Ss(e,t){return Ms(this,e,t,1)}function Ys(e,t){return Ms(this,e,t,-1)}function bs(e){return e<0?Math.floor(e):Math.ceil(e)}function Os(){var e,t,n,r,i,s=this._milliseconds,a=this._days,o=this._months,l=this._data;return s>=0&&a>=0&&o>=0||s<=0&&a<=0&&o<=0||(s+=864e5*bs(xs(o)+a),a=0,o=0),l.milliseconds=s%1e3,e=Ne(s/1e3),l.seconds=e%60,t=Ne(e/60),l.minutes=t%60,n=Ne(t/60),l.hours=n%24,a+=Ne(n/24),o+=i=Ne(Ts(a)),a-=bs(xs(i)),r=Ne(o/12),o%=12,l.days=a,l.months=o,l.years=r,this}function Ts(e){return 4800*e/146097}function xs(e){return 146097*e/4800}function Ns(e){if(!this.isValid())return NaN;var t,n,r=this._milliseconds;if('month'===(e=ne(e))||'quarter'===e||'year'===e)switch(t=this._days+r/864e5,n=this._months+Ts(t),e){case'month':return n;case'quarter':return n/3;case'year':return n/12}else switch(t=this._days+Math.round(xs(this._months)),e){case'week':return t/7+r/6048e5;case'day':return t+r/864e5;case'hour':return 24*t+r/36e5;case'minute':return 1440*t+r/6e4;case'second':return 86400*t+r/1e3;case'millisecond':return Math.floor(864e5*t)+r;default:throw new Error('Unknown unit '+e)}}function Ps(e){return function(){return this.as(e)}}var Rs=Ps('ms'),Cs=Ps('s'),Ws=Ps('m'),Fs=Ps('h'),Us=Ps('d'),Hs=Ps('w'),As=Ps('M'),Es=Ps('Q'),Ls=Ps('y'),Vs=Rs;function Is(){return Tr(this)}function Gs(e){return e=ne(e),this.isValid()?this[e+'s']():NaN}function js(e){return function(){return this.isValid()?this._data[e]:NaN}}var Zs=js('milliseconds'),zs=js('seconds'),qs=js('minutes'),$s=js('hours'),Bs=js('days'),Js=js('months'),Qs=js('years');function Xs(){return Ne(this.days()/7)}var Ks=Math.round,ea={ss:44,s:45,m:45,h:22,d:26,w:null,M:11};function ta(e,t,n,r,i){return i.relativeTime(t||1,!!n,e,r)}function na(e,t,n,r){var i=Tr(e).abs(),s=Ks(i.as('s')),a=Ks(i.as('m')),o=Ks(i.as('h')),l=Ks(i.as('d')),u=Ks(i.as('M')),d=Ks(i.as('w')),c=Ks(i.as('y')),h=s<=n.ss&&['s',s]||s0,h[4]=r,ta.apply(null,h)}function ra(e){return void 0===e?Ks:'function'==typeof e&&(Ks=e,!0)}function ia(e,t){return void 0!==ea[e]&&(void 0===t?ea[e]:(ea[e]=t,'s'===e&&(ea.ss=t-1),!0))}function sa(e,t){if(!this.isValid())return this.localeData().invalidDate();var n,r,i=!1,s=ea;return'object'==typeof e&&(t=e,e=!1),'boolean'==typeof e&&(i=e),'object'==typeof t&&(s=Object.assign({},ea,t),null!=t.s&&null==t.ss&&(s.ss=t.s-1)),r=na(this,!i,s,n=this.localeData()),i&&(r=n.pastFuture(+this,r)),n.postformat(r)}var aa=Math.abs;function oa(e){return(e>0)-(e<0)||+e}function la(){if(!this.isValid())return this.localeData().invalidDate();var e,t,n,r,i,s,a,o,l=aa(this._milliseconds)/1e3,u=aa(this._days),d=aa(this._months),c=this.asSeconds();return c?(e=Ne(l/60),t=Ne(e/60),l%=60,e%=60,n=Ne(d/12),d%=12,r=l?l.toFixed(3).replace(/\\.?0+$/,''):'',i=c<0?'-':'',s=oa(this._months)!==oa(c)?'-':'',a=oa(this._days)!==oa(c)?'-':'',o=oa(this._milliseconds)!==oa(c)?'-':'',i+'P'+(n?s+n+'Y':'')+(d?s+d+'M':'')+(u?a+u+'D':'')+(t||e||l?'T':'')+(t?o+t+'H':'')+(e?o+e+'M':'')+(l?o+r+'S':'')):'P0D'}var ua=ar.prototype;return ua.isValid=ir,ua.abs=Ds,ua.add=Ss,ua.subtract=Ys,ua.as=Ns,ua.asMilliseconds=Rs,ua.asSeconds=Cs,ua.asMinutes=Ws,ua.asHours=Fs,ua.asDays=Us,ua.asWeeks=Hs,ua.asMonths=As,ua.asQuarters=Es,ua.asYears=Ls,ua.valueOf=Vs,ua._bubble=Os,ua.clone=Is,ua.get=Gs,ua.milliseconds=Zs,ua.seconds=zs,ua.minutes=qs,ua.hours=$s,ua.days=Bs,ua.weeks=Xs,ua.months=Js,ua.years=Qs,ua.humanize=sa,ua.toISOString=la,ua.toString=la,ua.toJSON=la,ua.locale=ai,ua.localeData=li,ua.toIsoString=Y('toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)',la),ua.lang=oi,L('X',0,0,'unix'),L('x',0,0,'valueOf'),be('x',ve),be('X',De),Ce('X',(function(e,t,n){n._d=new Date(1e3*parseFloat(e))})),Ce('x',(function(e,t,n){n._d=new Date(Pe(e))})),r.version='2.30.1',i(Bn),r.fn=ls,r.min=Kn,r.max=er,r.now=tr,r.utc=_,r.unix=us,r.months=ps,r.isDate=c,r.locale=mn,r.invalid=g,r.duration=Tr,r.isMoment=M,r.weekdays=gs,r.parseZone=ds,r.localeData=gn,r.isDuration=or,r.monthsShort=ys,r.weekdaysMin=ws,r.defineLocale=pn,r.updateLocale=yn,r.locales=vn,r.weekdaysShort=vs,r.normalizeUnits=ne,r.relativeTimeRounding=ra,r.relativeTimeThreshold=ia,r.calendarFormat=Vr,r.prototype=ls,r.HTML5_FMT={DATETIME_LOCAL:'YYYY-MM-DDTHH:mm',DATETIME_LOCAL_SECONDS:'YYYY-MM-DDTHH:mm:ss',DATETIME_LOCAL_MS:'YYYY-MM-DDTHH:mm:ss.SSS',DATE:'YYYY-MM-DD',TIME:'HH:mm',TIME_SECONDS:'HH:mm:ss',TIME_MS:'HH:mm:ss.SSS',WEEK:'GGGG-[W]WW',MONTH:'YYYY-MM'},r}()},766(e,t,n){const r=n(420),i=r().startOf('day'),s=['pregnancy'],a=['pregnancy_home_visit'],o=['delivery'],l=['pregnancy','pregnancy_home_visit','pregnancy_danger_sign','pregannacy_danger_sign_follow_up'],u=294,d=(e,t)=>['fields',...(t||'').split('.')].reduce(((e,t)=>{if(void 0!==e)return e[t]}),e);function c(e,t,n,r){return e.filter((function(e){return t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r}))}function h(e,t){let n;return e.forEach((function(e){(function(e){return!!(e.form&&e.fields&&e.reported_date)})(e)&&t.includes(e.form)&&(!n||e.reported_date>n.reported_date)&&(n=e)})),n}function f(e){return M(e)&&d(e,'lmp_date_8601')&&r(d(e,'lmp_date_8601'))}function _(e,t){let n=f(t),i=t.reported_date;return x(e,t).forEach((function(e){const t=S(s=e)&&d(s,'lmp_date_8601')&&r(d(s,'lmp_date_8601'));var s;e.reported_date>i&&'yes'===d(e,'lmp_updated')&&(i=e.reported_date,n=t)})),n}function m(e,t){const n=_(e,t);if(n)return n.clone().add(280,'days')}function p(e){return Y(e)&&d(e,'delivery_outcome.delivery_date')&&r(d(e,'delivery_outcome.delivery_date'))}function y(e){const t=[];if('yes'===d(e,'t_danger_signs_referral_follow_up')){const n=d(e,'danger_signs');if(n)for(const e in n)'yes'===n[e]&&'r_danger_sign_present'!==e&&t.push(e)}return t}function g(e){const t=[];if(!M(e))return[];if('yes'===d(e,'risk_factors.r_risk_factor_present')){'yes'===d(e,'risk_factors.risk_factors_history.first_pregnancy')&&t.push('first_pregnancy'),'yes'===d(e,'risk_factors.risk_factors_history.previous_miscarriage')&&t.push('previous_miscarriage');const n=d(e,'risk_factors.risk_factors_present.primary_condition'),r=d(e,'risk_factors.risk_factors_present.secondary_condition');n&&t.push(...n.split(' ')),r&&t.push(...r.split(' '))}return t}function v(e,t){const n=g(t);return x(e,t).forEach((function(e){n.push(...function(e){const t=[];if(!S(e))return[];if('yes'===d(e,'anc_visits_hf.risk_factors.r_risk_factor_present')){const n=d(e,'anc_visits_hf.risk_factors.new_risks');n&&t.push(...n.split(' '))}return t}(e))})),n}function w(e){let t;return e&&M(e)?t=d(e,'risk_factors.risk_factors_present.additional_risk'):e&&S(e)&&(t=d(e,'anc_visits_hf.risk_factors.additional_risk')),t}function k(e,t){const n=[],r=w(t);r&&n.push(r);return x(e,t).forEach((function(e){const t=w(e);t&&n.push(t)})),n}function D(e){return e&&!e.date_of_death}function M(e){return e&&s.includes(e.form)}function S(e){return e&&a.includes(e.form)}function Y(e){return e&&o.includes(e.form)}function b(e,t,n){if('person'!==e.type||!D(e)||!M(n))return!1;const r=(_(t,n)||n.reported_date)>i.clone().subtract(u,'day'),s=T(t,n,42).length>0,a=function(e,t){return e.filter((function(e){return M(e)&&e.reported_date>t.reported_date}))}(t,n).length>0;return r&&!s&&!a&&!O(t,n,'abortion')&&!O(t,n,'miscarriage')}function O(e,t,n){const r=h(x(e,t),a);if(r&&d(r,'pregnancy_summary.visit_option')===n)return r}function T(e,t,n){return e.filter((function(e){return Y(e)&&e.reported_date>t.reported_date&&(!n||e.reported_date>=i.clone().subtract(n,'days'))}))}function x(e,t){let n=f(t);n||(n=r(t.reported_date));return e.filter((function(e){return S(e)&&e.reported_date>t.reported_date&&r(e.reported_date)b(e)))},isActivePregnancy:b,countANCFacilityVisits:function(e,t){let n=0;const r=x(e,t);return d(t,'anc_visits_hf.anc_visits_hf_past')&&!isNaN(d(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))&&(n+=parseInt(d(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))),n+=r.reduce((function(e,t){const n=d(t,'anc_visits_hf.anc_visits_hf_past');return n?(e+='yes'===n.last_visit_attended&&1,isNaN(n.visited_hf_count)?e:e+('yes'===n.report_other_visits&&parseInt(n.visited_hf_count))):0}),0),n},knowsHIVStatusInPast3Months:function(e){let t=!1;return c(e,s,i.clone().subtract(3,'months'),i).forEach((function(e){'yes'===d(e,'pregnancy_new_or_current.hiv_status.hiv_status_know')&&(t=!0)})),t},getAllRiskFactors:v,getAllRiskFactorExtra:k,getDangerSignCodes:y,getLatestDangerSignsForPregnancy:function(e,t){if(!t)return[];let n=_(e,t);n||(n=r(t.reported_date));const i=c(e,l,n.toDate(),n.clone().add(u,'days').toDate()),s=[];i.forEach((e=>{S(e)?'yes'===d(e,'pregnancy_summary.visit_option')&&s.push(e):s.push(e)}));const a=h(s,l);return a?y(a):[]},getNextANCVisitDate:function(e,t){let n=d(t,'t_pregnancy_follow_up_date'),i=t.reported_date;return x(e,t).forEach((function(e){e.reported_date>i&&d(e,'t_pregnancy_follow_up_date')&&(i=e.reported_date,n=d(e,'t_pregnancy_follow_up_date'))})),r(n)},isReadyForNewPregnancy:function(e,t){if('person'!==e.type)return!1;const n=h(t,s),a=h(t,o);if(!n&&!a)return!0;if(n){if(!a||a.reported_daten.reported_date))return p(a)O&&'yes'===Y(e,'lmp_updated')&&(O=e.reported_date,Y(e,'lmp_method_approx')&&(b=Y(e,'lmp_method_approx')))}));const x=M(T,e,'migrated'),N=M(T,e,'refused'),P=x||N;if(P){const e='clear_all'===Y(P,'pregnancy_ended.clear_option');t.push({label:'contact.profile.change_care',value:x?'Migrated out of area':'Refusing care',width:6},{label:'contact.profile.tasks_on_off',value:e?'Off':'On',width:6})}if(t.push({label:'Weeks Pregnant',value:D||0===D?{number:D,approximate:'yes'===b}:'contact.profile.value.unknown',translate:!D&&0!==D,filter:D||0===D?'weeksPregnant':'',width:6},{label:'contact.profile.edd',value:_?_.valueOf():'contact.profile.value.unknown',translate:!_,filter:_?'simpleDate':'',width:6}),d){let e='';e=!n&&i?i.join(', '):n.length>1||n&&i?'contact.profile.risk.multiple':'contact.profile.danger_sign.'+n[0],t.push({label:'contact.profile.risk.high',value:e,translate:!0,icon:'icon-risk',width:6})}return a.length>0&&t.push({label:'contact.profile.danger_signs.current',value:a.length>1?'contact.profile.danger_sign.multiple':'contact.profile.danger_sign.'+a[0],translate:!0,width:6}),t.push({label:'contact.profile.visit',value:'contact.profile.visits.of',context:{count:m(T,e),total:8},translate:!0,width:6},{label:'contact.profile.last_visited',value:h.valueOf(),filter:'relativeDay',width:6}),k&&k.isSameOrAfter(s)&&t.push({label:'contact.profile.anc.next',value:k.valueOf(),filter:'simpleDate',width:6}),t},modifyContext:function(e,t){let n=Y(t,'lmp_date_8601'),r=Y(t,'lmp_method_approx'),i=Y(t,'hiv_status_known'),s=Y(t,'deworming_med_received'),a=Y(t,'tt_received');const o=p(T,t),l=S(T,t);let d=Y(t,'t_pregnancy_follow_up_date');u(T,t).forEach((function(e){'yes'===Y(e,'lmp_updated')&&(n=Y(e,'lmp_date_8601'),r=Y(e,'lmp_method_approx')),i=Y(e,'hiv_status_known'),s=Y(e,'deworming_med_received'),a=Y(e,'tt_received'),'yes'===Y(e,'t_pregnancy_follow_up')&&(d=Y(e,'t_pregnancy_follow_up_date'))})),e.lmp_date_8601=n,e.lmp_method_approx=r,e.is_active_pregnancy=!0,e.deworming_med_received=s,e.hiv_tested_past=i,e.tt_received_past=a,e.risk_factor_codes=o.join(' '),e.risk_factor_extra=l.join('; '),e.pregnancy_follow_up_date_recent=d,e.pregnancy_uuid=t._id}},{label:'contact.profile.death.title',appliesToType:'person',appliesIf:function(){return!c(b)},fields:function(){const e=[];let t,n;const r=l(T,['death_report']);if(r){const e=Y(r,'death_details');e&&(t=e.date_of_death,n=e.place_of_death)}else b.date_of_death&&(t=b.date_of_death);return e.push({label:'contact.profile.death.date',value:t||'contact.profile.value.unknown',filter:t?'simpleDate':'',translate:!t,width:6},{label:'contact.profile.death.place',value:n||'contact.profile.value.unknown',translate:!0,width:6}),e}},{label:'contact.profile.pregnancy.past',appliesToType:'report',appliesIf:function(e){if('person'!==b.type)return!1;if('delivery'===e.form)return!0;if('pregnancy'===e.form){if(M(T,e,'abortion')||M(T,e,'miscarriage'))return!0;const t=v(T,e);return t&&s.isSameOrAfter(t.clone().add(42,'weeks'))&&0===d(T,e,a).length}return!1},fields:function(e){const t=[];let n,i,l='',u=0,c=0,h=0;if('delivery'===e.form){const s=r(e.reported_date);n=D(T,['pregnancy'],s.clone().subtract(a,'days').toDate(),s.toDate())[0],Y(e,'delivery_outcome')&&(i=k(e),l=Y(e,'delivery_outcome.delivery_place'),u=Y(e,'delivery_outcome.babies_delivered_num'),c=Y(e,'delivery_outcome.babies_deceased_num'),t.push({label:'contact.profile.delivery_date',value:i?i.valueOf():'',filter:'simpleDate',width:6},{label:'contact.profile.delivery_place',value:l,translate:!0,width:6},{label:'contact.profile.delivered_babies',value:u,width:6}))}else if('pregnancy'===e.form){n=e;const o=v(T,n),l=M(T,n,'abortion'),u=M(T,n,'miscarriage');if(l||u){let e='',n=r(0),i=0;l?(e='abortion',n=r(Y(l,'pregnancy_ended.abortion_date'))):(e='miscarriage',n=r(Y(u,'pregnancy_ended.miscarriage_date'))),i=n.diff(o,'weeks'),t.push({label:'contact.profile.pregnancy.end_early',value:e,translate:!0,width:6},{label:'contact.profile.pregnancy.end_date',value:n.valueOf(),filter:'simpleDate',width:6},{label:'contact.profile.pregnancy.end_weeks',value:i>0?i:'contact.profile.value.unknown',translate:i<=0,width:6})}else o&&s.isSameOrAfter(o.clone().add(42,'weeks'))&&0===d(T,e,a).length&&(i=w(T,e),t.push({label:'contact.profile.delivery_date',value:i?i.valueOf():'contact.profile.value.unknown',filter:'simpleDate',translate:!i,width:6}))}if(c>0&&Y(e,'baby_death')){t.push({label:'contact.profile.deceased_babies',value:c,width:6});let n=Y(e,'baby_death.baby_death_repeat');n||(n=[]);let r=0;n.forEach((function(e){r>0&&t.push({label:'',value:'',width:6}),t.push({label:'contact.profile.newborn.death_date',value:e.baby_death_date,filter:'simpleDate',width:6},{label:'contact.profile.newborn.death_place',value:e.baby_death_place,translate:!0,width:6},{label:'contact.profile.delivery.stillbirthQ',value:e.stillbirth,translate:!0,width:6}),r++,r===n.length&&t.push({label:'',value:'',width:6})}))}if(n){h=m(T,n),t.push({label:'contact.profile.anc_visit',value:h,width:3});if(o(T,n)){let e='';const r=p(T,n),i=S(T,n);e=!r&&i?i.join(', '):r.length>1||r&&i?'contact.profile.risk.multiple':'contact.profile.danger_sign.'+r[0],t.push({label:'contact.profile.risk.high',value:e,translate:!0,icon:'icon-risk',width:6})}}return t}}];e.exports={context:x,cards:P,fields:N}}},t={};function n(r){var i=t[r];if(void 0!==i)return i.exports;var s=t[r]={id:r,loaded:!1,exports:{}};return e[r].call(s.exports,s,s.exports,n),s.loaded=!0,s.exports}return n.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),n(344)})())); return ContactSummary;", "tasks": { - "rules": "(()=>{var e={85:(e,t,n)=>{var r=n(730),i=n(721);function o(e,t,n,r,i,o){var a;if(e.appliesToType){var s;if('contacts'===e.appliesTo){if(!i.contact)return;s='contact'===i.contact.type?i.contact.contact_type:i.contact.type}else{if(!o)return;s=o.form}if(-1===e.appliesToType.indexOf(s))return}if('scheduled_tasks'===e.appliesTo||!e.appliesIf||e.appliesIf(i,o))if('scheduled_tasks'===e.appliesTo){if(o&&e.appliesIf){if(!o.scheduled_tasks)return;for(a=0;a{const t=d(Date.now()),n=864e5,r=['pregnancy'],i=['delivery'],o=['pregnancy_home_visit'],a=['pregnancy','pregnancy_home_visit','pregnancy_facility_visit_reminder','pregnancy_danger_sign','pregnancy_danger_sign_follow_up','delivery'];const s=(e,t)=>['fields',...(t||'').split('.')].reduce(((e,t)=>{if(void 0!==e)return e[t]}),e);function c(e,t){let n;return e.forEach((function(e){t.includes(e.form)&&!e.deleted&&(!n||e.reported_date>n.reported_date)&&(n=e)})),n}function p(e){if(!e)return new Date;const t=e.split(/\\D/),n=new Date(t[0],t[1]-1,t[2]);return function(e){return e instanceof Date&&!isNaN(e)}(n)?n:new Date}function l(e){const t=new Date(e);return t.setHours(0),t.setMinutes(0),t.setSeconds(0),t.setMilliseconds(0),t}function d(e){if('string'==typeof e){if(''===e)return null;e=p(e)}return l(e).getTime()}function _(e,t){const n=l(new Date(e));return n.setDate(n.getDate()+t),n}function u(e){return r.includes(e.form)}function f(e){return o.includes(e.form)}const g=function(e,t){let n;return e.forEach((function(e){t.includes(e.form)&&(!n||e.reported_date>n.reported_date)&&(n=e)})),n},y=function(e){return u(e)&&d(s(e,'lmp_date_8601'))};function m(e,t){return e.reports.filter((function(e){let n=y(t);return n||(n=t.reported_date),f(e)&&e.reported_date>t.reported_date&&e.reported_date<_(n,294)}))}function v(e,t){let n=y(t),r=t.reported_date;return m(e,t).forEach((function(e){const t=function(e){return f(e)&&d(s(e,'lmp_date_8601'))}(e);e.reported_date>r&&''!==t&&t!==n&&(r=e.reported_date,n=t)})),n}e.exports={today:t,MS_IN_DAY:n,MAX_DAYS_IN_PREGNANCY:294,addDays:_,isAlive:function(e){return e&&e.contact&&!e.contact.date_of_death},getTimeForMidnight:l,isFormArraySubmittedInWindow:function(e,t,n,r,i){let o=!1,a=0;return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&(o=!0,i&&a++)})),i?a>=i:o},isFormArraySubmittedInWindowExcludingThisReport:function(e,t,n,r,i,o){let a=!1,s=0;return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&e._id!==i._id&&(a=!0,o&&s++)})),o?s>=o:a},getDateMS:d,getDateISOLocal:p,isDeliveryForm:function(e){return i.includes(e.form)},getMostRecentReport:c,getNewestPregnancyTimestamp:function(e){if(!e.contact)return;const t=c(e.reports,'pregnancy');return t?t.reported_date:0},getNewestDeliveryTimestamp:function(e){if(!e.contact)return;const t=c(e.reports,'delivery');return t?t.reported_date:0},getReportsSubmittedInWindow:function(e,t,n,r,i){const o=[];return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&(i&&!i(e)||o.push(e))})),o},countReportsSubmittedInWindow:function(e,t,n,r,i){let o=0;return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&(i&&!i(e)||o++)})),o},countANCFacilityVisits:function(e,t){let n=0;const r=m(e,t);return s(t,'anc_visits_hf.anc_visits_hf_past')&&!isNaN(s(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))&&(n+=parseInt(s(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))),n+=r.reduce((function(e,t){const n=s(t,'anc_visits_hf.anc_visits_hf_past');return n?(e+='yes'===n.last_visit_attended&&1,isNaN(n.visited_hf_count)?e:e+('yes'===n.report_other_visits&&parseInt(n.visited_hf_count))):0}),0),n},isFacilityDelivery:function(e,t){return!!e&&(1===arguments.length&&(t=e),'yes'===s(t,'facility_delivery'))},getMostRecentLMPDateForPregnancy:v,getNewestReport:g,getSubsequentPregnancyFollowUps:m,isActivePregnancy:function(e,r){if(!u(r))return!1;const i=(v(e,r)||r.reported_date)>t-254016e5,a=function(e,r,i){return e.reports.filter((function(e){return'delivery'===e.form&&e.reported_date>r.reported_date&&(!i||r.reported_date>=t-i*n)}))}(e,r,42).length>0,c=function(e,t){return e.reports.filter((function(e){return u(e)&&e.reported_date>t.reported_date}))}(e,r).length>0;return i&&!a&&!c&&!function(e,t){const n=m(e,t),r=g(n,o);return r&&'abortion'===s(r,'pregnancy_summary.visit_option')}(e,r)&&!function(e,t){const n=m(e,t),r=g(n,o);return r&&'miscarriage'===s(r,'pregnancy_summary.visit_option')}(e,r)},getRecentANCVisitWithEvent:function(e,t,n){const r=m(e,t),i=g(r,o);if(i&&s(i,'pregnancy_summary.visit_option')===n)return i},isPregnancyTaskMuted:function(e){const t=g(e.reports,a);return t&&f(t)&&'clear_all'===s(t,'pregnancy_ended.clear_option')},getField:s}},721:e=>{e.exports={defaultResolvedIf:function(e,t,n,r,i){var o,a;i||(i=Utils);var s=function(e){var t;if(!e||!e.actions)return;return(t=e.actions.find((function(e){return!e.type||'report'===e.type})))&&t.form}(this.definition);if(!s)throw new Error('Could not find the default resolving form!');return o=0,o=t?Math.max(i.addDate(r,-n.start).getTime(),t.reported_date+1):i.addDate(r,-n.start).getTime(),a=i.addDate(r,n.end+1).getTime(),i.isFormSubmittedInWindow(e.reports,s,o,a)}}},730:e=>{function t(e,n){var r=Object.keys(e);for(var i in r){var o=r[i];switch(typeof e[o]){case'object':t(e[o],n);break;case'function':e[o]=e[o].bind(n)}}}function n(e){var t=Object.assign({},e),r=Object.keys(t);for(var i in r){var o=r[i];if(Array.isArray(t[o])){t[o]=t[o].slice(0);for(var a=0;a{const r=n(190),{isAlive:i,getSubsequentPregnancyFollowUps:o,getMostRecentLMPDateForPregnancy:a,isActivePregnancy:s,countANCFacilityVisits:c,getField:p}=r;e.exports=[{id:'deaths-this-month',type:'count',icon:'icon-death-general',goal:0,translation_key:'targets.death_reporting.deaths.title',subtitle_translation_key:'targets.this_month.subtitle',appliesTo:'contacts',appliesToType:['person'],appliesIf:function(e){return!i(e)},date:e=>e.contact.date_of_death},{id:'pregnancy-registrations-this-month',type:'count',icon:'icon-pregnancy',goal:20,translation_key:'targets.anc.new_pregnancy_registrations.title',subtitle_translation_key:'targets.this_month.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){return!!t&&a(e,t)},date:'reported',idType:'contact'},{id:'births-this-month',type:'count',icon:'icon-infant',goal:-1,translation_key:'targets.births.title',subtitle_translation_key:'targets.this_month.subtitle',appliesTo:'contacts',appliesToType:['person'],appliesIf:function(e){return e&&e.contact&&e.contact.date_of_birth},date:e=>e.contact.date_of_birth,dhis:{dataElement:'kB0ZBFisE0e'}},{id:'active-pregnancies',type:'count',icon:'icon-pregnancy',goal:-1,translation_key:'targets.anc.active_pregnancies.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){return s(e,t)},date:'now',idType:'contact'},{id:'active-pregnancies-1+-visits',type:'count',icon:'icon-clinic',goal:-1,translation_key:'targets.anc.active_pregnancies_1p_visits.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){if(!s(e,t))return!1;return c(e,t)>0},date:'now',idType:'contact'},{id:'facility-deliveries',type:'percent',icon:'icon-mother-child',goal:-1,translation_key:'targets.anc.facility_deliveries.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['delivery'],appliesIf:function(e,t){return p(t,'delivery_outcome.delivery_place')},passesIf:function(e,t){return'health_facility'===p(t,'delivery_outcome.delivery_place')},date:'now',idType:'contact',dhis:{dataElement:'e22tIwy1nKR',categoryOptionCombo:'HllvX50cXC0',attributeOptionCombo:'HllvX50cXC0'}},{id:'active-pregnancies-4+-visits',type:'count',icon:'icon-clinic',goal:-1,translation_key:'targets.anc.active_pregnancies_4p_visits.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){if(!s(e,t))return!1;return c(e,t)>3},date:'now',idType:'contact'},{id:'active-pregnancies-8+-contacts',type:'count',icon:'icon-follow-up',goal:-1,translation_key:'targets.anc.active_pregnancies_8p_contacts.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){if(!s(e,t))return!1;return 1+(o(e,t).length||0)+(c(e,t)||0)>7},date:'now',idType:'contact'}]},945:(e,t,n)=>{var r=n(730);function i(e,t,n,r,i,o){var a=!!o;if(i.contact){var s='contact'===i.contact.type?i.contact.contact_type:i.contact.type,c=a?o.form:s;if(!(e.appliesToType&&e.appliesToType.indexOf(c)<0)&&(!e.appliesIf||e.appliesIf(i,o)))for(var p=a?o:i.contact,l=function(e,t,n){var r;return r='function'==typeof e.idType?e.idType(t,n):'report'===e.idType?n&&n._id:t.contact&&t.contact._id,Array.isArray(r)||(r=[r]),r}(e,i,o),d=!e.passesIf||!!e.passesIf(i,o),_=function(e,t,n,r){if('function'==typeof e.date)return e.date(n,r)||t.now().getTime();if(void 0===e.date||null===e.date||'now'===e.date)return t.now().getTime();if('reported'===e.date)return r?r.reported_date:n.contact.reported_date;throw new Error('Unrecognised value for target.date: '+e.date)}(e,n,i,o),u=e.groupBy&&e.groupBy(i,o),f=0;f{const r=n(190),{MAX_DAYS_IN_PREGNANCY:i,today:o,getNewestPregnancyTimestamp:a,getNewestDeliveryTimestamp:s,isAlive:c,isFormArraySubmittedInWindow:p,getDateISOLocal:l,getTimeForMidnight:d,isDeliveryForm:_,getMostRecentLMPDateForPregnancy:u,addDays:f,getRecentANCVisitWithEvent:g,isPregnancyTaskMuted:y,getField:m}=r,v=(e,t,n)=>({id:`pregnancy-home-visit-week${e}`,start:t,end:n,dueDate:function(t,n,r){const i=u(n,r);return f(i||r.reported_date,7*e)}});function h(e,t,n,r){if(t.reported_date=o},resolvedIf:h,actions:[{type:'report',form:'pregnancy_home_visit',label:'Pregnancy home visit'}],events:[...Array(21).keys()].map((e=>v(2*(e+1),6,7)))},{name:'anc.facility_reminder',icon:'icon-pregnancy',title:'task.anc.facility_reminder.title',appliesTo:'reports',appliesToType:['pregnancy','pregnancy_home_visit'],appliesIf:function(e,t){return m(t,'t_pregnancy_follow_up_date')},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date),o=f(r,n.end+1).getTime();return p(e.reports,['pregnancy_facility_visit_reminder'],i,o)},actions:[{type:'report',form:'pregnancy_facility_visit_reminder',label:'Pregnancy facility visit reminder',modifyContent:function(e,t,n){e.source_visit_date=m(n,'t_pregnancy_follow_up_date')}}],events:[{id:'pregnancy-facility-visit-reminder',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_pregnancy_follow_up_date'))}}]},{name:'anc.pregnancy_danger_sign_followup',icon:'icon-pregnancy-danger',title:'task.anc.pregnancy_danger_sign_followup.title',appliesTo:'reports',appliesToType:['pregnancy','pregnancy_home_visit','pregnancy_danger_sign','pregnancy_danger_sign_follow_up'],appliesIf:function(e,t){return'yes'===m(t,'t_danger_signs_referral_follow_up')&&c(e)},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date+1),o=f(r,n.end+1).getTime();return p(e.reports,['pregnancy_danger_sign_follow_up'],i,o)},actions:[{type:'report',form:'pregnancy_danger_sign_follow_up'}],events:[{id:'pregnancy-danger-sign-follow-up',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_danger_signs_referral_follow_up_date'))}}]},{name:'anc.delivery',icon:'icon-mother-child',title:'task.anc.delivery.title',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){const n=u(e,t);return n&&f(n,336)>=o&&c(e)},resolvedIf:function(e,t,n,r){if(g(e,t,'abortion')||g(e,t,'miscarriage'))return!0;if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date),o=f(r,n.end+1).getTime();return p(e.reports,['delivery'],i,o)},actions:[{type:'report',form:'delivery'}],events:[{id:'delivery-reminder',start:28,end:42,dueDate:function(e,t,n){return f(u(t,n),i)}}]},{name:'pnc.danger_sign_followup_mother',icon:'icon-follow-up',title:'task.pnc.danger_sign_followup_mother.title',appliesTo:'reports',appliesToType:['delivery','pnc_danger_sign_follow_up_mother'],appliesIf:function(e,t){return'yes'===m(t,'t_danger_signs_referral_follow_up')&&c(e)},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date+1),o=f(r,n.end+1).getTime();return p(e.reports,['pnc_danger_sign_follow_up_mother'],i,o)},actions:[{type:'report',form:'pnc_danger_sign_follow_up_mother',modifyContent:function(e,t,n){_(n)?e.delivery_uuid=n._id:e.delivery_uuid=m(n,'inputs.delivery_uuid')}}],events:[{id:'pnc-danger-sign-follow-up-mother',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_danger_signs_referral_follow_up_date'))}}]},{name:'pnc.danger_sign_followup_baby.from_contact',icon:'icon-follow-up',title:'task.pnc.danger_sign_followup_baby.title',appliesTo:'contacts',appliesToType:['person'],appliesIf:function(e){return e.contact&&'yes'===e.contact.t_danger_signs_referral_follow_up&&c(e)},resolvedIf:function(e,t,n,r){const i=Math.max(f(r,-n.start).getTime(),e.contact.reported_date),o=f(r,n.end).getTime();return p(e.reports,['pnc_danger_sign_follow_up_baby'],i,o)},priority:function(){return{level:10,label:'High'}},actions:[{type:'report',form:'pnc_danger_sign_follow_up_baby',modifyContent:function(e,t){e.delivery_uuid=t.contact.created_by_doc}}],events:[{id:'pnc-danger-sign-follow-up-baby',start:3,end:7,dueDate:function(e,t){return l(t.contact.t_danger_signs_referral_follow_up_date)}}]},{name:'pnc.danger_sign_followup_baby.from_report',icon:'icon-follow-up',title:'task.pnc.danger_sign_followup_baby.title',appliesTo:'reports',appliesToType:['pnc_danger_sign_follow_up_baby'],appliesIf:function(e,t){return'yes'===m(t,'t_danger_signs_referral_follow_up')&&c(e)},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date+1),o=f(r,n.end+1).getTime();return p(e.reports,['pnc_danger_sign_follow_up_baby'],i,o)},priority:function(){return{level:10,label:'High'}},actions:[{type:'report',form:'pnc_danger_sign_follow_up_baby',modifyContent:function(e,t,n){e.delivery_uuid=m(n,'inputs.delivery_uuid')}}],events:[{id:'pnc-danger-sign-follow-up-baby',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_danger_signs_referral_follow_up_date'))}}]}]}},t={};function n(r){var i=t[r];if(void 0!==i)return i.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}var r=n(991),i=n(931),o=n(85);n(945)(i,c,Utils,Target,emit),o(r,c,Utils,Task,emit),emit('_complete',{_id:!0})})();", + "rules": "(()=>{var e={730(e){function t(e,n){var r=Object.keys(e);for(var i in r){var o=r[i];switch(typeof e[o]){case'object':t(e[o],n);break;case'function':e[o]=e[o].bind(n)}}}function n(e){var t=Object.assign({},e),r=Object.keys(t);for(var i in r){var o=r[i];if(Array.isArray(t[o])){t[o]=t[o].slice(0);for(var a=0;a['fields',...(t||'').split('.')].reduce(((e,t)=>{if(void 0!==e)return e[t]}),e);function c(e,t){let n;return e.forEach((function(e){t.includes(e.form)&&!e.deleted&&(!n||e.reported_date>n.reported_date)&&(n=e)})),n}function p(e){if(!e)return new Date;const t=e.split(/\\D/),n=new Date(t[0],t[1]-1,t[2]);return function(e){return e instanceof Date&&!isNaN(e)}(n)?n:new Date}function l(e){const t=new Date(e);return t.setHours(0),t.setMinutes(0),t.setSeconds(0),t.setMilliseconds(0),t}function d(e){if('string'==typeof e){if(''===e)return null;e=p(e)}return l(e).getTime()}function _(e,t){const n=l(new Date(e));return n.setDate(n.getDate()+t),n}function u(e){return r.includes(e.form)}function f(e){return o.includes(e.form)}const g=function(e,t){let n;return e.forEach((function(e){t.includes(e.form)&&(!n||e.reported_date>n.reported_date)&&(n=e)})),n},y=function(e){return u(e)&&d(s(e,'lmp_date_8601'))};function m(e,t){return e.reports.filter((function(e){let n=y(t);return n||(n=t.reported_date),f(e)&&e.reported_date>t.reported_date&&e.reported_date<_(n,294)}))}function v(e,t){let n=y(t),r=t.reported_date;return m(e,t).forEach((function(e){const t=function(e){return f(e)&&d(s(e,'lmp_date_8601'))}(e);e.reported_date>r&&''!==t&&t!==n&&(r=e.reported_date,n=t)})),n}e.exports={today:t,MS_IN_DAY:n,MAX_DAYS_IN_PREGNANCY:294,addDays:_,isAlive:function(e){return e&&e.contact&&!e.contact.date_of_death},getTimeForMidnight:l,isFormArraySubmittedInWindow:function(e,t,n,r,i){let o=!1,a=0;return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&(o=!0,i&&a++)})),i?a>=i:o},isFormArraySubmittedInWindowExcludingThisReport:function(e,t,n,r,i,o){let a=!1,s=0;return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&e._id!==i._id&&(a=!0,o&&s++)})),o?s>=o:a},getDateMS:d,getDateISOLocal:p,isDeliveryForm:function(e){return i.includes(e.form)},getMostRecentReport:c,getNewestPregnancyTimestamp:function(e){if(!e.contact)return;const t=c(e.reports,'pregnancy');return t?t.reported_date:0},getNewestDeliveryTimestamp:function(e){if(!e.contact)return;const t=c(e.reports,'delivery');return t?t.reported_date:0},getReportsSubmittedInWindow:function(e,t,n,r,i){const o=[];return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&(i&&!i(e)||o.push(e))})),o},countReportsSubmittedInWindow:function(e,t,n,r,i){let o=0;return e.forEach((function(e){t.includes(e.form)&&e.reported_date>=n&&e.reported_date<=r&&(i&&!i(e)||o++)})),o},countANCFacilityVisits:function(e,t){let n=0;const r=m(e,t);return s(t,'anc_visits_hf.anc_visits_hf_past')&&!isNaN(s(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))&&(n+=parseInt(s(t,'anc_visits_hf.anc_visits_hf_past.visited_hf_count'))),n+=r.reduce((function(e,t){const n=s(t,'anc_visits_hf.anc_visits_hf_past');return n?(e+='yes'===n.last_visit_attended&&1,isNaN(n.visited_hf_count)?e:e+('yes'===n.report_other_visits&&parseInt(n.visited_hf_count))):0}),0),n},isFacilityDelivery:function(e,t){return!!e&&(1===arguments.length&&(t=e),'yes'===s(t,'facility_delivery'))},getMostRecentLMPDateForPregnancy:v,getNewestReport:g,getSubsequentPregnancyFollowUps:m,isActivePregnancy:function(e,r){if(!u(r))return!1;const i=(v(e,r)||r.reported_date)>t-254016e5,a=function(e,r,i){return e.reports.filter((function(e){return'delivery'===e.form&&e.reported_date>r.reported_date&&(!i||r.reported_date>=t-i*n)}))}(e,r,42).length>0,c=function(e,t){return e.reports.filter((function(e){return u(e)&&e.reported_date>t.reported_date}))}(e,r).length>0;return i&&!a&&!c&&!function(e,t){const n=m(e,t),r=g(n,o);return r&&'abortion'===s(r,'pregnancy_summary.visit_option')}(e,r)&&!function(e,t){const n=m(e,t),r=g(n,o);return r&&'miscarriage'===s(r,'pregnancy_summary.visit_option')}(e,r)},getRecentANCVisitWithEvent:function(e,t,n){const r=m(e,t),i=g(r,o);if(i&&s(i,'pregnancy_summary.visit_option')===n)return i},isPregnancyTaskMuted:function(e){const t=g(e.reports,a);return t&&f(t)&&'clear_all'===s(t,'pregnancy_ended.clear_option')},getField:s}},931(e,t,n){const r=n(190),{isAlive:i,getSubsequentPregnancyFollowUps:o,getMostRecentLMPDateForPregnancy:a,isActivePregnancy:s,countANCFacilityVisits:c,getField:p}=r;e.exports=[{id:'deaths-this-month',type:'count',icon:'icon-death-general',goal:0,translation_key:'targets.death_reporting.deaths.title',subtitle_translation_key:'targets.this_month.subtitle',appliesTo:'contacts',appliesToType:['person'],appliesIf:function(e){return!i(e)},date:e=>e.contact.date_of_death},{id:'pregnancy-registrations-this-month',type:'count',icon:'icon-pregnancy',goal:20,translation_key:'targets.anc.new_pregnancy_registrations.title',subtitle_translation_key:'targets.this_month.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){return!!t&&a(e,t)},date:'reported',idType:'contact'},{id:'births-this-month',type:'count',icon:'icon-infant',goal:-1,translation_key:'targets.births.title',subtitle_translation_key:'targets.this_month.subtitle',appliesTo:'contacts',appliesToType:['person'],appliesIf:function(e){return e&&e.contact&&e.contact.date_of_birth},date:e=>e.contact.date_of_birth,dhis:{dataElement:'kB0ZBFisE0e'}},{id:'active-pregnancies',type:'count',icon:'icon-pregnancy',goal:-1,translation_key:'targets.anc.active_pregnancies.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){return s(e,t)},date:'now',idType:'contact'},{id:'active-pregnancies-1+-visits',type:'count',icon:'icon-clinic',goal:-1,translation_key:'targets.anc.active_pregnancies_1p_visits.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){if(!s(e,t))return!1;return c(e,t)>0},date:'now',idType:'contact'},{id:'facility-deliveries',type:'percent',icon:'icon-mother-child',goal:-1,translation_key:'targets.anc.facility_deliveries.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['delivery'],appliesIf:function(e,t){return p(t,'delivery_outcome.delivery_place')},passesIf:function(e,t){return'health_facility'===p(t,'delivery_outcome.delivery_place')},date:'now',idType:'contact',dhis:{dataElement:'e22tIwy1nKR',categoryOptionCombo:'HllvX50cXC0',attributeOptionCombo:'HllvX50cXC0'}},{id:'active-pregnancies-4+-visits',type:'count',icon:'icon-clinic',goal:-1,translation_key:'targets.anc.active_pregnancies_4p_visits.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){if(!s(e,t))return!1;return c(e,t)>3},date:'now',idType:'contact'},{id:'active-pregnancies-8+-contacts',type:'count',icon:'icon-follow-up',goal:-1,translation_key:'targets.anc.active_pregnancies_8p_contacts.title',subtitle_translation_key:'targets.all_time.subtitle',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){if(!s(e,t))return!1;return 1+(o(e,t).length||0)+(c(e,t)||0)>7},date:'now',idType:'contact'}]},991(e,t,n){const r=n(190),{MAX_DAYS_IN_PREGNANCY:i,today:o,getNewestPregnancyTimestamp:a,getNewestDeliveryTimestamp:s,isAlive:c,isFormArraySubmittedInWindow:p,getDateISOLocal:l,getTimeForMidnight:d,isDeliveryForm:_,getMostRecentLMPDateForPregnancy:u,addDays:f,getRecentANCVisitWithEvent:g,isPregnancyTaskMuted:y,getField:m}=r,v=(e,t,n)=>({id:`pregnancy-home-visit-week${e}`,start:t,end:n,dueDate:function(t,n,r){const i=u(n,r);return f(i||r.reported_date,7*e)}});function h(e,t,n,r){if(t.reported_date=o},resolvedIf:h,actions:[{type:'report',form:'pregnancy_home_visit',label:'Pregnancy home visit'}],events:[...Array(21).keys()].map((e=>v(2*(e+1),6,7)))},{name:'anc.facility_reminder',icon:'icon-pregnancy',title:'task.anc.facility_reminder.title',appliesTo:'reports',appliesToType:['pregnancy','pregnancy_home_visit'],appliesIf:function(e,t){return m(t,'t_pregnancy_follow_up_date')},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date),o=f(r,n.end+1).getTime();return p(e.reports,['pregnancy_facility_visit_reminder'],i,o)},actions:[{type:'report',form:'pregnancy_facility_visit_reminder',label:'Pregnancy facility visit reminder',modifyContent:function(e,t,n){e.source_visit_date=m(n,'t_pregnancy_follow_up_date')}}],events:[{id:'pregnancy-facility-visit-reminder',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_pregnancy_follow_up_date'))}}]},{name:'anc.pregnancy_danger_sign_followup',icon:'icon-pregnancy-danger',title:'task.anc.pregnancy_danger_sign_followup.title',appliesTo:'reports',appliesToType:['pregnancy','pregnancy_home_visit','pregnancy_danger_sign','pregnancy_danger_sign_follow_up'],appliesIf:function(e,t){return'yes'===m(t,'t_danger_signs_referral_follow_up')&&c(e)},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date+1),o=f(r,n.end+1).getTime();return p(e.reports,['pregnancy_danger_sign_follow_up'],i,o)},actions:[{type:'report',form:'pregnancy_danger_sign_follow_up'}],events:[{id:'pregnancy-danger-sign-follow-up',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_danger_signs_referral_follow_up_date'))}}]},{name:'anc.delivery',icon:'icon-mother-child',title:'task.anc.delivery.title',appliesTo:'reports',appliesToType:['pregnancy'],appliesIf:function(e,t){const n=u(e,t);return n&&f(n,336)>=o&&c(e)},resolvedIf:function(e,t,n,r){if(g(e,t,'abortion')||g(e,t,'miscarriage'))return!0;if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date),o=f(r,n.end+1).getTime();return p(e.reports,['delivery'],i,o)},actions:[{type:'report',form:'delivery'}],events:[{id:'delivery-reminder',start:28,end:42,dueDate:function(e,t,n){return f(u(t,n),i)}}]},{name:'pnc.danger_sign_followup_mother',icon:'icon-follow-up',title:'task.pnc.danger_sign_followup_mother.title',appliesTo:'reports',appliesToType:['delivery','pnc_danger_sign_follow_up_mother'],appliesIf:function(e,t){return'yes'===m(t,'t_danger_signs_referral_follow_up')&&c(e)},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date+1),o=f(r,n.end+1).getTime();return p(e.reports,['pnc_danger_sign_follow_up_mother'],i,o)},actions:[{type:'report',form:'pnc_danger_sign_follow_up_mother',modifyContent:function(e,t,n){_(n)?e.delivery_uuid=n._id:e.delivery_uuid=m(n,'inputs.delivery_uuid')}}],events:[{id:'pnc-danger-sign-follow-up-mother',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_danger_signs_referral_follow_up_date'))}}]},{name:'pnc.danger_sign_followup_baby.from_contact',icon:'icon-follow-up',title:'task.pnc.danger_sign_followup_baby.title',appliesTo:'contacts',appliesToType:['person'],appliesIf:function(e){return e.contact&&'yes'===e.contact.t_danger_signs_referral_follow_up&&c(e)},resolvedIf:function(e,t,n,r){const i=Math.max(f(r,-n.start).getTime(),e.contact.reported_date),o=f(r,n.end).getTime();return p(e.reports,['pnc_danger_sign_follow_up_baby'],i,o)},priority:function(){return{level:10,label:'High'}},actions:[{type:'report',form:'pnc_danger_sign_follow_up_baby',modifyContent:function(e,t){e.delivery_uuid=t.contact.created_by_doc}}],events:[{id:'pnc-danger-sign-follow-up-baby',start:3,end:7,dueDate:function(e,t){return l(t.contact.t_danger_signs_referral_follow_up_date)}}]},{name:'pnc.danger_sign_followup_baby.from_report',icon:'icon-follow-up',title:'task.pnc.danger_sign_followup_baby.title',appliesTo:'reports',appliesToType:['pnc_danger_sign_follow_up_baby'],appliesIf:function(e,t){return'yes'===m(t,'t_danger_signs_referral_follow_up')&&c(e)},resolvedIf:function(e,t,n,r){if(y(e))return!0;const i=Math.max(f(r,-n.start).getTime(),t.reported_date+1),o=f(r,n.end+1).getTime();return p(e.reports,['pnc_danger_sign_follow_up_baby'],i,o)},priority:function(){return{level:10,label:'High'}},actions:[{type:'report',form:'pnc_danger_sign_follow_up_baby',modifyContent:function(e,t,n){e.delivery_uuid=m(n,'inputs.delivery_uuid')}}],events:[{id:'pnc-danger-sign-follow-up-baby',start:3,end:7,dueDate:function(e,t,n){return l(m(n,'t_danger_signs_referral_follow_up_date'))}}]}]}},t={};function n(r){var i=t[r];if(void 0!==i)return i.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}var r=n(991),i=n(931),o=n(85);n(945)(i,c,Utils,Target,emit),o(r,c,Utils,Task,emit),emit('_complete',{_id:!0})})();", "isDeclarative": true, "targets": { "enabled": true, diff --git a/ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js b/ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js deleted file mode 100644 index c87b7d182f8..00000000000 --- a/ddocs/medic-db/medic-client/views/docs_by_id_lineage/map.js +++ /dev/null @@ -1,20 +0,0 @@ -function(doc) { - - var emitLineage = function(contact, depth) { - while (contact && contact._id) { - emit([ doc._id, depth++ ], { _id: contact._id }); - contact = contact.parent; - } - }; - - var types = [ 'contact', 'district_hospital', 'health_center', 'clinic', 'person' ]; - - if (types.indexOf(doc.type) !== -1) { - // contact - emitLineage(doc, 0); - } else if (doc.type === 'data_record' && doc.form) { - // report - emit([ doc._id, 0 ]); - emitLineage(doc.contact, 1); - } -} diff --git a/shared-libs/cht-datasource/src/local/libs/lineage.ts b/shared-libs/cht-datasource/src/local/libs/lineage.ts index 5c53ab7e326..c32f2ed9e5b 100644 --- a/shared-libs/cht-datasource/src/local/libs/lineage.ts +++ b/shared-libs/cht-datasource/src/local/libs/lineage.ts @@ -15,7 +15,7 @@ import { Nullable } from '../../libs/core'; import { Doc } from '../../libs/doc'; -import { getDocsByIds, queryDocsByRange } from './doc'; +import { getDocsByIds } from './doc'; import logger from '@medic/logger'; import lineageFactory from '@medic/lineage'; import * as Report from '../../report'; @@ -27,14 +27,41 @@ import { InvalidArgumentError } from '../../libs/error'; import contactTypeUtils from '@medic/contact-types-utils'; import { isEqual } from 'lodash'; +const getParentIds = (doc: Doc): string[] => { + const parentIds: string[] = []; + let current: unknown = doc.type === 'data_record' ? doc.contact : doc.parent; + while (isRecord(current)) { + if (typeof current._id === 'string') { + parentIds.push(current._id); + } + current = current.parent; + } + return parentIds; +}; + /** * Returns the identified document along with the parent documents recorded for its lineage. The returned array is * sorted such that the identified document is the first element and the parent documents are in order of lineage. * @internal */ export const getLineageDocsById = (medicDb: PouchDB.Database): (id: string) => Promise[]> => { - const fn = queryDocsByRange(medicDb, 'medic-client/docs_by_id_lineage'); - return (id: string) => fn([id], [id, {}]); + const getMedicDocsById = getDocsByIds(medicDb); + return async (id: string) => { + try { + const doc = await medicDb.get(id); + const parentIds = getParentIds(doc); + if (parentIds.length === 0) { + return [doc]; + } + const ancestors = await getMedicDocsById(parentIds); + return [doc, ...ancestors]; + } catch (err: unknown) { + if ((err as PouchDB.Core.Error).status === 404) { + return []; + } + throw err; + } + }; }; /** @internal */ diff --git a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts index 44fb2385280..db7e37f0919 100644 --- a/shared-libs/cht-datasource/test/local/libs/doc.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/doc.spec.ts @@ -245,11 +245,11 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc1._id); + const result = await queryDocsByRange(db, 'medic-client/contacts_by_type')(doc0._id, doc1._id); expect(result).to.deep.equal([doc0, doc1, doc2]); - expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { include_docs: true, startkey: doc0._id, endkey: doc1._id, @@ -271,10 +271,10 @@ describe('local doc lib', () => { }); isDoc.returns(true); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc2._id, limit, skip); + const result = await queryDocsByRange(db, 'medic-client/contacts_by_type')(doc0._id, doc2._id, limit, skip); expect(result).to.deep.equal([doc0, null, doc2]); - expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { startkey: doc0._id, endkey: doc2._id, include_docs: true, @@ -291,10 +291,10 @@ describe('local doc lib', () => { }); isDoc.returns(false); - const result = await queryDocsByRange(db, 'medic-client/docs_by_id_lineage')(doc0._id, doc0._id, limit, skip); + const result = await queryDocsByRange(db, 'medic-client/contacts_by_type')(doc0._id, doc0._id, limit, skip); expect(result).to.deep.equal([null]); - expect(dbQuery.calledOnceWithExactly('medic-client/docs_by_id_lineage', { + expect(dbQuery.calledOnceWithExactly('medic-client/contacts_by_type', { startkey: doc0._id, endkey: doc0._id, include_docs: true, diff --git a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts index 9312ef26632..895aee76bd9 100644 --- a/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts +++ b/shared-libs/cht-datasource/test/local/libs/lineage.spec.ts @@ -30,18 +30,26 @@ describe('local lineage lib', () => { it('getLineageDocsById', async () => { const uuid = '123'; - const queryFn = sinon.stub().resolves([]); - const queryDocsByRange = sinon - .stub(LocalDoc, 'queryDocsByRange') - .returns(queryFn); - const medicDb = { hello: 'world' } as unknown as PouchDB.Database; + const doc = { _id: uuid, parent: { _id: 'parent1' } }; + const parentDoc = { _id: 'parent1' }; + medicGet.resolves(doc); + const getDocsByIdsInner = sinon.stub().resolves([parentDoc]); + const getDocsByIdsOuter = sinon.stub(LocalDoc, 'getDocsByIds').returns(getDocsByIdsInner); const fn = Lineage.getLineageDocsById(medicDb); const result = await fn(uuid); + expect(result).to.deep.equal([doc, parentDoc]); + expect(medicGet.calledOnceWithExactly(uuid)).to.be.true; + expect(getDocsByIdsOuter.calledOnceWithExactly(medicDb)).to.be.true; + expect(getDocsByIdsInner.calledOnceWithExactly(['parent1'])).to.be.true; + }); + + it('getLineageDocsById handles 404', async () => { + medicGet.rejects({ status: 404 }); + const fn = Lineage.getLineageDocsById(medicDb); + const result = await fn('missing'); expect(result).to.deep.equal([]); - expect(queryDocsByRange.calledOnceWithExactly(medicDb, 'medic-client/docs_by_id_lineage')).to.be.true; - expect(queryFn.calledOnceWithExactly([uuid], [uuid, {}])).to.be.true; }); describe('getPrimaryContactIds', () => { diff --git a/shared-libs/lineage/src/hydration.js b/shared-libs/lineage/src/hydration.js index 33ee9c0547f..275106d54e3 100644 --- a/shared-libs/lineage/src/hydration.js +++ b/shared-libs/lineage/src/hydration.js @@ -229,16 +229,25 @@ module.exports = function(Promise, DB) { }; const fetchLineageById = function(id) { - const options = { - startkey: [id], - endkey: [id, {}], - include_docs: true - }; - return DB.query('medic-client/docs_by_id_lineage', options) - .then(function(result) { - return result.rows.map(function(row) { - return row.doc; + // The lineage of a document is recorded on the document itself: the parent chain for a contact, or the contact's + // parent chain for a report. Fetch the document, then fetch its ancestors by id, preserving lineage order. + return DB.get(id) + .then(function(doc) { + const startParent = utils.isReport(doc) ? doc.contact : doc.parent; + const parentIds = extractParentIds(startParent); + if (!parentIds.length) { + return [doc]; + } + return fetchDocs(parentIds).then(function(ancestors) { + const ancestorsById = new Map(ancestors.map(ancestor => [ancestor._id, ancestor])); + return [doc, ...parentIds.map(parentId => ancestorsById.get(parentId))]; }); + }) + .catch(function(err) { + if (err.status === 404) { + return []; + } + throw err; }); }; diff --git a/shared-libs/lineage/test/hydration.spec.js b/shared-libs/lineage/test/hydration.spec.js index 6da39b4a5ef..f75e383a27a 100644 --- a/shared-libs/lineage/test/hydration.spec.js +++ b/shared-libs/lineage/test/hydration.spec.js @@ -24,15 +24,15 @@ describe('Lineage', function() { describe('fetchLineageById', function() { it('queries db with correct parameters', function() { - query.resolves({ rows: [] }); + get.resolves({ _id: 'banana', parent: { _id: 'apple' } }); + allDocs.resolves({ rows: [{ doc: { _id: 'apple' } }] }); const id = 'banana'; return lineage.fetchLineageById(id).then(() => { - chai.expect(query.callCount).to.equal(1); - chai.expect(query.getCall(0).args[0]).to.equal('medic-client/docs_by_id_lineage'); - chai.expect(query.getCall(0).args[1].startkey).to.deep.equal([ id ]); - chai.expect(query.getCall(0).args[1].endkey).to.deep.equal([ id, {} ]); - chai.expect(query.getCall(0).args[1].include_docs).to.deep.equal(true); + chai.expect(get.callCount).to.equal(1); + chai.expect(get.getCall(0).args[0]).to.equal('banana'); + chai.expect(allDocs.callCount).to.equal(1); + chai.expect(allDocs.getCall(0).args[0]).to.deep.equal({ keys: ['apple'], include_docs: true }); }); }); }); @@ -164,7 +164,6 @@ describe('Lineage', function() { describe('fetchHydratedDoc', function() { it('supports callback as second argument', function(done) { - query.resolves({ rows: [] }); get.resolves({ _id: 'a', type: 'person' }); lineage.fetchHydratedDoc('a', function(err, result) { @@ -175,7 +174,7 @@ describe('Lineage', function() { }); it('passes error to callback', function(done) { - query.rejects(new Error('db fail')); + get.rejects(new Error('db fail')); lineage.fetchHydratedDoc('a', function(err) { chai.expect(err.message).to.equal('db fail'); @@ -184,7 +183,7 @@ describe('Lineage', function() { }); it('throws when lineage is empty and throwWhenMissingLineage is true', function() { - query.resolves({ rows: [] }); + get.rejects({ status: 404 }); return lineage.fetchHydratedDoc('a', { throwWhenMissingLineage: true }) .then(() => chai.expect.fail('should have thrown')) @@ -205,7 +204,7 @@ describe('Lineage', function() { it('throws non-404 errors for single doc', function() { const err = new Error('server error'); err.status = 500; - query.rejects(err); + get.rejects(err); return lineage.fetchHydratedDocs(['a']) .then(() => chai.expect.fail('should have thrown')) diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 1f8b2ee87f8..690303ca085 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -276,9 +276,14 @@ describe('server', () => { const reqID = getReqId(apiLogs[0]); const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); - expect(haproxyRequests.length).to.equal(2); + // Request count depends on whether the doc has ancestors: + // _session + DB.get (2) OR _session + DB.get + _all_docs (3) + expect(haproxyRequests.length).to.be.at.least(2); expect(haproxyRequests[0]).to.include('_session'); - expect(haproxyRequests[1]).to.include('_design/medic-client/_view/docs_by_id_lineage'); + const hasDbGetOrPost = haproxyRequests.some(r => { + return r.includes(constants.USER_CONTACT_ID) || r.includes('_all_docs'); + }); + expect(hasDbGetOrPost).to.be.true; }); it('should propagate ID via couch-request', async () => { diff --git a/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts b/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts index 302a7697d40..ed0b5af9176 100644 --- a/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts +++ b/webapp/tests/karma/ts/services/lineage-model-generator.service.spec.ts @@ -10,14 +10,16 @@ describe('LineageModelGenerator service', () => { let service; let dbQuery; let dbAllDocs; + let dbGet; beforeEach(() => { dbQuery = sinon.stub(); dbAllDocs = sinon.stub(); + dbGet = sinon.stub(); TestBed.configureTestingModule({ providers: [ - { provide: DbService, useValue: { get: () => ({ query: dbQuery, allDocs: dbAllDocs }) }}, + { provide: DbService, useValue: { get: () => ({ query: dbQuery, allDocs: dbAllDocs, get: dbGet }) }}, ], }); @@ -31,7 +33,7 @@ describe('LineageModelGenerator service', () => { describe('contact', () => { it('handles not found', done => { - dbQuery.resolves({ rows: [] }); + dbGet.rejects({ status: 404 }); service.contact('a') .then(() => { done(new Error('expected error to be thrown')); @@ -45,10 +47,7 @@ describe('LineageModelGenerator service', () => { it('handles no lineage', () => { const contact = { _id: 'a', _rev: '1' }; - dbQuery.resolves({ - rows: [ - { doc: contact } - ] }); + dbGet.resolves(contact); return service.contact('a').then(model => { expect(model._id).to.equal('a'); expect(model.doc).to.deep.equal(contact); @@ -56,23 +55,19 @@ describe('LineageModelGenerator service', () => { }); it('binds lineage', () => { - const contact = { _id: 'a', _rev: '1' }; - const parent = { _id: 'b', _rev: '1' }; + const contact = { _id: 'a', _rev: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.resolves({ + dbGet.withArgs('a').resolves(contact); + dbAllDocs.withArgs({ keys: ['b', 'c'], include_docs: true }).resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); return service.contact('a').then(model => { - expect(dbQuery.callCount).to.equal(1); - expect(dbQuery.args[0][0]).to.equal('medic-client/docs_by_id_lineage'); - expect(dbQuery.args[0][1]).to.deep.equal({ - startkey: [ 'a' ], - endkey: [ 'a', {} ], - include_docs: true - }); + expect(dbGet.callCount).to.equal(1); + expect(dbAllDocs.callCount).to.equal(1); + expect(dbAllDocs.args[0][0].keys).to.deep.equal(['b', 'c']); expect(model._id).to.equal('a'); expect(model.doc).to.deep.equal(contact); expect(model.lineage).to.deep.equal([ parent, grandparent ]); @@ -80,18 +75,18 @@ describe('LineageModelGenerator service', () => { }); it('binds contacts', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'b', parent: { _id: 'c' } } }; const contactsContact = { _id: 'd', name: 'dave' }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'e' }, parent: { _id: 'c' } }; const parentsContact = { _id: 'e', name: 'eliza' }; const grandparent = { _id: 'c', _rev: '1' }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.withArgs({ keys: ['b', 'c'], include_docs: true }).resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); - dbAllDocs.resolves({ + dbAllDocs.withArgs({ keys: sinon.match.array.deepEquals(['d', 'e']), include_docs: true }).resolves({ rows: [ { doc: contactsContact }, { doc: parentsContact } @@ -104,25 +99,27 @@ describe('LineageModelGenerator service', () => { }); it('hydrates lineage contacts - #3812', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' } }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' }, parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; const parentContact = { _id: 'd', name: 'donny' }; const grandparentContact = { _id: 'e', name: 'erica' }; - dbQuery.resolves({ + const xContact = { _id: 'x', name: 'xavier' }; + dbGet.resolves(contact); + dbAllDocs.withArgs({ keys: ['b', 'c'], include_docs: true }).resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); - dbAllDocs.resolves({ + dbAllDocs.withArgs({ keys: sinon.match.array.deepEquals(['x', 'd', 'e']), include_docs: true }).resolves({ rows: [ + { doc: xContact }, { doc: parentContact }, { doc: grandparentContact } ] }); return service.contact('a').then(model => { - expect(dbAllDocs.callCount).to.equal(1); - expect(dbAllDocs.args[0][0]).to.deep.equal({ + expect(dbAllDocs.callCount).to.equal(2); + expect(dbAllDocs.args[1][0]).to.deep.equal({ keys: [ 'x', 'd', 'e' ], include_docs: true }); @@ -132,18 +129,18 @@ describe('LineageModelGenerator service', () => { }); it('should skip lineage contact hydration if requested', () => { - const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' } }; - const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' } }; + const contact = { _id: 'a', _rev: '1', contact: { _id: 'x' }, parent: { _id: 'b', parent: { _id: 'c' } } }; + const parent = { _id: 'b', _rev: '1', contact: { _id: 'd' }, parent: { _id: 'c' } }; const grandparent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); return service.contact('a', { hydrate: false }).then(model => { - expect(dbAllDocs.callCount).to.equal(0); + expect(dbAllDocs.callCount).to.equal(1); // One for lineage, zero for contacts expect(model.doc.contact).to.deep.equal({ _id: 'x' }); expect(model.lineage[0].contact).to.deep.equal({ _id: 'd' }); expect(model.lineage[1].contact).to.deep.equal({ _id: 'e' }); @@ -152,7 +149,7 @@ describe('LineageModelGenerator service', () => { it('merges lineage when merge passed', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c' } } }; - const parent = { _id: 'b', name: '2' }; + const parent = { _id: 'b', name: '2', parent: { _id: 'c' } }; const grandparent = { _id: 'c', name: '3' }; const expected = { _id: 'a', @@ -183,9 +180,9 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); @@ -197,8 +194,9 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ rows: - [{ doc: contact, key: ['a', 0] }, { doc: parent, key: ['a', 1] }, { key: ['a', 2] }, { key: ['a', 3] }] + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: + [{ doc: parent, id: 'b' }, { id: 'c' }, { id: 'd' }] }); const expected = { _id: 'a', @@ -217,12 +215,12 @@ describe('LineageModelGenerator service', () => { it('should merge lineage with undefined members v2', () => { const contact = { _id: 'a', name: '1', parent: { _id: 'b', parent: { _id: 'c', parent: { _id: 'd' } } } }; const parent = { _id: 'b', name: '2', parent: { _id: 'c', parent: { _id: 'd' } } }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact, key: ['a', 0] }, - { doc: parent, key: ['a', 1] }, - { key: ['a', 2] }, - { key: ['a', 3], doc: { _id: 'd', name: '4' } } + { doc: parent, id: 'b' }, + { id: 'c' }, + { id: 'd', doc: { _id: 'd', name: '4' } } ] }); const expected = { _id: 'a', @@ -267,9 +265,9 @@ describe('LineageModelGenerator service', () => { } ] }; - dbQuery.resolves({ + dbGet.resolves(contact); + dbAllDocs.resolves({ rows: [ - { doc: contact }, { doc: parent }, { doc: grandparent } ] }); @@ -282,7 +280,7 @@ describe('LineageModelGenerator service', () => { describe('report', () => { it('handles not found', done => { - dbQuery.resolves({ rows: [] }); + dbGet.rejects({ status: 404 }); service.report('a') .then(() => { done(new Error('expected error to be thrown')); @@ -296,10 +294,7 @@ describe('LineageModelGenerator service', () => { it('handles no lineage', () => { const report = { _id: 'a', _rev: '1' }; - dbQuery.resolves({ - rows: [ - { doc: report } - ] }); + dbGet.resolves(report); return service.report('a').then(model => { expect(model._id).to.equal('a'); expect(model.doc).to.deep.equal(report); @@ -311,9 +306,9 @@ describe('LineageModelGenerator service', () => { const contact = { _id: 'b', _rev: '1' }; const parent = { _id: 'c', _rev: '1' }; const grandparent = { _id: 'd', _rev: '1' }; - dbQuery.resolves({ + dbGet.withArgs('a').resolves(report); + dbAllDocs.resolves({ rows: [ - { doc: report }, { doc: contact }, { doc: parent }, { doc: grandparent } @@ -326,28 +321,33 @@ describe('LineageModelGenerator service', () => { }); it('hydrates lineage contacts - #3812', () => { - const report = { _id: 'a', _rev: '1', type: DOC_TYPES.DATA_RECORD, form: 'a', contact: { _id: 'x' } }; - const contact = { _id: 'b', _rev: '1', contact: { _id: 'y' } }; - const parent = { _id: 'c', _rev: '1', contact: { _id: 'e' } }; + const reportContact = { _id: 'x', parent: { _id: 'c', parent: { _id: 'd' } } }; + const report = { _id: 'a', _rev: '1', type: DOC_TYPES.DATA_RECORD, form: 'a', contact: reportContact }; + const contact = { _id: 'x', _rev: '1', contact: { _id: 'y' }, parent: { _id: 'c' } }; + const parent = { _id: 'c', _rev: '1', contact: { _id: 'e' }, parent: { _id: 'd' } }; const grandparent = { _id: 'd', _rev: '1', contact: { _id: 'f' } }; const parentContact = { _id: 'e', name: 'erica' }; const grandparentContact = { _id: 'f', name: 'frank' }; - dbQuery.resolves({ + const xContact = { _id: 'x', name: 'xavier' }; + const yContact = { _id: 'y', name: 'yvonne' }; + dbGet.resolves(report); + dbAllDocs.withArgs(sinon.match({ keys: ['x', 'c', 'd'], include_docs: true })).resolves({ rows: [ - { doc: report }, { doc: contact }, { doc: parent }, { doc: grandparent } ] }); - dbAllDocs.resolves({ + dbAllDocs.withArgs(sinon.match({ keys: ['y', 'e', 'f'], include_docs: true })).resolves({ rows: [ + { doc: xContact }, + { doc: yContact }, { doc: parentContact }, { doc: grandparentContact } ] }); return service.report('a').then(model => { - expect(dbAllDocs.callCount).to.equal(1); - expect(dbAllDocs.args[0][0]).to.deep.equal({ - keys: [ 'x', 'y', 'e', 'f' ], + expect(dbAllDocs.callCount).to.equal(2); + expect(dbAllDocs.args[1][0]).to.deep.equal({ + keys: [ 'y', 'e', 'f' ], include_docs: true }); expect(model.doc.contact.parent.contact).to.deep.equal(parentContact); diff --git a/webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js b/webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js deleted file mode 100644 index cf88040d14e..00000000000 --- a/webapp/tests/mocha/unit/views/docs_by_id_lineage.spec.js +++ /dev/null @@ -1,198 +0,0 @@ -const expect = require('chai').expect; -const utils = require('./utils'); -const map = utils.loadView('medic-db', 'medic-client', 'docs_by_id_lineage'); -const { DOC_TYPES, CONTACT_TYPES } = require('@medic/constants'); - -describe('docs_by_id_lineage view', () => { - beforeEach(() => { - map.reset(); - }); - describe('data_record lineage', () => { - it('does not emit if doc is not a report', () => { - const doc = { - _id: 'messsage', - type: DOC_TYPES.DATA_RECORD, - sms_message: { } - }; - - const result = map(doc, true); - expect(result.length).to.equal(0); - }); - it('emits report document for depth 0', () => { - const doc = { - _id: 'report', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - }; - - const result = map(doc, true); - expect(result.length).to.equal(1); - expect(result[0]).to.deep.equal({ key: [ 'report', 0 ], value: undefined }); - }); - - it('emits contact lineage for depth 1+', () => { - const doc = { - _id: 'report', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: { - _id: 'contact1', - parent: { - _id: 'contact2', - parent: { - _id: 'contact3' - } - } - } - }; - const result = map(doc, true); - expect(result.length).to.equal(4); - expect(result[0]).to.deep.equal({ key: [ 'report', 0 ], value: undefined }); - expect(result[1]).to.deep.equal({ key: [ 'report', 1 ], value: { _id: 'contact1' }}); - expect(result[2]).to.deep.equal({ key: [ 'report', 2 ], value: { _id: 'contact2' }}); - expect(result[3]).to.deep.equal({ key: [ 'report', 3 ], value: { _id: 'contact3' }}); - }); - - it('does not emit lineage for empty contact parents', () => { - const doc1 = { - _id: 'report1', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: {} - }; - const result1 = map(doc1, true); - expect(result1.length).to.equal(1); - expect(result1[0]).to.deep.equal({ key: [ 'report1', 0 ], value: undefined }); - - const doc2 = { - _id: 'report2', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: { - _id: 'contact1', - parent: {} - } - }; - - map.reset(); - const result2 = map(doc2, true); - expect(result2.length).to.equal(2); - expect(result2[0]).to.deep.equal({ key: [ 'report2', 0 ], value: undefined }); - expect(result2[1]).to.deep.equal({ key: [ 'report2', 1 ], value: { _id: 'contact1' }}); - - const doc3 = { - _id: 'report3', - type: DOC_TYPES.DATA_RECORD, - form: 'form', - contact: { - _id: 'contact1', - parent: { - _id: 'contact2', - parent: {} - } - } - }; - map.reset(); - const result3 = map(doc3, true); - expect(result3.length).to.equal(3); - expect(result3[0]).to.deep.equal({ key: [ 'report3', 0 ], value: undefined }); - expect(result3[1]).to.deep.equal({ key: [ 'report3', 1 ], value: { _id: 'contact1' }}); - expect(result3[2]).to.deep.equal({ key: [ 'report3', 2 ], value: { _id: 'contact2' }}); - }); - }); - - describe('contacts lineage', () => { - it('emits lineage for type `person`, `clinic`, `health_center` and `district_hospital`', () => { - const person = { _id: 'person', type: 'person' }; - const result = map(person, true); - expect(result.length).to.equal(1); - expect(result[0]).to.deep.equal({ key: [ 'person', 0 ], value: { _id: 'person' }}); - - map.reset(); - const clinic = { _id: 'clinic', type: CONTACT_TYPES.CLINIC }; - const resultClinic = map(clinic, true); - expect(resultClinic.length).to.equal(1); - expect(resultClinic[0]).to.deep.equal({ key: [ CONTACT_TYPES.CLINIC, 0 ], value: { _id: 'clinic' }}); - - map.reset(); - const healthCenter = { _id: 'healthCenter', type: 'health_center' }; - const resultHealthCenter = map(healthCenter, true); - expect(resultHealthCenter.length).to.equal(1); - expect(resultHealthCenter[0]).to.deep.equal({ key: [ 'healthCenter', 0 ], value: { _id: 'healthCenter' }}); - - map.reset(); - const districtHospital = { _id: 'districtHospital', type: CONTACT_TYPES.DISTRICT_HOSPITAL }; - const resultdistrictHospital = map(districtHospital, true); - expect(resultdistrictHospital.length).to.equal(1); - expect(resultdistrictHospital[0]) - .to.deep.equal({ key: [ 'districtHospital', 0 ], value: { _id: 'districtHospital' }}); - }); - - it('emits full lineage', () => { - const checkLineage = (result, key) => { - if (key > 0) { - expect(result).to.deep.equal({ key: [ 'person', key ], value: { _id: `parent${key}` }}); - } else { - expect(result).to.deep.equal({ key: [ 'person', 0 ], value: { _id: 'person' }}); - } - }; - for (let depth = 1; depth < 10; depth++) { - const doc = { _id: 'person', type: 'person', parent: {} }; - let currentParent = doc.parent; - for (let i = 1; i <= depth; i++) { - currentParent._id = `parent${i}`; - currentParent.parent = {}; - currentParent = currentParent.parent; - } - - map.reset(); - const results = map(doc, true); - expect(results.length).to.equal(depth + 1); - results.forEach(checkLineage); - } - }); - - it('does not emit lineage for empty parents', () => { - const doc1 = { - _id: 'contact1', - type: 'person', - parent: {} - }; - const result1 = map(doc1, true); - expect(result1.length).to.equal(1); - expect(result1[0]).to.deep.equal({ key: [ 'contact1', 0 ], value: { _id: 'contact1'} }); - - const doc2 = { - _id: 'contact2', - type: 'person', - parent: { - _id: 'contact3', - parent: {} - } - }; - map.reset(); - const result2 = map(doc2, true); - expect(result2.length).to.equal(2); - expect(result2[0]).to.deep.equal({ key: [ 'contact2', 0 ], value: { _id: 'contact2' }}); - expect(result2[1]).to.deep.equal({ key: [ 'contact2', 1 ], value: { _id: 'contact3' }}); - - const doc3 = { - _id: 'contact3', - type: 'person', - parent: { - _id: 'contact4', - parent: { - _id: 'contact5', - parent: {} - } - } - }; - map.reset(); - const result3 = map(doc3, true); - expect(result3.length).to.equal(3); - expect(result3[0]).to.deep.equal({ key: [ 'contact3', 0 ], value: { _id: 'contact3' }}); - expect(result3[1]).to.deep.equal({ key: [ 'contact3', 1 ], value: { _id: 'contact4' }}); - expect(result3[2]).to.deep.equal({ key: [ 'contact3', 2 ], value: { _id: 'contact5' }}); - }); - }); -});