diff --git a/packages/report-server/package.json b/packages/report-server/package.json index 38ad7d11d5..29fe93b026 100644 --- a/packages/report-server/package.json +++ b/packages/report-server/package.json @@ -39,6 +39,7 @@ "api-error-handler": "^1.0.0", "body-parser": "^1.18.3", "cors": "^2.8.5", + "date-fns": "^2.29.3", "dotenv": "^8.2.0", "express": "^4.16.2", "lodash.isplainobject": "^4.0.6", @@ -51,6 +52,7 @@ "devDependencies": { "@types/lodash.isplainobject": "^4.0.6", "@types/lodash.keyby": "^4.6.6", - "@types/lodash.pick": "^4.4.0" + "@types/lodash.pick": "^4.4.0", + "mockdate": "^3.0.5" } } diff --git a/packages/report-server/src/__tests__/reportBuilder/customReport/testCustomReport.test.ts b/packages/report-server/src/__tests__/reportBuilder/customReport/testCustomReport.test.ts index 5af48160b9..8a0d42725c 100644 --- a/packages/report-server/src/__tests__/reportBuilder/customReport/testCustomReport.test.ts +++ b/packages/report-server/src/__tests__/reportBuilder/customReport/testCustomReport.test.ts @@ -10,7 +10,7 @@ import { testCustomReport } from '../../../reportBuilder/customReports/testCusto import { entityApiMock } from '../testUtils'; -describe('buildContext', () => { +describe('testCustomReport', () => { const HIERARCHY = 'test_hierarchy'; const ENTITIES = { test_hierarchy: [ diff --git a/packages/report-server/src/__tests__/reportBuilder/customReport/tongaCovidRawData.fixtures.ts b/packages/report-server/src/__tests__/reportBuilder/customReport/tongaCovidRawData.fixtures.ts new file mode 100644 index 0000000000..b909ec74e3 --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/customReport/tongaCovidRawData.fixtures.ts @@ -0,0 +1,188 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +export const HIERARCHY = 'test_hierarchy'; +export const ENTITIES = { + test_hierarchy: [ + { code: 'TO', name: 'Tonga', type: 'country' }, + { + code: 'TO_Island_1', + name: 'Tonga Island 1', + type: 'district', + }, + { + code: 'TO_Island_2', + name: 'Tonga Island 2', + type: 'district', + }, + { + code: 'TO_Village_1', + name: 'Tonga Village 1', + type: 'village', + }, + { + code: 'TO_Village_2', + name: 'Tonga Village 2', + type: 'village', + }, + { + code: 'TO_Village_3', + name: 'Tonga Village 3', + type: 'village', + }, + { + code: 'TO_Individual_1', + name: 'Tonga Individual 1', + type: 'individual', + }, + { + code: 'TO_Individual_2', + name: 'Tonga Individual 2', + type: 'individual', + }, + { + code: 'TO_Individual_3', + name: 'Tonga Individual 3', + type: 'individual', + }, + { + code: 'TO_Individual_4', + name: 'Tonga Individual 4', + type: 'individual', + }, + { + code: 'TO_Individual_5', + name: 'Tonga Individual 5', + type: 'individual', + }, + ], +}; + +export const RELATIONS = { + test_hierarchy: [ + { parent: 'TO', child: 'TO_Island_1' }, + { parent: 'TO', child: 'TO_Island_2' }, + { parent: 'TO_Island_1', child: 'TO_Village_1' }, + { parent: 'TO_Island_1', child: 'TO_Village_2' }, + { parent: 'TO_Island_2', child: 'TO_Village_3' }, + { parent: 'TO_Village_1', child: 'TO_Individual_1' }, + { parent: 'TO_Village_1', child: 'TO_Individual_2' }, + { parent: 'TO_Village_2', child: 'TO_Individual_3' }, + { parent: 'TO_Village_3', child: 'TO_Individual_4' }, + { parent: 'TO_Village_3', child: 'TO_Individual_5' }, + ], +}; + +const C19TRegistrationEvents = [ + { + orgUnit: 'TO_Individual_1', + eventDate: '1920-01-01', + dataValues: { + C19T002: 'Sausages', + C19T003: 'Silly', + C19T004: '2020-01-01', + C19T005: 'Male', + C19T006: '9182487324', + }, + }, + { + orgUnit: 'TO_Individual_2', + eventDate: '1921-11-12', + dataValues: { + C19T002: 'Whiskers', + C19T003: 'Paddington', + C19T004: '1970-01-01', + C19T005: 'Female', + C19T006: '9012837273', + }, + }, + { + orgUnit: 'TO_Individual_4', + eventDate: '1924-12-01', + dataValues: { + C19T002: 'Junior', + C19T003: 'Super', + C19T004: '1990-09-13', + C19T005: 'Other', + C19T006: '08090928', + }, + }, +]; + +const C19TResultsEvents = [ + { + orgUnit: 'TO_Individual_2', + eventDate: '2020-01-01', + dataValues: { + C19T012: 'Flu', + C19T013: 'Basic', + C19T013_a: 7, + C19T015: 1, + C19T015_a: 0, + C19T016: 1, + C19T042: '19/09/2022', + C19T017: 1, + C19T018: 1, + C19T019: 0, + C19T022: 'No', + C19T020: 1, + C19T038: 0, + C19T039: 0, + C19T044: '13/09/2022', + C19T024: 'Disney Land', + C19T025: 'Car park', + C19T026: 'Good', + C19T027: 'Roomy', + C19T028: 'Soccer pitch', + C19T041: 'What?', + C19T029: 'Nope', + C19T033: 13, + C19T034: 7, + C19T035: 1, + C19T036: 0, + C19T037: 'N/A', + C19T043: 'Fully vaccinated', + }, + }, + { + orgUnit: 'TO_Individual_4', + eventDate: '2020-05-19', + dataValues: { + C19T012: 'Covid', + C19T013: 'Antigen', + C19T013_a: 4, + C19T015: 0, + C19T015_a: 1, + C19T016: 0, + C19T017: 0, + C19T018: 0, + C19T019: 0, + C19T021: 'None', + C19T022: 'No', + C19T020: 0, + C19T038: 0, + C19T039: 0, + C19T044: 'N/A', + C19T024: 'Space', + C19T025: 'Salvos', + C19T026: 'Not bad', + C19T027: 'Circular', + C19T028: 'Van', + C19T041: 'Is RRT?', + C19T029: 'None', + C19T033: 2, + C19T034: 4, + C19T035: 0, + C19T036: 0, + C19T037: 'N/A', + C19T043: 'Partially vaccinated', + }, + }, +]; + +export const EVENTS: Record }[]> = { + C19T_Registration: C19TRegistrationEvents, + C19T_Results: C19TResultsEvents, +}; diff --git a/packages/report-server/src/__tests__/reportBuilder/customReport/tongaCovidRawData.test.ts b/packages/report-server/src/__tests__/reportBuilder/customReport/tongaCovidRawData.test.ts new file mode 100644 index 0000000000..a45f26778e --- /dev/null +++ b/packages/report-server/src/__tests__/reportBuilder/customReport/tongaCovidRawData.test.ts @@ -0,0 +1,201 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ + +import MockDate from 'mockdate'; +import { AccessPolicy } from '@tupaia/access-policy'; +import { ReqContext } from '../../../reportBuilder/context'; + +import { tongaCovidRawData } from '../../../reportBuilder/customReports/tongaCovidRawData'; + +import { entityApiMock } from '../testUtils'; +import { ENTITIES, EVENTS, HIERARCHY, RELATIONS } from './tongaCovidRawData.fixtures'; + +const CURRENT_DATE_STUB = '2020-12-15T00:00:00Z'; + +const fetchFakeEvents = ( + surveyCode: string, + { + organisationUnitCodes, + dataElementCodes, + }: { organisationUnitCodes: string[]; dataElementCodes: string[] }, +) => { + return EVENTS[surveyCode] + .filter(event => organisationUnitCodes.includes(event.orgUnit)) + .map(event => ({ + ...event, + dataValues: Object.fromEntries( + Object.entries(event.dataValues).filter(([dataElementCode]) => + dataElementCodes.includes(dataElementCode), + ), + ), + })); +}; + +jest.mock('@tupaia/aggregator', () => ({ + createAggregator: jest.fn().mockImplementation(() => ({ + fetchEvents: fetchFakeEvents, + })), +})); + +describe('tongaCovidRawData', () => { + const apiMock = entityApiMock(ENTITIES, RELATIONS); + + const reqContext: ReqContext = { + hierarchy: HIERARCHY, + permissionGroup: 'Public', + services: { + entity: apiMock, + } as ReqContext['services'], + accessPolicy: new AccessPolicy({ AU: ['Public'] }), + }; + + beforeAll(() => { + MockDate.set(CURRENT_DATE_STUB); + }); + + afterAll(() => { + MockDate.reset(); + }); + + it('builds the tongaCovidRawData report', async () => { + const expectedValue = { + columns: [ + { key: 'Test ID', title: 'Test ID' }, + { key: 'Surname', title: 'Surname' }, + { key: 'Given Names', title: 'Given Names' }, + { key: 'Date of Birth', title: 'Date of Birth' }, + { key: 'Age', title: 'Age' }, + { key: 'Sex', title: 'Sex' }, + { key: 'Phone No.', title: 'Phone No.' }, + { key: 'Island Group', title: 'Island Group' }, + { key: 'Village Code', title: 'Village Code' }, + { key: 'Address', title: 'Address' }, + { key: 'Date of Test', title: 'Date of Test' }, + { key: 'Result', title: 'Result' }, + { key: 'New Case', title: 'New Case' }, + { key: 'Estimated Recovery Date', title: 'Estimated Recovery Date' }, + { key: 'Test Type', title: 'Test Type' }, + { key: 'CT Value', title: 'CT Value' }, + { key: 'Vaccination Status', title: 'Vaccination Status' }, + { key: 'Outbound Traveller', title: 'Outbound Traveller' }, + { key: 'inbound Traveller', title: 'inbound Traveller' }, + { key: 'Symptomatic', title: 'Symptomatic' }, + { key: 'Date of Symptomatic Onset', title: 'Date of Symptomatic Onset' }, + { key: 'Quarantine', title: 'Quarantine' }, + { key: 'Primary Contact', title: 'Primary Contact' }, + { key: 'Frontliner', title: 'Frontliner' }, + { key: 'Frontliner Type', title: 'Frontliner Type' }, + { key: 'Other', title: 'Other' }, + { key: 'Community Testing', title: 'Community Testing' }, + { key: 'Patient', title: 'Patient' }, + { key: 'Other Reason', title: 'Other Reason' }, + { key: 'Primary Contact Testing Day', title: 'Primary Contact Testing Day' }, + { key: 'Testing Site', title: 'Testing Site' }, + { key: 'Quarantine Facility', title: 'Quarantine Facility' }, + { key: 'Ward Type', title: 'Ward Type' }, + { key: 'Clinic Type', title: 'Clinic Type' }, + { key: 'Health Center', title: 'Health Center' }, + { key: 'RRT Team Name', title: 'RRT Team Name' }, + ], + rows: [ + { + Address: 'Tonga Village 1', + Age: 50, + 'CT Value': 7, + 'Clinic Type': 'Roomy', + 'Community Testing': 'Yes', + 'Date Previous Positive': 'N/A', + 'Date of Birth': '1970-01-01', + 'Date of Symptomatic Onset': '19/09/2022', + 'Date of Test': '2020-01-01', + 'Estimated Recovery Date': 'Not applicable', + Frontliner: 'No', + 'Given Names': 'Paddington', + 'Health Center': 'Soccer pitch', + 'Inbound Traveller': 'No', + 'Island Group': 'Tonga Island 1', + 'New Case': 'Yes', + Other: 'No', + 'Other Reason': 'No', + 'Other Results': 7, + 'Other Site': 'Nope', + 'Other Type': 'Basic', + 'Outbound Traveller': 'Yes', + Patient: 'No', + 'Phone No.': '9012837273', + 'Previous Positive': 'No', + 'Primary Contact': 'Yes', + 'Primary Contact Testing Day': '13/09/2022', + Quarantine: 'Yes', + 'Quarantine Facility': 'Car park', + 'RRT Team Name': 'What?', + Result: 13, + Sex: 'Female', + Surname: 'Whiskers', + Symptomatic: 'Yes', + 'Test ID': 'TO_Individual_2', + 'Test Type': 'Flu', + 'Testing Site': 'Disney Land', + 'Vaccination Status': 'Fully vaccinated', + 'Village Code': 'TO_Village_1', + 'Ward Type': 'Good', + eventDate: '2020-01-01', + orgUnit: 'TO_Individual_2', + }, + { + Address: 'Tonga Village 3', + Age: 29, + 'CT Value': 4, + 'Clinic Type': 'Circular', + 'Community Testing': 'No', + 'Date Previous Positive': 'N/A', + 'Date of Birth': '1990-09-13', + 'Date of Test': '2020-05-19', + 'Estimated Recovery Date': 'Not applicable', + Frontliner: 'No', + 'Frontliner Type': 'None', + 'Given Names': 'Super', + 'Health Center': 'Van', + 'Inbound Traveller': 'Yes', + 'Island Group': 'Tonga Island 2', + 'New Case': 'No', + Other: 'No', + 'Other Reason': 'No', + 'Other Results': 4, + 'Other Site': 'None', + 'Other Type': 'Antigen', + 'Outbound Traveller': 'No', + Patient: 'No', + 'Phone No.': '08090928', + 'Previous Positive': 'No', + 'Primary Contact': 'No', + 'Primary Contact Testing Day': 'N/A', + Quarantine: 'No', + 'Quarantine Facility': 'Salvos', + 'RRT Team Name': 'Is RRT?', + Result: 2, + Sex: 'Other', + Surname: 'Junior', + Symptomatic: 'No', + 'Test ID': 'TO_Individual_4', + 'Test Type': 'Covid', + 'Testing Site': 'Space', + 'Vaccination Status': 'Partially vaccinated', + 'Village Code': 'TO_Village_3', + 'Ward Type': 'Not bad', + eventDate: '2020-05-19', + orgUnit: 'TO_Individual_4', + }, + ], + }; + + const numberOfFacilitiesInTonga = await tongaCovidRawData(reqContext, { + organisationUnitCodes: ['TO'], + hierarchy: 'test_hierarchy', + }); + + expect(numberOfFacilitiesInTonga).toEqual(expectedValue); + }); +}); diff --git a/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts b/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts index ccb6ceb6f2..6a87d93744 100644 --- a/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts +++ b/packages/report-server/src/__tests__/reportBuilder/testUtils/entityApiMock.ts @@ -10,16 +10,43 @@ export const entityApiMock = ( entities: Record[]>, relations: Record = {}, ) => { + const getEntities = ( + hierarchyName: string, + entityCodes: string[], + queryOptions: { field?: string; fields?: string[]; filter?: Record } = {}, + ) => { + const entitiesInHierarchy = entities[hierarchyName] || []; + const foundEntities = entitiesInHierarchy.filter(e => entityCodes.includes(e.code)); + const { field, fields, filter } = queryOptions; + + let filteredEntities = foundEntities; + if (filter) { + filteredEntities = filteredEntities.filter(e => + Object.entries(filter).every(([key, value]) => e[key] === value), + ); + } + + if (field) { + return filteredEntities.map(e => e[field]); + } + + if (fields) { + return filteredEntities.map(e => pick(e, fields)); + } + + return filteredEntities; + }; + const getDescendantsOfEntities = ( hierarchyName: string, entityCodes: string[], - queryOptions: { fields?: string[]; filter?: { type: string } } = {}, + queryOptions: { fields?: string[]; filter?: { type?: string } } = {}, ) => { const entitiesInHierarchy = entities[hierarchyName] || []; const relationsInHierarchy = relations[hierarchyName] || []; const ancestorEntities = entitiesInHierarchy.filter(e => entityCodes.includes(e.code)); const ancestorEntityQueue = [...ancestorEntities]; - const descendantEntities = []; + const descendantEntityCodes = []; while (ancestorEntityQueue.length > 0) { const parent = ancestorEntityQueue.shift(); if (parent === undefined) { @@ -31,17 +58,52 @@ export const entityApiMock = ( .map(({ child }) => entitiesInHierarchy.find(entity => entity.code === child)) .filter(isDefined); - descendantEntities.push(...children); + descendantEntityCodes.push(...children.map(e => e.code)); ancestorEntityQueue.push(...children); } - const { fields, filter } = queryOptions; + return getEntities(hierarchyName, descendantEntityCodes, queryOptions); + }; - const filteredDescendants = filter - ? descendantEntities.filter(entity => entity.type === filter.type) - : descendantEntities; + const getAncestorsOfEntities = ( + hierarchyName: string, + entityCodes: string[], + queryOptions: { fields?: string[]; filter?: { type?: string } } = {}, + ) => { + const entitiesInHierarchy = entities[hierarchyName] || []; + const relationsInHierarchy = relations[hierarchyName] || []; + const descendantEntities = entitiesInHierarchy.filter(e => entityCodes.includes(e.code)); + const descendantEntityQueue = [...descendantEntities]; + const ancestorCodes = []; + while (descendantEntityQueue.length > 0) { + const child = descendantEntityQueue.shift(); + if (child === undefined) { + continue; + } + + const isDefined = (val: T): val is Exclude => val !== undefined; + const parents = relationsInHierarchy + .filter(({ child: childCode }) => child.code === childCode) + .map(({ parent }) => entitiesInHierarchy.find(entity => entity.code === parent)) + .filter(isDefined); - return fields ? filteredDescendants.map(e => pick(e, fields)) : filteredDescendants; + ancestorCodes.push(...parents.map(e => e.code)); + descendantEntityQueue.push(...parents); + } + + return getEntities(hierarchyName, ancestorCodes, queryOptions); + }; + + const getRelationsOfEntities = ( + hierarchyName: string, + entityCodes: string[], + queryOptions: { fields?: string[]; filter?: { type?: string } } = {}, + ) => { + return [ + ...getAncestorsOfEntities(hierarchyName, entityCodes, queryOptions), + ...getEntities(hierarchyName, entityCodes, queryOptions), + ...getDescendantsOfEntities(hierarchyName, entityCodes, queryOptions), + ]; }; return { @@ -49,11 +111,7 @@ export const entityApiMock = ( hierarchyName: string, entityCodes: string[], queryOptions: { fields?: string[] } = {}, - ) => { - const foundEntities = entities[hierarchyName]?.filter(e => entityCodes.includes(e.code)); - const { fields } = queryOptions; - return fields ? foundEntities.map(e => pick(e, fields)) : foundEntities; - }, + ) => getEntities(hierarchyName, entityCodes, queryOptions), getDescendantsOfEntities: async ( hierarchyName: string, entityCodes: string[], @@ -62,21 +120,57 @@ export const entityApiMock = ( getRelationshipsOfEntities: async ( hierarchyName: string, entityCodes: string[], - groupBy: 'ancestor', - queryOptions: { fields?: string[]; filter?: { type: string } } = {}, - ancestorQueryOptions: { filter?: { type: string } } = {}, - descendantQueryOptions: { filter?: { type: string } } = {}, + groupBy: 'ancestor' | 'descendant', + queryOptions: { field?: string } = {}, + ancestorQueryOptions: { field?: string; filter?: { type: string } } = {}, + descendantQueryOptions: { field?: string; filter?: { type: string } } = {}, ) => { - const entitiesInHierarchy = entities[hierarchyName] || []; - const ancestorEntities = ancestorQueryOptions.filter?.type - ? getDescendantsOfEntities(hierarchyName, entityCodes, ancestorQueryOptions) - : entitiesInHierarchy.filter(e => entityCodes.includes(e.code)); - - const ancestorEntityCodes = ancestorEntities.map(e => e.code); - return ancestorEntityCodes.reduce((obj: Record, ancestor) => { - obj[ancestor] = getDescendantsOfEntities(hierarchyName, [ancestor], descendantQueryOptions); - return obj; - }, {}); + const { field: queryField, ...restOfQueryOptions } = queryOptions; + const { + field: ancestorField = queryField || 'code', + ...restOfAncestorQueryOptions + } = ancestorQueryOptions; + const ancestorOptions = { + ...restOfQueryOptions, + ...restOfAncestorQueryOptions, + fields: [ancestorField, 'code'], + }; + const descendantOptions = { + field: 'code', + ...queryOptions, + ...descendantQueryOptions, + }; + const ancestorEntities = ancestorOptions.filter?.type + ? getRelationsOfEntities(hierarchyName, entityCodes, ancestorOptions) + : getEntities(hierarchyName, entityCodes, ancestorOptions); + const descendantsGroupedByAncestor: Record = ancestorEntities.reduce( + (obj, ancestor) => { + return { + ...obj, + [ancestor[ancestorField]]: getDescendantsOfEntities( + hierarchyName, + [ancestor.code], + descendantOptions, + ), + }; + }, + {} as Record, + ); + + if (groupBy === 'ancestor') { + return descendantsGroupedByAncestor; + } + const ancestorsGroupedByDescendant = Object.entries(descendantsGroupedByAncestor).reduce( + (obj, [ancestor, descendants]) => { + descendants.forEach(descendant => { + // eslint-disable-next-line no-param-reassign + obj[descendant] = ancestor; + }); + return obj; + }, + {} as Record, + ); + return ancestorsGroupedByDescendant; }, }; }; diff --git a/packages/report-server/src/reportBuilder/customReports/data/tongaCovidRawData.json b/packages/report-server/src/reportBuilder/customReports/data/tongaCovidRawData.json new file mode 100644 index 0000000000..bbd2dd4c0d --- /dev/null +++ b/packages/report-server/src/reportBuilder/customReports/data/tongaCovidRawData.json @@ -0,0 +1,126 @@ +{ + "C19T_Registration": { + "dataElementCodes": ["C19T002", "C19T003", "C19T004", "C19T005", "C19T006"], + "binaryAndCheckboxQuestions": [] + }, + "C19T_Results": { + "dataElementCodes": [ + "C19T012", + "C19T013", + "C19T013_a", + "C19T015", + "C19T015_a", + "C19T016", + "C19T042", + "C19T017", + "C19T018", + "C19T019", + "C19T021", + "C19T022", + "C19T020", + "C19T038", + "C19T039", + "C19T044", + "C19T024", + "C19T025", + "C19T026", + "C19T027", + "C19T028", + "C19T041", + "C19T029", + "C19T033", + "C19T034", + "C19T035", + "C19T036", + "C19T037", + "C19T043" + ] + }, + "codesToNames": { + "C19T002": "Surname", + "C19T003": "Given Names", + "C19T004": "dob", + "C19T005": "Sex", + "C19T006": "Phone No.", + "C19T012": "Test Type", + "C19T013": "Other Type", + "C19T013_a": "CT Value", + "C19T015": "Outbound Traveller", + "C19T015_a": "Inbound Traveller", + "C19T016": "Symptomatic", + "C19T042": "Date of Symptomatic Onset", + "C19T017": "Quarantine", + "C19T018": "Primary Contact", + "C19T019": "Frontliner", + "C19T021": "Frontliner Type", + "C19T022": "Other", + "C19T020": "Community Testing", + "C19T038": "Patient", + "C19T039": "Other Reason", + "C19T044": "Primary Contact Testing Day", + "C19T024": "Testing Site", + "C19T025": "Quarantine Facility", + "C19T026": "Ward Type", + "C19T027": "Clinic Type", + "C19T028": "Health Center", + "C19T041": "RRT Team Name", + "C19T029": "Other Site", + "C19T033": "Result", + "C19T034": "Other Results", + "C19T035": "New Case", + "C19T036": "Previous Positive", + "C19T037": "Date Previous Positive", + "C19T043": "Vaccination Status" + }, + "columns": [ + "Test ID", + "Surname", + "Given Names", + "Date of Birth", + "Age", + "Sex", + "Phone No.", + "Island Group", + "Village Code", + "Address", + "Date of Test", + "Result", + "New Case", + "Estimated Recovery Date", + "Test Type", + "CT Value", + "Vaccination Status", + "Outbound Traveller", + "inbound Traveller", + "Symptomatic", + "Date of Symptomatic Onset", + "Quarantine", + "Primary Contact", + "Frontliner", + "Frontliner Type", + "Other", + "Community Testing", + "Patient", + "Other Reason", + "Primary Contact Testing Day", + "Testing Site", + "Quarantine Facility", + "Ward Type", + "Clinic Type", + "Health Center", + "RRT Team Name" + ], + "binaryAndCheckboxQuestions": [ + "C19T016", + "C19T017", + "C19T018", + "C19T019", + "C19T020", + "C19T038", + "C19T015", + "C19T015_a", + "C19T039", + "C19T035", + "C19T036" + ] +} diff --git a/packages/report-server/src/reportBuilder/customReports/index.ts b/packages/report-server/src/reportBuilder/customReports/index.ts index cba577a298..87eab70f87 100644 --- a/packages/report-server/src/reportBuilder/customReports/index.ts +++ b/packages/report-server/src/reportBuilder/customReports/index.ts @@ -7,10 +7,14 @@ import { Resolved } from '@tupaia/tsutils'; import { FetchReportQuery } from '../../types'; import { ReqContext } from '../context'; import { testCustomReport } from './testCustomReport'; +import { tongaCovidRawData } from './tongaCovidRawData'; type CustomReportBuilder = (reqContext: ReqContext, query: FetchReportQuery) => Promise; -export const customReports: Record = { testCustomReport }; +export const customReports: Record = { + testCustomReport, + tongaCovidRawData, +}; export type CustomReportOutputType = Resolved< ReturnType diff --git a/packages/report-server/src/reportBuilder/customReports/tongaCovidRawData.ts b/packages/report-server/src/reportBuilder/customReports/tongaCovidRawData.ts new file mode 100644 index 0000000000..a18a32a473 --- /dev/null +++ b/packages/report-server/src/reportBuilder/customReports/tongaCovidRawData.ts @@ -0,0 +1,287 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + */ +import { format, differenceInYears, addDays, isDate } from 'date-fns'; +import keyBy from 'lodash.keyby'; +import { createAggregator } from '@tupaia/aggregator'; +import { ReportServerAggregator } from '../../aggregator'; +import { FetchReportQuery, Event } from '../../types'; +import { ReqContext } from '../context'; +import SURVEYS from './data/tongaCovidRawData.json'; + +interface RelationshipsOptions { + hierarchy: string; + entityCodes: string[]; + queryOptions?: any; + ancestorOptions?: any; + descendantOptions?: any; +} + +const getRelationships = (reqContext: ReqContext, options: RelationshipsOptions) => { + const { hierarchy, entityCodes, queryOptions, ancestorOptions, descendantOptions } = options; + return reqContext.services.entity.getRelationshipsOfEntities( + hierarchy, + entityCodes, + 'descendant', + queryOptions, + ancestorOptions, + descendantOptions, + ) as Promise>; +}; + +const fetchEntities = async (reqContext: ReqContext, hierarchy: string, entityCodes: string[]) => { + const villageOptions: RelationshipsOptions = { + hierarchy, + entityCodes, + queryOptions: {}, + ancestorOptions: { filter: { type: 'village' } }, + descendantOptions: { filter: { type: 'individual' } }, + }; + + const islandOptions: RelationshipsOptions = { + hierarchy, + entityCodes, + queryOptions: {}, + ancestorOptions: { field: 'name', filter: { type: 'district' } }, + descendantOptions: { filter: { type: 'individual' } }, + }; + const [villageCodeByIndividualCodes, islandNameByIndividualCodes] = await Promise.all( + [villageOptions, islandOptions].map(options => getRelationships(reqContext, options)), + ); + + const individualCodes = Object.keys(villageCodeByIndividualCodes); + const villageCodes = new Set(); + Object.values(villageCodeByIndividualCodes).forEach((code: string) => { + villageCodes.add(code); + }); + const includedVillageCodes = [...villageCodes]; + const villageCodesAndNames = (await reqContext.services.entity.getEntities( + hierarchy, + includedVillageCodes, + { + fields: ['code', 'name'], + }, + )) as { code: string; name: string }[]; + const individualCodeByVillageNameAndCode: Record = {}; + Object.keys(villageCodeByIndividualCodes).forEach(individualCode => { + const villageCode = villageCodeByIndividualCodes[individualCode]; + const villageWithCode = villageCodesAndNames.find(village => village.code === villageCode); + + if (!villageWithCode) { + throw new Error(`Could not find village with code: ${villageCode}`); + } + + const { name } = villageWithCode; + individualCodeByVillageNameAndCode[individualCode] = { + code: villageCode, + name, + }; + }); + + return { + individualCodes, + villageByIndividual: individualCodeByVillageNameAndCode, + islandByIndividual: islandNameByIndividualCodes, + }; +}; + +const fetchEvents = async ( + reqContext: ReqContext, + entityCodes: string[], + hierarchy: string, + startDate?: string, + endDate?: string, + period?: string, +) => { + const aggregator = new ReportServerAggregator(createAggregator(undefined, reqContext)); + const resultEventsPromise = aggregator.fetchEvents( + 'C19T_Results', + undefined, + entityCodes, + hierarchy, + { startDate, endDate, period }, + SURVEYS.C19T_Results.dataElementCodes, + ); + const registrationEventsPromise = aggregator.fetchEvents( + 'C19T_Registration', + undefined, + entityCodes, + hierarchy, + {}, + SURVEYS.C19T_Registration.dataElementCodes, + ); + + const [registrationEvents, resultsEvents] = await Promise.all([ + registrationEventsPromise, + resultEventsPromise, + ]); + + return { registrationEvents, resultsEvents }; +}; + +const combineAndFlatten = (registrationEvents: Event[], resultEvents: Event[]) => { + const registrationEventsByOrgUnit = keyBy(registrationEvents, 'orgUnit'); + const combinedEvents: Record[] = []; + resultEvents.forEach(resultEvent => { + const { dataValues: resultDataValues, orgUnit, eventDate } = resultEvent; + const matchingRegistration = registrationEventsByOrgUnit[orgUnit]; + + if (!matchingRegistration) { + return; + } + + const { dataValues: registrationDataValues } = matchingRegistration; + + combinedEvents.push({ + orgUnit, + eventDate, + ...registrationDataValues, + ...resultDataValues, + }); + }); + + const dataWithUpdatesAndAddOns = combinedEvents.map(event => { + const { + orgUnit, + C19T033: result, + C19T042: onsetDate, + C19T004: dob, + eventDate: dateSpecimenCollected, + } = event; + const testDate = new Date(dateSpecimenCollected); + const age = getAge(dob, testDate); + return { + 'Test ID': orgUnit, + 'Estimated Recovery Date': getEstimatedRecoveryDate(result, dateSpecimenCollected, onsetDate), + dateSpecimenCollected, + Age: age, + ...event, + }; + }); + + return dataWithUpdatesAndAddOns; +}; + +const getAge = (dob: string | undefined, now: Date) => { + if (!dob) { + return 'unknown'; + } + const dobDate = new Date(dob); + const age = isDate(dobDate) && differenceInYears(now, dobDate); + return age; +}; + +const getEstimatedRecoveryDate = ( + result: string, + collectionDate: string, + onsetDate: string | undefined, +) => { + if (result !== 'Positive') { + return 'Not applicable'; + } + + if (onsetDate) { + const recoveryDate = addDays(new Date(onsetDate), 13); + + return isDate(recoveryDate) && format(recoveryDate, 'yyyy-MM-dd'); + } + + const recoveryDate = addDays(new Date(collectionDate), 13); + return isDate(recoveryDate) && format(recoveryDate, 'yyyy-MM-dd'); +}; + +const binaryToYesNo = (value: unknown) => { + if (value === undefined) { + return undefined; + } + + return value === 1 ? 'Yes' : 'No'; +}; + +const parseRowData = (rowData: Record) => { + const formattedRow: Record = {}; + const { codesToNames, binaryAndCheckboxQuestions } = SURVEYS; + Object.keys(rowData).forEach(fieldKey => { + switch (fieldKey) { + case 'dateSpecimenCollected': { + formattedRow['Date of Test'] = rowData[fieldKey]; + break; + } + case 'C19T004': { + if (!rowData[fieldKey]) { + formattedRow['Date of Birth'] = 'Unknown'; + } + const rawDate = new Date(rowData[fieldKey]); + const dobValue = isDate(rawDate) && format(rawDate, 'yyyy-MM-dd'); + formattedRow['Date of Birth'] = dobValue; + break; + } + case 'Test ID': + case 'Age': + case 'Estimated Recovery Date': + formattedRow[fieldKey] = rowData[fieldKey]; + break; + default: { + const name = codesToNames[fieldKey as keyof typeof codesToNames] || fieldKey; + const value = binaryAndCheckboxQuestions.includes(fieldKey) + ? binaryToYesNo(rowData[fieldKey]) + : rowData[fieldKey]; + formattedRow[name] = value; + } + } + }); + return formattedRow; +}; + +const addVillageAndIsland = ( + rows: Record[], + villageByIndividual: Record, + islandByIndividual: Record, +) => { + return rows.map(row => { + const { orgUnit } = row; + const { code: villageCode, name: villageName } = villageByIndividual[orgUnit]; + const islandName = islandByIndividual[orgUnit]; + return { + ...row, + 'Village Code': villageCode, + Address: villageName, + 'Island Group': islandName, + }; + }); +}; + +export const tongaCovidRawData = async (reqContext: ReqContext, query: FetchReportQuery) => { + const { organisationUnitCodes: entityCodes, hierarchy, startDate, endDate, period } = query; + + const { individualCodes, villageByIndividual, islandByIndividual } = await fetchEntities( + reqContext, + hierarchy, + entityCodes, + ); + + const { registrationEvents, resultsEvents } = await fetchEvents( + reqContext, + individualCodes, + hierarchy, + startDate, + endDate, + period, + ); + + const builtEvents: Record[] = combineAndFlatten(registrationEvents, resultsEvents); + const rows = builtEvents.map(rowData => parseRowData(rowData)); + + const rowsWithVillageAndIsland = addVillageAndIsland( + rows, + villageByIndividual, + islandByIndividual, + ); + + const columns: Record[] = SURVEYS.columns.map(key => { + return { title: key, key }; + }); + + return { columns, rows: rowsWithVillageAndIsland }; +}; diff --git a/yarn.lock b/yarn.lock index 5df23253bf..292dbf0dd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5581,12 +5581,14 @@ __metadata: api-error-handler: ^1.0.0 body-parser: ^1.18.3 cors: ^2.8.5 + date-fns: ^2.29.3 dotenv: ^8.2.0 express: ^4.16.2 lodash.isplainobject: ^4.0.6 lodash.keyby: ^4.6.0 lodash.pick: ^4.4.0 mathjs: ^9.4.0 + mockdate: ^3.0.5 moment: ^2.24.0 winston: ^3.2.1 languageName: unknown @@ -12640,6 +12642,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^2.29.3": + version: 2.29.3 + resolution: "date-fns@npm:2.29.3" + checksum: e01cf5b62af04e05dfff921bb9c9933310ed0e1ae9a81eb8653452e64dc841acf7f6e01e1a5ae5644d0337e9a7f936175fd2cb6819dc122fdd9c5e86c56be484 + languageName: node + linkType: hard + "dayjs@npm:^1.8.15": version: 1.9.6 resolution: "dayjs@npm:1.9.6"