diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000000..b26b7322a3 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,120 @@ +# Tupaia contributions guide + +Thank you for taking the time to contribute, and welcome to the Tupaia! +We value your expertise, insights, and commitment to making Tupaia a global good. This +document is a guide on how to contribute to Tupaia effectively, ensuring it remains a +cohesive, maintainable, and globally valuable product. + +## Code of conduct + +This project and everyone participating in it is governed by the +[BES Open Source Code of Conduct](code-of-conduct.md). +By participating, you are expected to uphold this code. Please report unacceptable behaviour to [opensource@bes.au](opensource@bes.au). + +## Copyright Attribution + +All open-source contributors to Tupaia will be required to sign a Contributor License Agreement (CLA) +before BES can accept your contribution into the project code. This is done so that we can continue +to maintain the codebase covered by a single license. Signing a CLA can be done through GitHub +before merging in your first pull request. + +## Our Philosophy + +Our core philosophy is to maintain the long-term sustainability of Tupaia. We, at BES work hard +to maintain a cohesive vision for the project. While we encourage collaboration and contributions, +we also want to avoid fragmentation that could compromise Tupaia's usability, maintainability, +and value. We believe that the best way to contribute is to align your efforts with our existing +product roadmap. + +## Contributing + +Before considering becoming an open-source contributor for Tupaia, please take note that becoming +an open-source contributor requires a significant amount of time onboarding, and ongoing +coordination and support from BES as the project maintainers. As such we encourage contributions +from individuals and organizations who are prepared to invest significant amounts of time into +their work, ensuring alignment with our roadmap and the highest quality results. While we appreciate +the interest of potential casual contributors, we aim to collaborate with contributors who are +serious about their commitment to Tupaia. If you're ready to deeply engage and uphold Tupaia's +vision and standards, we warmly welcome your involvement. + +### Following Our Roadmap + +Before making your contribution, make sure that it aligns with our product roadmap for Tupaia. +This ensures that your contribution builds towards a cohesive vision for Tupaia. This generally +means pulling in features from our existing roadmap, and speeding up the delivery process into the +core product. We will gladly co-design features if you identify something missing from the roadmap +that will benefit the users of Tupaia. + +#### Meaningful Contributions (under construction) + +To assess whether or not your contribution will be meaningful to the development of Tupaia before +making your contribution, put your contribution idea against the following criteria: + +**Documentation:** + +- If there is no documentation for a specific aspect of Tupaia +- If a specific area in the project's documentation is either: lacking detail, outdated or unclear. + +**Features:** + +- If the feature aligns with the Tupaia roadmap +- If your feature has been co-designed with a BES developer + +### Developer Onboarding and Orientation + +Being a complex product, Tupaia is at the stage where supported onboarding is necessary for the +project's contributors. The BES Tupaia team are happy to provide hands-on support for serious and +committed contributors, But we will ask to see evidence of your commitment as well as a CV. + +Steps for onboarding and setting up your development environment can be found on our [README](../README.md). +However, it's most likely that support is needed from a BES developer in order for a successful and +stable onboarding. + +## Making your code contribution + +You can find an outline of the project structure and prerequisites on our [Readme](https://github.com/beyondessential/tupaia/blob/master/README.md) + +### Code Quality + +When making your contribution, please ensure that your code is of high quality. Follows best +practices, write clean and readable code, add comments where necessary, and thoroughly test your +code. We have a code review process for all contributions, and have a high bar for quality. + +### Branch Naming Conventions + +For open-source contribution branches, the naming convention is: + + - + +For example, `1736-pdf-export-crash` + +### Branching Strategy + +1. When creating a branch for your contribution, branch off the latest `dev`. +2. Make your commits on the branch you made. +3. Once your changes are complete, pull from the latest `dev` and create a pull request on GitHub + +### Pull Requests + +When your contribution is ready for review, create a pull request. A BES developer will review +your changes and provide feedback. If your pull request has been approved by a reviewer, it +will then go through a round of internal testing. Once your changes have passed testing, they +can be merged into main and be included in an upcoming Tupaia release. + +### Note on Forking + +While Tupaia is open-source, there is always the possibility for forking the repository, which +we strongly recommend against. We will not provide any support for forked versions of Tupaia. +We have seen projects struggle and even fail because of this type of splintering. Instead, we +encourage you to collaborate with us on the mainline product roadmap. + +## Licence + +Any contributions you make will be licensed under [our open-source license](https://github.com/beyondessential/tupaia/blob/master/LICENSE) + +--- + +Again, thank you for considering contributing to Tupaia. With your help, we can make Tupaia a +sustainable, useful, and valuable global good. + +The Tupaia Team diff --git a/.github/code-of-conduct.md b/.github/code-of-conduct.md new file mode 100644 index 0000000000..d31e02e405 --- /dev/null +++ b/.github/code-of-conduct.md @@ -0,0 +1,69 @@ +# BES Contributor Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behaviour that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behaviour include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Appreciate and Accommodate Our Similarities and Differences + +Our contributors come from many cultures and backgrounds. Cultural differences can encompass everything from official religious observances to personal habits to clothing. Be respectful of people with different cultural practices, attitudes and beliefs. Work to eliminate your own biases, prejudices and discriminatory practices. Think of others’ needs from their point of view. Use preferred titles (including pronouns) and the appropriate tone of voice. Respect people’s right to privacy and confidentiality. Be open to learning from and educating others as well as educating yourself; it is unrealistic to expect our contributors to know the cultural practices of every ethnic and cultural group, but everyone needs to recognize one’s native culture is only part of positive interactions. + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behaviour and will take appropriate and fair corrective action in response to any behaviour that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, docs site edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported to the community leaders responsible for enforcement at opensource@bes.au. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement guidelines + +### 1. Warning + +Community Impact: Use of inappropriate language or other behaviour deemed unprofessional or unwelcome in the community. + +Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behaviour was inappropriate. A public apology may be requested. + +### 2. Temporary or Permanent Ban + +Community Impact: A serious violation of community standards, including sustained inappropriate behaviour. + +A ban from any sort of interaction or public communication with the community, either permanently or for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, +available at https://www.contributor-covenant.org/version/2/1/code_of_conduct/ with the addition of Appreciate and Accommodate Our Similarities and Differences adapted from the [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e7a58e9213..264841435b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -91,6 +91,10 @@ jobs: DB_URL: localhost DB_USER: tupaia AGGREGATION_URL_PREFIX: dev- + API_CLIENT_NAME: api-client + API_CLIENT_PASSWORD: api-client + API_CLIENT_SALT: abc132 + JWT_SECRET: abc132 # test database DB_PG_USER: postgres @@ -104,12 +108,6 @@ jobs: DATA_LAKE_DB_URL: localhost DATA_LAKE_DB_USER: tupaia - # central-server - API_CLIENT_SALT: abc132 - CLIENT_SECRET: abc132 - CLIENT_USERNAME: meditrak_client_test - JWT_SECRET: abc132 - strategy: fail-fast: false matrix: diff --git a/code-of-conduct.md b/code-of-conduct.md deleted file mode 100644 index cabd17e9a9..0000000000 --- a/code-of-conduct.md +++ /dev/null @@ -1,79 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to make participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Appreciate and Accommodate Our Similarities and Differences -Our contributors come from many cultures and backgrounds. Cultural differences can encompass everything from official religious observances to personal habits to clothing. Be respectful of people with different cultural practices, attitudes and beliefs. Work to eliminate your own biases, prejudices and discriminatory practices. Think of others’ needs from their point of view. Use preferred titles (including pronouns) and the appropriate tone of voice. Respect people’s right to privacy and confidentiality. Be open to learning from and educating others as well as educating yourself; it is unrealistic to expect our contributors to know the cultural practices of every ethnic and cultural group, but everyone needs to recognize one’s native culture is only part of positive interactions. - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies within all project spaces, and it also applies when -an individual is representing the project or its community in public spaces. -Examples of representing a project or community include using an official -project e-mail address, posting via an official social media account, or acting -as an appointed representative at an online or offline event. Representation of -a project may be further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at opensource@beyondessential.com.au. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html with the addition of Appreciate and Accommodate Our Similarities and Differences adapted from the [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/package.json b/package.json index 038ed13478..02553ec162 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "ts-node": "^10.7.0", "typescript": "^5.2.2", "vite": "^4.4.8", + "vite-plugin-compression": "^0.5.1", "vite-plugin-ejs": "^1.6.4", "yargs": "15.4.1" }, diff --git a/packages/admin-panel-server/src/app/createApp.ts b/packages/admin-panel-server/src/app/createApp.ts index 4c60cd7910..dfe6c745d5 100644 --- a/packages/admin-panel-server/src/app/createApp.ts +++ b/packages/admin-panel-server/src/app/createApp.ts @@ -53,9 +53,9 @@ const { /** * Set up express server with middleware, */ -export function createApp() { +export async function createApp() { const forwardToEntityApi = forwardRequest(ENTITY_API_URL); - const app = new OrchestratorApiBuilder(new TupaiaDatabase(), 'admin-panel') + const builder = new OrchestratorApiBuilder(new TupaiaDatabase(), 'admin-panel') .attachApiClientToContext(authHandlerProvider) .useSessionModel(AdminPanelSessionModel) .verifyLogin(hasTupaiaAdminPanelAccess) @@ -143,8 +143,11 @@ export function createApp() { ) .use('hierarchy', forwardToEntityApi) .use('hierarchies', forwardToEntityApi) - .use('*', forwardRequest(CENTRAL_API_URL)) - .build(); + .use('*', forwardRequest(CENTRAL_API_URL)); + + await builder.initialiseApiClient([{ entityCode: 'DL', permissionGroupName: 'Public' }]); + + const app = builder.build(); return app; } diff --git a/packages/admin-panel-server/src/index.ts b/packages/admin-panel-server/src/index.ts index 16ef73d9b5..632cf44019 100644 --- a/packages/admin-panel-server/src/index.ts +++ b/packages/admin-panel-server/src/index.ts @@ -3,8 +3,8 @@ * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ -import * as dotenv from 'dotenv'; import http from 'http'; +import * as dotenv from 'dotenv'; import winston from 'winston'; import { configureWinston } from '@tupaia/server-boilerplate'; @@ -13,21 +13,23 @@ import { createApp } from './app'; configureWinston(); dotenv.config(); // Load the environment variables into process.env -/** - * Set up app with routes etc. - */ -const app = createApp(); +(async () => { + /** + * Set up app with routes etc. + */ + const app = await createApp(); -/** - * Start the server - */ -const port = process.env.PORT || 8070; -http.createServer(app).listen(port); -winston.info(`Running on port ${port}`); + /** + * Start the server + */ + const port = process.env.PORT || 8070; + http.createServer(app).listen(port); + winston.info(`Running on port ${port}`); -/** - * Notify PM2 that we are ready - * */ -if (process.send) { - process.send('ready'); -} + /** + * Notify PM2 that we are ready + * */ + if (process.send) { + process.send('ready'); + } +})(); diff --git a/packages/admin-panel/.env.example b/packages/admin-panel/.env.example index 19d732a4d6..fd327ca58f 100644 --- a/packages/admin-panel/.env.example +++ b/packages/admin-panel/.env.example @@ -1,4 +1,2 @@ REACT_APP_API_URL= -REACT_APP_CLIENT_BASIC_AUTH_HEADER= - REACT_APP_VIZ_BUILDER_API_URL= \ No newline at end of file diff --git a/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx b/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx index fb0f4a4aa8..87ccada25c 100644 --- a/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx +++ b/packages/admin-panel/src/autocomplete/ReduxAutocomplete.jsx @@ -3,7 +3,7 @@ * Copyright (c) 2018 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { getAutocompleteState } from './selectors'; @@ -26,6 +26,8 @@ const ReduxAutocompleteComponent = React.memo( onChangeSelection, onChangeSearchTerm, selection, + setInitialSelection, + initialValue, isLoading, results, label, @@ -37,12 +39,27 @@ const ReduxAutocompleteComponent = React.memo( placeholder, helperText, }) => { + const [hasSetInitialSelection, setHasSetInitialSelection] = useState(false); + React.useEffect(() => { return () => { onClearState(); }; }, []); + // If working with a multi-value input, set the initial selection to the initial value so that users can easily add/remove from the existing values + React.useEffect(() => { + if ( + !hasSetInitialSelection && + allowMultipleValues && + initialValue && + initialValue.length > 0 + ) { + setInitialSelection(initialValue); + setHasSetInitialSelection(true); + } + }, [hasSetInitialSelection, allowMultipleValues, initialValue]); + let value = selection; // If value is null and multiple is true mui autocomplete will crash @@ -77,6 +94,7 @@ ReduxAutocompleteComponent.propTypes = { allowMultipleValues: PropTypes.bool, canCreateNewOptions: PropTypes.bool, isLoading: PropTypes.bool.isRequired, + setInitialSelection: PropTypes.func.isRequired, onChangeSearchTerm: PropTypes.func.isRequired, onChangeSelection: PropTypes.func.isRequired, onClearState: PropTypes.func.isRequired, @@ -87,11 +105,13 @@ ReduxAutocompleteComponent.propTypes = { label: PropTypes.string, helperText: PropTypes.string, selection: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + initialValue: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), }; ReduxAutocompleteComponent.defaultProps = { allowMultipleValues: false, selection: [], + initialValue: [], results: [], canCreateNewOptions: false, searchTerm: null, @@ -123,6 +143,15 @@ const mapDispatchToProps = ( distinct, }, ) => ({ + setInitialSelection: initialValue => + dispatch( + changeSelection( + reduxId, + // Note: This will look incorrect if we're using a different optionLabelKey to optionValueKey (as the selected option will have the 'value' as the label) + // However the same issue exists with the placeholder, and in practice we rarely use different keys for labels and values in multi-select + initialValue.map(value => ({ [optionLabelKey]: value, [optionValueKey]: value })), + ), + ), onChangeSelection: (event, newSelection, reason) => { if (newSelection === null) { onChange(null); diff --git a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx index 72de26b37d..c4293e69fa 100644 --- a/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx +++ b/packages/admin-panel/src/widgets/InputField/registerInputFields.jsx @@ -66,6 +66,7 @@ export const registerInputFields = () => { { + toHaveBeenCalledOnceWith(...params: E): R; + toBeRejectedWith(error?: string | Constructable | RegExp | Error): Promise; + } + } +} diff --git a/packages/aggregator/src/Aggregator.js b/packages/aggregator/src/Aggregator.js index 302050de0e..4e19887e5c 100644 --- a/packages/aggregator/src/Aggregator.js +++ b/packages/aggregator/src/Aggregator.js @@ -125,13 +125,11 @@ export class Aggregator { } async fetchDataElements(codes, fetchOptions) { - const dataSourceSpec = { code: codes, type: this.dataSourceTypes.DATA_ELEMENT }; - return this.dataBroker.pullMetadata(dataSourceSpec, fetchOptions); + return this.dataBroker.pullDataElements(codes, fetchOptions); } async fetchDataGroup(code, fetchOptions) { - const dataSourceSpec = { code, type: this.dataSourceTypes.DATA_GROUP }; - return this.dataBroker.pullMetadata(dataSourceSpec, fetchOptions); + return this.dataBroker.pullDataGroup(code, fetchOptions); } // TODO ultimately Aggregator should handle preaggregation internally - at that point this method diff --git a/packages/aggregator/src/__tests__/Aggregator/Aggregator.test.js b/packages/aggregator/src/__tests__/Aggregator/Aggregator.test.js index c18bf0092b..b09a86a345 100644 --- a/packages/aggregator/src/__tests__/Aggregator/Aggregator.test.js +++ b/packages/aggregator/src/__tests__/Aggregator/Aggregator.test.js @@ -12,6 +12,8 @@ import { RESPONSE_BY_SOURCE_TYPE, AGGREGATED_ANALYTICS, FILTERED_ANALYTICS, + DATA_ELEMENTS, + DATA_GROUPS, } from './fixtures'; jest.mock('../../analytics/aggregateAnalytics/aggregateAnalytics'); @@ -26,16 +28,17 @@ const dataBroker = { getDataSourceTypes: jest.fn(() => DATA_SOURCE_TYPES), pullAnalytics: jest.fn(async () => RESPONSE_BY_SOURCE_TYPE[DATA_ELEMENT]), pullEvents: jest.fn(async () => RESPONSE_BY_SOURCE_TYPE[DATA_GROUP]), + pullDataElements: jest.fn(async codes => + Object.values(DATA_ELEMENTS).filter(de => codes.includes(de.code)), + ), + pullDataGroup: jest.fn(async code => DATA_GROUPS[code]), }; +/** + * @type {Aggregator} + */ let aggregator; -const fetchOptions = { - organisationUnitCodes: ['TO'], - startDate: '20200214', - endDate: '20200215', - period: '20200214;20200215', -}; const aggregationOptions = { aggregations: [ { @@ -56,6 +59,13 @@ describe('Aggregator', () => { }); describe('fetchAnalytics()', () => { + const fetchOptions = { + organisationUnitCodes: ['TO'], + startDate: '20200214', + endDate: '20200215', + period: '20200214;20200215', + }; + it('`aggregationOptions` parameter is optional', async () => { const assertErrorIsNotThrown = async emptyAggregationOptions => expect( @@ -170,7 +180,14 @@ describe('Aggregator', () => { }); }); - describe('fetch events', () => { + describe('fetchEvents()', () => { + const fetchOptions = { + organisationUnitCodes: ['TO'], + startDate: '20200214', + endDate: '20200215', + period: '20200214;20200215', + }; + it('fetches events', async () => { const code = 'PROGRAM_1'; @@ -205,4 +222,25 @@ describe('Aggregator', () => { return expect(response).toStrictEqual([]); }); }); + + describe('fetchDataElements', () => { + it('fetches data elements', async () => { + const codes = ['POP01', 'POP02']; + const fetchOptions = { includeOptions: true }; + + const results = await aggregator.fetchDataElements(codes, fetchOptions); + expect(results).toStrictEqual([DATA_ELEMENTS.POP01, DATA_ELEMENTS.POP02]); + expect(dataBroker.pullDataElements).toHaveBeenCalledOnceWith(codes, fetchOptions); + }); + }); + + describe('fetchDataGroup', () => { + it('fetches data groups', async () => { + const fetchOptions = { includeOptions: true }; + + const result = await aggregator.fetchDataGroup('POP', fetchOptions); + expect(result).toStrictEqual(DATA_GROUPS.POP); + expect(dataBroker.pullDataGroup).toHaveBeenCalledOnceWith('POP', fetchOptions); + }); + }); }); diff --git a/packages/aggregator/src/__tests__/Aggregator/fixtures.js b/packages/aggregator/src/__tests__/Aggregator/fixtures.js index af8fad9c47..f211397e6b 100644 --- a/packages/aggregator/src/__tests__/Aggregator/fixtures.js +++ b/packages/aggregator/src/__tests__/Aggregator/fixtures.js @@ -28,3 +28,17 @@ export const AGGREGATED_ANALYTICS = [ { dataElement: 'POP01', period: '20200214', value: 6 }, ]; export const FILTERED_ANALYTICS = [{ dataElement: 'POP01', period: '20200214', value: 3 }]; + +export const DATA_ELEMENTS = { + POP01: { code: 'POP01', name: 'Population 1' }, + POP02: { code: 'POP02', name: 'Population 2' }, + DIFF: { code: 'Diff', name: 'Other' }, +}; + +export const DATA_GROUPS = { + POP: { + code: 'POP', + name: 'Population Survey', + dataElements: [DATA_ELEMENTS.POP01, DATA_ELEMENTS.POP02], + }, +}; diff --git a/packages/central-server/.env.example b/packages/central-server/.env.example index b4f7549455..eb125453e5 100644 --- a/packages/central-server/.env.example +++ b/packages/central-server/.env.example @@ -2,8 +2,6 @@ AGGREGATION_URL_PREFIX= API_CLIENT_SALT= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -CLIENT_SECRET= -CLIENT_USERNAME= TUPAIA_ADMIN_EMAIL_ADDRESS= DB_ENABLE_SSL= DB_NAME= diff --git a/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js b/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js index f2477a739d..c72eaf3f8f 100644 --- a/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js +++ b/packages/central-server/src/apiV2/answers/assertAnswerPermissions.js @@ -91,7 +91,8 @@ export const createAnswerViaSurveyResponseDBFilter = async ( // Add additional sorting when requesting via parent const dbOptions = { ...options, - sort: ['screen_number', 'component_number', ...options.sort], + // use the specified sort order first, so the the results get correctly sorted + sort: [...options.sort, 'screen_number', 'component_number'], }; // Join other tables necessary for the additional sorting entries diff --git a/packages/central-server/src/apiV2/export/constructExportEmail.js b/packages/central-server/src/apiV2/export/constructExportEmail.js index b2eac0a85c..cd484c3f85 100644 --- a/packages/central-server/src/apiV2/export/constructExportEmail.js +++ b/packages/central-server/src/apiV2/export/constructExportEmail.js @@ -2,30 +2,49 @@ * Tupaia * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd */ - +import path from 'path'; +import fs from 'fs'; import { createDownloadLink } from './download'; -const constructMessage = responseBody => { - const { error, filePath } = responseBody; +const EMAIL_EXPORT_FILE_MODES = { + ATTACHMENT: 'attachment', + DOWNLOAD_LINK: 'downloadLink', +}; - if (error) { - return `Unfortunately, your export failed. +const generateAttachments = async filePath => { + const fileName = path.basename(filePath); + const buffer = await fs.readFileSync(filePath); + return [{ filename: fileName, content: buffer }]; +}; -${error}`; +export const constructExportEmail = async (responseBody, req) => { + const { emailExportFileMode = EMAIL_EXPORT_FILE_MODES.DOWNLOAD_LINK } = req.query; + const { error, filePath } = responseBody; + const subject = 'Your export from Tupaia'; + if (error) { + return { + subject, + message: `Unfortunately, your export failed. +${error}`, + }; } if (!filePath) { throw new Error('No filePath in export response body'); } - const downloadLink = createDownloadLink(filePath); - - return `Please click this one-time link to download your requested export: ${downloadLink} + if (emailExportFileMode === EMAIL_EXPORT_FILE_MODES.ATTACHMENT) { + return { + subject, + message: 'Please find your requested export attached to this email.', + attachments: await generateAttachments(filePath), + }; + } -Note that you need to be logged in to the admin panel for it to work, and after clicking it once, you won't be able to download the file again.`; + const downloadLink = createDownloadLink(filePath); + return { + subject, + message: `Please click this one-time link to download your requested export: ${downloadLink} +Note that you need to be logged in to the admin panel for it to work, and after clicking it once, you won't be able to download the file again.`, + }; }; - -export const constructExportEmail = responseBody => ({ - subject: 'Your export from Tupaia', - message: constructMessage(responseBody), -}); diff --git a/packages/central-server/src/apiV2/middleware/auth/clientAuth.js b/packages/central-server/src/apiV2/middleware/auth/clientAuth.js index 01272cc4b0..c7fe433468 100644 --- a/packages/central-server/src/apiV2/middleware/auth/clientAuth.js +++ b/packages/central-server/src/apiV2/middleware/auth/clientAuth.js @@ -12,19 +12,7 @@ export async function getAPIClientUser(authHeader, models) { throw new UnauthenticatedError('The provided basic authorization header is invalid'); } - // Still check against environment variables so that these - // updates can be deployed without breaking the app. This - // check can be removed once the proper credentials have been - // added to the production database. - const { API_CLIENT_SALT, CLIENT_USERNAME, CLIENT_SECRET } = process.env; - if ( - CLIENT_SECRET && - CLIENT_USERNAME && - username === CLIENT_USERNAME && - secretKey === CLIENT_SECRET - ) { - return { username: 'env', user_account_id: null }; - } + const { API_CLIENT_SALT } = process.env; // We always need a valid client; throw if none is found const secretKeyHash = encryptPassword(secretKey, API_CLIENT_SALT); diff --git a/packages/central-server/src/apiV2/middleware/emailAfterTimeout.js b/packages/central-server/src/apiV2/middleware/emailAfterTimeout.js index 3012dd754d..0356c08071 100644 --- a/packages/central-server/src/apiV2/middleware/emailAfterTimeout.js +++ b/packages/central-server/src/apiV2/middleware/emailAfterTimeout.js @@ -31,8 +31,8 @@ const setupEmailResponse = async (req, res, constructEmailFromResponse) => { // override the respond function so that when the endpoint handler finishes (or throws an error), // the response is sent via email - res.overrideRespond = responseBody => { - const { subject, message, attachments } = constructEmailFromResponse(responseBody); + res.overrideRespond = async responseBody => { + const { subject, message, attachments } = await constructEmailFromResponse(responseBody, req); sendResponseAsEmail(user, subject, message, attachments); }; }; diff --git a/packages/central-server/src/apiV2/surveyResponses/saveResponsesToDatabase.js b/packages/central-server/src/apiV2/surveyResponses/saveResponsesToDatabase.js index 9d72c05158..24f3928fa9 100644 --- a/packages/central-server/src/apiV2/surveyResponses/saveResponsesToDatabase.js +++ b/packages/central-server/src/apiV2/surveyResponses/saveResponsesToDatabase.js @@ -2,6 +2,7 @@ import { generateId } from '@tupaia/database'; import { getTimezoneNameFromTimestamp } from '@tupaia/tsutils'; import { ValidationError, stripTimezoneFromDate } from '@tupaia/utils'; import keyBy from 'lodash.keyby'; +import momentTimezone from 'moment-timezone'; import { upsertAnswers } from '../../dataAccessors'; async function getRecordsByCode(model, codes) { @@ -72,8 +73,14 @@ function buildResponseRecord(user, entitiesByCode, body) { } const defaultToTimestampOrThrow = (value, parameterName) => { - if (value) return new Date(value).toISOString(); - if (timestamp) return new Date(timestamp).toISOString(); + if (value) + return momentTimezone(value) + .tz(timezone || 'Etc/UTC') + .format(); + if (timestamp) + return momentTimezone(timestamp) + .tz(timezone || 'Etc/UTC') + .format(); throw new ValidationError(`Must provide ${parameterName} or timestamp`); }; diff --git a/packages/central-server/src/tests/apiV2/meditrakApp/permissionsBasedSync.fixtures.js b/packages/central-server/src/tests/apiV2/meditrakApp/permissionsBasedSync.fixtures.js index a184c67393..88974b8037 100644 --- a/packages/central-server/src/tests/apiV2/meditrakApp/permissionsBasedSync.fixtures.js +++ b/packages/central-server/src/tests/apiV2/meditrakApp/permissionsBasedSync.fixtures.js @@ -2,11 +2,11 @@ * Tupaia * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd */ - import { upsertCountry, upsertEntity, upsertPermissionGroup, + upsertProject, upsertQuestion, upsertSurvey, upsertSurveyScreen, @@ -139,6 +139,10 @@ export const insertPermissionsBasedSyncTestData = async () => { return upsertCountry({ code, name }); }), ); + + const project = await upsertProject({ + code: 'test_project', + }); const entities = await Promise.all(ENTITIES.map(e => upsertEntity(e))); const permissionGroups = await Promise.all( PERMISSION_GROUPS.map(pg => upsertPermissionGroup(pg)), @@ -154,7 +158,12 @@ export const insertPermissionsBasedSyncTestData = async () => { const countryIds = surveyCountries.map(code => countries.find(c => c.code === code).id); const permissionGroupId = permissionGroups.find(pg => pg.name === surveyPermissionGroup).id; - return { country_ids: countryIds, permission_group_id: permissionGroupId, ...restOfSurvey }; + return { + country_ids: countryIds, + permission_group_id: permissionGroupId, + project_id: project.id, + ...restOfSurvey, + }; }); const surveys = await Promise.all(surveysWithIds.map(s => upsertSurvey(s))); diff --git a/packages/central-server/src/tests/hooks/questionHooks.test.js b/packages/central-server/src/tests/hooks/questionHooks.test.js index 9aabc3cda2..905b7da8ed 100644 --- a/packages/central-server/src/tests/hooks/questionHooks.test.js +++ b/packages/central-server/src/tests/hooks/questionHooks.test.js @@ -188,7 +188,7 @@ describe('Question hooks', () => { body: { survey_id: GENERIC_SURVEY_ID, entity_id: ENTITY_ID, - timestamp: 999, + timestamp: 9999, answers: { 'TEST_backdate-test': 'test', }, @@ -205,7 +205,7 @@ describe('Question hooks', () => { body: { survey_id: GENERIC_SURVEY_ID, entity_id: ENTITY_ID, - timestamp: 888, + timestamp: 8888, answers: { 'TEST_backdate-test': 'test', }, diff --git a/packages/central-server/src/tests/testUtilities/TestableApp.js b/packages/central-server/src/tests/testUtilities/TestableApp.js index 2c63cf36c8..e01796598e 100644 --- a/packages/central-server/src/tests/testUtilities/TestableApp.js +++ b/packages/central-server/src/tests/testUtilities/TestableApp.js @@ -21,7 +21,7 @@ const getVersionedEndpoint = (endpoint, apiVersion = DEFAULT_API_VERSION) => `/v${apiVersion}/${endpoint}`; export const getAuthorizationHeader = () => { - return createBasicHeader(process.env.CLIENT_USERNAME, process.env.CLIENT_SECRET); + return createBasicHeader(process.env.API_CLIENT_NAME, process.env.API_CLIENT_PASSWORD); }; const translateQuery = query => diff --git a/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js b/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js index 2fc8416181..204be68bae 100644 --- a/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js +++ b/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js @@ -70,8 +70,8 @@ export async function addBaselineTestData() { }); const apiUser = await createUserAccessor(models, { - emailAddress: process.env.CLIENT_USERNAME, - password: process.env.CLIENT_SECRET, + emailAddress: process.env.API_CLIENT_NAME, + password: process.env.API_CLIENT_PASSWORD, firstName: 'API', lastName: 'Client', employer: 'Automation', @@ -84,11 +84,14 @@ export async function addBaselineTestData() { await models.apiClient.findOrCreate( { - username: process.env.CLIENT_USERNAME, + username: process.env.API_CLIENT_NAME, }, { user_account_id: apiUser.userId, - secret_key_hash: encryptPassword(process.env.CLIENT_SECRET, process.env.API_CLIENT_SALT), + secret_key_hash: encryptPassword( + process.env.API_CLIENT_PASSWORD, + process.env.API_CLIENT_SALT, + ), }, ); } diff --git a/packages/central-server/src/tests/testUtilities/database/upsertRecord.js b/packages/central-server/src/tests/testUtilities/database/upsertRecord.js index 22da43e1de..cd651e0fdc 100644 --- a/packages/central-server/src/tests/testUtilities/database/upsertRecord.js +++ b/packages/central-server/src/tests/testUtilities/database/upsertRecord.js @@ -3,7 +3,7 @@ * Copyright (c) 2019 Beyond Essential Systems Pty Ltd */ -import { upsertDummyRecord } from '@tupaia/database'; +import { generateTestId, upsertDummyRecord } from '@tupaia/database'; import { getModels } from './getModels'; const models = getModels(); @@ -53,7 +53,11 @@ export const upsertDataGroup = async data => { }; export const upsertSurvey = async data => { - return upsertDummyRecord(models.survey, data); + const project = await upsertProject({ code: generateTestId() }); + return upsertDummyRecord(models.survey, { + ...data, + project_id: project.id, + }); }; export const upsertSurveyGroup = async data => { @@ -90,3 +94,7 @@ export const upsertSurveyScreenComponent = async data => { export const upsertPermissionGroup = async data => { return upsertDummyRecord(models.permissionGroup, data); }; + +export const upsertProject = async data => { + return upsertDummyRecord(models.project, data); +}; diff --git a/packages/data-broker/.env.example b/packages/data-broker/.env.example new file mode 100644 index 0000000000..121449ba67 --- /dev/null +++ b/packages/data-broker/.env.example @@ -0,0 +1,6 @@ +# Used in tests +DB_NAME= +DB_PASSWORD= +DB_PORT= +DB_URL= +DB_USER= diff --git a/packages/data-broker/README.md b/packages/data-broker/README.md index 24c9e09c65..c184ee3aee 100644 --- a/packages/data-broker/README.md +++ b/packages/data-broker/README.md @@ -13,5 +13,8 @@ Centralised gateway which provides a common interface to external data sources. ### Interface - `push` - pushes data to an external data source -- `pull` - pulls analytics or events data for requested data elements or data groups -- `pullMetadata` - pull metadata around requested data elements, data groups, or sync groups +- `pullAnalytics` - pulls analytics for requested data elements +- `pullEvents` - pulls event data for requested data groups +- `pullSyncGroupResults` - pulls data for requested sync groups +- `pullDataElements` - pull metadata around requested data elements +- `pullDataGroup` - pull metadata around requested data group diff --git a/packages/data-broker/src/DataBroker/DataBroker.ts b/packages/data-broker/src/DataBroker/DataBroker.ts index ae7ad56b63..42e1c425a5 100644 --- a/packages/data-broker/src/DataBroker/DataBroker.ts +++ b/packages/data-broker/src/DataBroker/DataBroker.ts @@ -44,10 +44,14 @@ type FetchConditions = { code: string | string[] }; type Fetcher = (dataSourceSpec: FetchConditions) => Promise; -interface PullOptions { +type PullOptions = Record & { organisationUnitCode?: string; organisationUnitCodes?: string[]; -} +}; + +type PullMetadataOptions = Record & { + organisationUnitCode?: string; +}; let modelRegistry: DataBrokerModelRegistry; @@ -259,17 +263,26 @@ export class DataBroker { ); } - public async pullMetadata(dataSourceSpec: DataSourceSpec, options?: Record) { - const dataSources = await this.fetchDataSources(dataSourceSpec); + public async pullDataElements(codes: string[], options?: PullMetadataOptions) { + const dataElements = await fetchDataElements(this.models, codes); const { serviceType, dataServiceMapping } = await this.getSingleServiceAndMapping( - dataSources, + dataElements, options, ); const service = this.createService(serviceType); - // `dataSourceSpec` is defined for a single `type` - const { type } = dataSourceSpec; - return service.pullMetadata(dataSources, type, { dataServiceMapping, ...options }); + return service.pullMetadata(dataElements, 'dataElement', { dataServiceMapping, ...options }); + } + + public async pullDataGroup(code: string, options?: PullMetadataOptions) { + const [dataGroup] = await fetchDataGroups(this.models, [code]); + const { serviceType, dataServiceMapping } = await this.getSingleServiceAndMapping( + [dataGroup], + options, + ); + + const service = this.createService(serviceType); + return service.pullMetadata([dataGroup], 'dataGroup', { dataServiceMapping, ...options }); } /** @@ -277,7 +290,7 @@ export class DataBroker { */ private async getSingleServiceAndMapping( dataSources: DataSourceTypeInstance[], - options: { organisationUnitCode?: string } = {}, + options: PullMetadataOptions = {}, ): Promise<{ serviceType: ServiceType; dataServiceMapping: DataServiceMapping }> { const { organisationUnitCode } = options; diff --git a/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts b/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts index 79b4ae4c14..930a7e3068 100644 --- a/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts +++ b/packages/data-broker/src/__tests__/DataBroker/DataBroker.stubs.ts @@ -8,6 +8,7 @@ import { reduceToDictionary } from '@tupaia/utils'; import { createModelsStub as baseCreateModelsStub } from '@tupaia/database'; import * as CreateService from '../../services/createService'; import { Service } from '../../services/Service'; +import { DataBrokerModelRegistry, DataElement, DataGroup, DataServiceSyncGroup } from '../../types'; import { DATA_ELEMENT_DATA_SERVICES, DATA_ELEMENTS, @@ -16,7 +17,6 @@ import { MockServiceData, SYNC_GROUPS, } from './DataBroker.fixtures'; -import { DataBrokerModelRegistry, DataElement, DataGroup, DataServiceSyncGroup } from '../../types'; export const stubCreateService = (services: Record) => jest.spyOn(CreateService, 'createService').mockImplementation((_, type) => { diff --git a/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts b/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts index 49c3081bb6..e2f218217e 100644 --- a/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts +++ b/packages/data-broker/src/__tests__/DataBroker/DataBroker.test.ts @@ -7,12 +7,12 @@ import assert from 'assert'; import { AccessPolicy } from '@tupaia/access-policy'; import { DataBroker } from '../../DataBroker/DataBroker'; +import { DataServiceMapping } from '../../services/DataServiceMapping'; import { Service } from '../../services/Service'; import { DataBrokerModelRegistry, DataElement, ServiceType } from '../../types'; import { DATA_SOURCE_TYPES } from '../../utils'; import { DATA_BY_SERVICE, DATA_ELEMENTS, DATA_GROUPS, SYNC_GROUPS } from './DataBroker.fixtures'; import { stubCreateService, createModelsStub, MockService } from './DataBroker.stubs'; -import { DataServiceMapping } from '../../services/DataServiceMapping'; const mockModels = createModelsStub(); @@ -61,7 +61,13 @@ describe('DataBroker', () => { }); describe('Data service resolution', () => { - type MethodUnderTest = 'push' | 'pullAnalytics' | 'pullEvents' | 'delete' | 'pullMetadata'; + type MethodUnderTest = + | 'push' + | 'pullAnalytics' + | 'pullEvents' + | 'delete' + | 'pullDataElements' + | 'pullDataGroup'; const TO_OPTIONS = { organisationUnitCode: 'TO_FACILITY_01' }; const FJ_OPTIONS = { organisationUnitCode: 'FJ_FACILITY_01' }; @@ -81,8 +87,8 @@ describe('DataBroker', () => { ['delete', 2, [{ code: 'DHIS_PROGRAM_01', type: 'dataGroup' }, TO_OPTIONS], 'dhis'], ['pullAnalytics', 1, ['DHIS_01', TO_OPTIONS], 'dhis'], ['pullEvents', 2, ['DHIS_PROGRAM_01', TO_OPTIONS], 'dhis'], - ['pullMetadata', 1, [{ code: 'DHIS_01', type: 'dataElement' }, TO_OPTIONS], 'dhis'], - ['pullMetadata', 2, [{ code: 'DHIS_PROGRAM_01', type: 'dataGroup' }, TO_OPTIONS], 'dhis'], + ['pullDataElements', 1, [['DHIS_01'], TO_OPTIONS], 'dhis'], + ['pullDataGroup', 2, ['DHIS_PROGRAM_01', TO_OPTIONS], 'dhis'], ]; testData.forEach(([methodUnderTest, testNum, inputArgs, expectedServiceNameUsed]) => @@ -115,9 +121,9 @@ describe('DataBroker', () => { ['delete', 1, [{ code: 'DHIS_01', type: 'dataElement' }, NO_OU_OPT], 'dhis'], ['delete', 2, [{ code: 'DHIS_PROGRAM_01', type: 'dataGroup' }, NO_OU_OPT], 'dhis'], ['pullAnalytics', 1, ['DHIS_01', NO_OU_OPT], 'dhis'], - ['pullEvents', 2, ['DHIS_PROGRAM_01', NO_OU_OPT], 'dhis'], - ['pullMetadata', 1, [{ code: 'DHIS_01', type: 'dataElement' }, NO_OU_OPT], 'dhis'], - ['pullMetadata', 2, [{ code: 'DHIS_PROGRAM_01', type: 'dataGroup' }, NO_OU_OPT], 'dhis'], + ['pullEvents', 1, ['DHIS_PROGRAM_01', NO_OU_OPT], 'dhis'], + ['pullDataElements', 1, [['DHIS_01'], NO_OU_OPT], 'dhis'], + ['pullDataGroup', 1, ['DHIS_PROGRAM_01'], 'dhis'], ]; testData.forEach(([methodUnderTest, testNum, inputArgs, expectedServiceNameUsed]) => @@ -148,7 +154,7 @@ describe('DataBroker', () => { 'tupaia', ], ['pullAnalytics', ['MAPPED_01', FJ_OPTIONS], 'tupaia'], - ['pullMetadata', [{ code: 'MAPPED_01', type: 'dataElement' }, FJ_OPTIONS], 'tupaia'], + ['pullDataElements', [['MAPPED_01'], FJ_OPTIONS], 'tupaia'], ]; testData.forEach(([methodUnderTest, inputArgs, expectedServiceType]) => @@ -166,35 +172,71 @@ describe('DataBroker', () => { describe('passes mapping to service', () => { const dataBroker = new DataBroker(); - const mapping = new DataServiceMapping([ - { dataSource: DATA_ELEMENTS.DHIS_01, service_type: 'dhis', config: {} }, - ]); - const testData: [MethodUnderTest, any[]][] = [ - ['push', [{ code: 'DHIS_01', type: 'dataElement' }, [{ value: 2 }], TO_OPTIONS]], - ['delete', [{ code: 'DHIS_01', type: 'dataElement' }, { value: 2 }, TO_OPTIONS]], - ['pullMetadata', [{ code: 'DHIS_01', type: 'dataElement' }, TO_OPTIONS]], - ]; + it('passes mapping to service: push', async () => { + const expectedMapping = new DataServiceMapping([ + { dataSource: DATA_ELEMENTS.DHIS_01, service_type: 'dhis', config: {} }, + ]); - testData.forEach( - ([methodUnderTest, inputArgs]) => - it(`passes mapping to service: ${methodUnderTest}`, async () => { - await dataBroker[methodUnderTest](inputArgs[0], inputArgs[1], inputArgs[2]); - expect(SERVICES.dhis[methodUnderTest]).toHaveBeenCalledOnceWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ dataServiceMapping: mapping }), - ); - }), + await dataBroker.push({ code: 'DHIS_01', type: 'dataElement' }, [{ value: 2 }], TO_OPTIONS); + expect(SERVICES.dhis.push).toHaveBeenCalledOnceWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ dataServiceMapping: expectedMapping }), + ); + }); - it('passes mapping to service: pullAnalytics', async () => { - await dataBroker.pullAnalytics(['DHIS_01'], TO_OPTIONS); - expect(SERVICES.dhis.pullAnalytics).toHaveBeenCalledOnceWith( - expect.anything(), - expect.objectContaining({ dataServiceMapping: mapping }), - ); - }), - ); + it('passes mapping to service: delete', async () => { + const expectedMapping = new DataServiceMapping([ + { dataSource: DATA_ELEMENTS.DHIS_01, service_type: 'dhis', config: {} }, + ]); + + await dataBroker.delete({ code: 'DHIS_01', type: 'dataElement' }, { value: 2 }, TO_OPTIONS); + expect(SERVICES.dhis.delete).toHaveBeenCalledOnceWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ dataServiceMapping: expectedMapping }), + ); + }); + + it('passes mapping to service: pullAnalytics', async () => { + const expectedMapping = new DataServiceMapping([ + { dataSource: DATA_ELEMENTS.DHIS_01, service_type: 'dhis', config: {} }, + ]); + + await dataBroker.pullAnalytics(['DHIS_01'], TO_OPTIONS); + expect(SERVICES.dhis.pullAnalytics).toHaveBeenCalledOnceWith( + expect.anything(), + expect.objectContaining({ dataServiceMapping: expectedMapping }), + ); + }); + + it('passes mapping to service: pullDataElements', async () => { + const expectedMapping = new DataServiceMapping([ + { dataSource: DATA_ELEMENTS.DHIS_01, service_type: 'dhis', config: {} }, + ]); + + await dataBroker.pullDataElements(['DHIS_01'], TO_OPTIONS); + expect(SERVICES.dhis.pullMetadata).toHaveBeenCalledOnceWith( + expect.anything(), + 'dataElement', + expect.objectContaining({ dataServiceMapping: expectedMapping }), + ); + }); + + it('passes mapping to service: pullDataGroup', async () => { + const expectedMapping = new DataServiceMapping( + [], + [{ dataSource: DATA_GROUPS.DHIS_PROGRAM_01, service_type: 'dhis', config: {} }], + ); + + await dataBroker.pullDataGroup('DHIS_PROGRAM_01', TO_OPTIONS); + expect(SERVICES.dhis.pullMetadata).toHaveBeenCalledOnceWith( + expect.anything(), + 'dataGroup', + expect.objectContaining({ dataServiceMapping: expectedMapping }), + ); + }); }); describe('multiple org units pull', () => { @@ -543,29 +585,17 @@ describe('DataBroker', () => { }); }); - describe('pullMetadata()', () => { - it('throws if the data sources belong to multiple services', async () => { + describe('pullDataElements()', () => { + it('throws if the data elements belong to multiple services', async () => { const dataBroker = new DataBroker(); - await expect( - dataBroker.pullMetadata({ code: ['DHIS_01', 'TUPAIA_01'], type: 'dataElement' }), - ).toBeRejectedWith('Multiple data service types found, only a single service type expected'); - }); - - it('single data element', async () => { - const dataBroker = new DataBroker(); - await dataBroker.pullMetadata({ code: 'DHIS_01', type: 'dataElement' }, options); - - expect(createServiceMock).toHaveBeenCalledOnceWith(mockModels, 'dhis', dataBroker); - expect(SERVICES.dhis.pullMetadata).toHaveBeenCalledOnceWith( - [DATA_ELEMENTS.DHIS_01], - 'dataElement', - expect.objectContaining(options), + await expect(dataBroker.pullDataElements(['DHIS_01', 'TUPAIA_01'])).toBeRejectedWith( + 'Multiple data service types found, only a single service type expected', ); }); - it('multiple data elements', async () => { + it('pulls data elements', async () => { const dataBroker = new DataBroker(); - await dataBroker.pullMetadata({ code: ['DHIS_01', 'DHIS_02'], type: 'dataElement' }, options); + await dataBroker.pullDataElements(['DHIS_01', 'DHIS_02'], options); expect(createServiceMock).toHaveBeenCalledOnceWith(mockModels, 'dhis', dataBroker); expect(SERVICES.dhis.pullMetadata).toHaveBeenCalledOnceWith( @@ -574,20 +604,16 @@ describe('DataBroker', () => { expect.objectContaining(options), ); }); + }); - it('multiple data groups', async () => { + describe('pullDataGroup()', () => { + it('pulls data group', async () => { const dataBroker = new DataBroker(); - await dataBroker.pullMetadata( - { - code: ['DHIS_PROGRAM_01', 'DHIS_PROGRAM_02'], - type: 'dataGroup', - }, - options, - ); + await dataBroker.pullDataGroup('DHIS_PROGRAM_01', options); expect(createServiceMock).toHaveBeenCalledOnceWith(mockModels, 'dhis', dataBroker); expect(SERVICES.dhis.pullMetadata).toHaveBeenCalledOnceWith( - [DATA_GROUPS.DHIS_PROGRAM_01, DATA_GROUPS.DHIS_PROGRAM_02], + [DATA_GROUPS.DHIS_PROGRAM_01], 'dataGroup', expect.objectContaining(options), ); diff --git a/packages/data-broker/src/services/Service.ts b/packages/data-broker/src/services/Service.ts index fcf2566ab5..2fe87fa276 100644 --- a/packages/data-broker/src/services/Service.ts +++ b/packages/data-broker/src/services/Service.ts @@ -13,7 +13,6 @@ import { DataSourceType, Diagnostics, EventResults, - Metadata, SyncGroupResults, } from '../types'; import { DATA_SOURCE_TYPES } from '../utils'; @@ -82,7 +81,7 @@ export abstract class Service { dataSources: DataSource[], type: DataSourceType, options: PullMetadataOptions, - ): Promise { + ): Promise<{ code: string }[]> { return dataSources.map(ds => ({ code: ds.code })); } } diff --git a/packages/data-broker/src/types/results.ts b/packages/data-broker/src/types/results.ts index e290c69708..30eac26537 100644 --- a/packages/data-broker/src/types/results.ts +++ b/packages/data-broker/src/types/results.ts @@ -12,17 +12,19 @@ export interface Analytic { export interface DataElementMetadata { code: string; - name: string; + name?: string; } -export interface DhisMetadataObject extends DataElementMetadata { +export interface DhisMetadataObject { id: string; + code: string; + name: string; options?: Record; } export interface DataGroupMetadata { code: string; - name: string; + name?: string; dataElements?: DataElementMetadata[]; } @@ -77,8 +79,3 @@ export interface Diagnostics { errors: string[]; wasSuccessful: boolean; } - -export type Metadata = { - code: string; - [key: string]: any; // any metadata -}; diff --git a/packages/data-table-server/src/index.ts b/packages/data-table-server/src/index.ts index 60e582beae..aa904d11ff 100644 --- a/packages/data-table-server/src/index.ts +++ b/packages/data-table-server/src/index.ts @@ -3,9 +3,8 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ -import * as dotenv from 'dotenv'; - import http from 'http'; +import * as dotenv from 'dotenv'; import winston from 'winston'; import { TupaiaDatabase } from '@tupaia/database'; @@ -15,24 +14,26 @@ import { createApp } from './app'; configureWinston(); dotenv.config(); // Load the environment variables into process.env -const database = new TupaiaDatabase(); -database.configurePgGlobals(true); - -/** - * Set up app with routes etc. - */ -const app = createApp(database); - -/** - * Start the server - */ -const port = process.env.PORT || 8010; -http.createServer(app).listen(port); -winston.info(`Running on port ${port}`); - -/** - * Notify PM2 that we are ready - * */ -if (process.send) { - process.send('ready'); -} +(async () => { + const database = new TupaiaDatabase(); + database.configurePgGlobals(true); + + /** + * Set up app with routes etc. + */ + const app = createApp(database); + + /** + * Start the server + */ + const port = process.env.PORT || 8010; + http.createServer(app).listen(port); + winston.info(`Running on port ${port}`); + + /** + * Notify PM2 that we are ready + * */ + if (process.send) { + process.send('ready'); + } +})(); diff --git a/packages/database/src/__tests__/changeHandlers/AnalyticsRefresher.fixtures.js b/packages/database/src/__tests__/changeHandlers/AnalyticsRefresher.fixtures.js index aa6a4eef2f..fbd0b1d6b9 100644 --- a/packages/database/src/__tests__/changeHandlers/AnalyticsRefresher.fixtures.js +++ b/packages/database/src/__tests__/changeHandlers/AnalyticsRefresher.fixtures.js @@ -115,9 +115,20 @@ const SURVEY_RESPONSE = [ }, ]; +const PROJECT = [{ id: 'project001', code: 'P001' }]; const SURVEY = [ - { id: 'survey001_test', code: 'S001', data_group_id: 'dataGroup001_test' }, - { id: 'survey002_test', code: 'S002', data_group_id: 'dataGroup002_test' }, + { + id: 'survey001_test', + code: 'S001', + data_group_id: 'dataGroup001_test', + project_id: 'project001', + }, + { + id: 'survey002_test', + code: 'S002', + data_group_id: 'dataGroup002_test', + project_id: 'project001', + }, ]; const ENTITY = [ @@ -149,6 +160,7 @@ export const TEST_DATA = { user: USER, dataElement: DATA_ELEMENT, dataGroup: DATA_GROUP, + project: PROJECT, survey: SURVEY, question: QUESTION, surveyResponse: SURVEY_RESPONSE, diff --git a/packages/database/src/migrations/20240108015220-UpdateSurveyProjectIdToBeNonNullable-modifies-schema.js b/packages/database/src/migrations/20240108015220-UpdateSurveyProjectIdToBeNonNullable-modifies-schema.js new file mode 100644 index 0000000000..43c3d9bf2b --- /dev/null +++ b/packages/database/src/migrations/20240108015220-UpdateSurveyProjectIdToBeNonNullable-modifies-schema.js @@ -0,0 +1,33 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db.runSql(` + ALTER TABLE survey + ALTER COLUMN project_id SET NOT NULL; + `); +}; + +exports.down = function (db) { + return db.runSql(` + ALTER TABLE survey + ALTER COLUMN project_id DROP NOT NULL; +`); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/modelClasses/OneTimeLogin.js b/packages/database/src/modelClasses/OneTimeLogin.js index 66f3513b72..25699db79c 100644 --- a/packages/database/src/modelClasses/OneTimeLogin.js +++ b/packages/database/src/modelClasses/OneTimeLogin.js @@ -10,7 +10,7 @@ import { DatabaseModel } from '../DatabaseModel'; import { DatabaseType } from '../DatabaseType'; import { TYPES } from '../types'; -class OneTimeLoginType extends DatabaseType { +export class OneTimeLoginType extends DatabaseType { static databaseType = TYPES.ONE_TIME_LOGIN; isExpired() { diff --git a/packages/database/src/modelClasses/UserEntityPermission.js b/packages/database/src/modelClasses/UserEntityPermission.js index f58f10bded..e6a0064a6f 100644 --- a/packages/database/src/modelClasses/UserEntityPermission.js +++ b/packages/database/src/modelClasses/UserEntityPermission.js @@ -7,7 +7,7 @@ import { DatabaseModel } from '../DatabaseModel'; import { DatabaseType } from '../DatabaseType'; import { TYPES } from '../types'; -class UserEntityPermissionType extends DatabaseType { +export class UserEntityPermissionType extends DatabaseType { static databaseType = TYPES.USER_ENTITY_PERMISSION; static joins = [ diff --git a/packages/database/src/modelClasses/index.js b/packages/database/src/modelClasses/index.js index 7077bc7e44..86f45d159c 100644 --- a/packages/database/src/modelClasses/index.js +++ b/packages/database/src/modelClasses/index.js @@ -133,7 +133,7 @@ export { export { APIClientModel } from './APIClient'; export { ApiRequestLogModel } from './ApiRequestLog'; export { CommentModel } from './Comment'; -export { CountryModel } from './Country'; +export { CountryModel, CountryType } from './Country'; export { DhisInstanceModel, DhisInstanceType } from './DhisInstance'; export { DataElementDataServiceModel } from './DataElementDataService'; export { DataElementModel, DataElementType } from './DataElement'; @@ -159,7 +159,7 @@ export { MeditrakDeviceModel } from './MeditrakDevice'; export { MeditrakSyncQueueModel, MeditrakSyncQueueType } from './MeditrakSyncQueue'; export { OptionModel } from './Option'; export { OptionSetModel } from './OptionSet'; -export { PermissionGroupModel } from './PermissionGroup'; +export { PermissionGroupModel, PermissionGroupType } from './PermissionGroup'; export { ProjectModel, ProjectType } from './Project'; export { QuestionModel } from './Question'; export { ReportModel, ReportType } from './Report'; @@ -168,7 +168,7 @@ export { SurveyGroupModel } from './SurveyGroup'; export { SurveyScreenComponentModel } from './SurveyScreenComponent'; export { SurveyResponseModel, SurveyResponseType } from './SurveyResponse'; export { SurveyScreenModel } from './SurveyScreen'; -export { UserEntityPermissionModel } from './UserEntityPermission'; +export { UserEntityPermissionModel, UserEntityPermissionType } from './UserEntityPermission'; export { UserModel, UserType } from './User'; export { SupersetInstanceModel } from './SupersetInstance'; export { DashboardType, DashboardModel } from './Dashboard'; @@ -179,3 +179,4 @@ export { DashboardMailingListEntryModel, } from './DashboardMailingListEntry'; export { DashboardRelationType, DashboardRelationModel } from './DashboardRelation'; +export { OneTimeLoginType, OneTimeLoginModel } from './OneTimeLogin'; diff --git a/packages/database/src/testUtilities/buildAndInsertSurveys.js b/packages/database/src/testUtilities/buildAndInsertSurveys.js index c8f5e7b58d..7b3ae8c335 100644 --- a/packages/database/src/testUtilities/buildAndInsertSurveys.js +++ b/packages/database/src/testUtilities/buildAndInsertSurveys.js @@ -3,6 +3,7 @@ * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd */ +import { generateTestId } from './generateTestId'; import { findOrCreateDummyRecord } from './upsertDummyRecord'; const buildAndInsertQuestion = async ( @@ -37,6 +38,15 @@ const buildAndInsertDataGroup = async (models, fields) => { ); }; +const buildAndInsertProject = async models => { + const uniqueId = generateTestId(); + return findOrCreateDummyRecord( + models.project, + { id: uniqueId }, + { id: uniqueId, code: uniqueId }, + ); +}; + const buildAndInsertDataElement = async (models, fields) => { const { code, type, ...createFields } = fields; return findOrCreateDummyRecord( @@ -52,10 +62,12 @@ export const buildAndInsertSurvey = async ( ) => { const dataGroup = await buildAndInsertDataGroup(models, { code, ...dataSourceFields }); + const project = await buildAndInsertProject(models); + const survey = await findOrCreateDummyRecord( models.survey, { code }, - { ...surveyFields, data_group_id: dataGroup.id }, + { ...surveyFields, data_group_id: dataGroup.id, project_id: project.id }, ); const surveyScreen = await findOrCreateDummyRecord( models.surveyScreen, @@ -107,5 +119,5 @@ export const buildAndInsertSurvey = async ( * ``` */ export const buildAndInsertSurveys = async (models, surveys) => { - return Promise.all(surveys.map(async survey => buildAndInsertSurvey(models, survey))); + return Promise.all(surveys.map(survey => buildAndInsertSurvey(models, survey))); }; diff --git a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts index d9c26a672e..9795d18bf0 100644 --- a/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts +++ b/packages/datatrak-web-server/src/__tests__/processSurveyResponse.test.ts @@ -3,7 +3,7 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ import { EntityType, QuestionType } from '@tupaia/types'; -import { getBrowserTimeZone, getUniqueSurveyQuestionFileName } from '@tupaia/utils'; +import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { generateId } from '@tupaia/database'; import { processSurveyResponse } from '../routes/SubmitSurvey/processSurveyResponse'; @@ -46,6 +46,7 @@ describe('processSurveyResponse', () => { surveyId: 'theSurveyId', countryId: 'theCountryId', startTime: 'theStartTime', + timezone: 'theTimezone', }; const processedResponseData = { @@ -56,7 +57,7 @@ describe('processSurveyResponse', () => { entity_id: 'theCountryId', end_time: timestamp, timestamp: timestamp, - timezone: getBrowserTimeZone(), + timezone: 'theTimezone', options_created: [], entities_upserted: [], qr_codes_to_create: [], diff --git a/packages/datatrak-web-server/src/app/createApp.ts b/packages/datatrak-web-server/src/app/createApp.ts index 3da330a7e0..1c113e5ad9 100644 --- a/packages/datatrak-web-server/src/app/createApp.ts +++ b/packages/datatrak-web-server/src/app/createApp.ts @@ -40,6 +40,8 @@ import { ActivityFeedRoute, SingleSurveyResponseRoute, SingleSurveyResponseRequest, + GenerateLoginTokenRoute, + GenerateLoginTokenRequest, } from '../routes'; const { @@ -49,14 +51,15 @@ const { const authHandlerProvider = (req: Request) => new SessionSwitchingAuthHandler(req); -export function createApp() { - const app = new OrchestratorApiBuilder(new TupaiaDatabase(), 'datatrak-web-server', { +export async function createApp() { + const builder = new OrchestratorApiBuilder(new TupaiaDatabase(), 'datatrak-web-server', { attachModels: true, }) .useSessionModel(DataTrakSessionModel) .useAttachSession(attachSessionIfAvailable) .attachApiClientToContext(authHandlerProvider) .post('submitSurvey', handleWith(SubmitSurveyRoute)) + .post('generateLoginToken', handleWith(GenerateLoginTokenRoute)) .get('getUser', handleWith(UserRoute)) .get('entity/:projectCode/:entityCode', handleWith(SingleEntityRoute)) .get('entities', handleWith(EntitiesRoute)) @@ -71,8 +74,28 @@ export function createApp() { .get('surveyResponse/:id', handleWith(SingleSurveyResponseRoute)) .use('signup', forwardRequest(WEB_CONFIG_API_URL, { authHandlerProvider })) // Forward everything else to central server - .use('*', forwardRequest(CENTRAL_API_URL, { authHandlerProvider })) - .build(); + .use('*', forwardRequest(CENTRAL_API_URL, { authHandlerProvider })); + + await builder.initialiseApiClient([ + { entityCode: 'DL', permissionGroupName: 'Public' }, // Demo Land + { entityCode: 'FJ', permissionGroupName: 'Public' }, // Fiji + { entityCode: 'CK', permissionGroupName: 'Public' }, // Cook Islands + { entityCode: 'PG', permissionGroupName: 'Public' }, // Papua New Guinea + { entityCode: 'SB', permissionGroupName: 'Public' }, // Solomon Islands + { entityCode: 'TK', permissionGroupName: 'Public' }, // Tokelau + { entityCode: 'VE', permissionGroupName: 'Public' }, // Venezuela + { entityCode: 'WS', permissionGroupName: 'Public' }, // Samoa + { entityCode: 'KI', permissionGroupName: 'Public' }, // Kiribati + { entityCode: 'TO', permissionGroupName: 'Public' }, // Tonga + { entityCode: 'NG', permissionGroupName: 'Public' }, // Nigeria + { entityCode: 'VU', permissionGroupName: 'Public' }, // Vanuatu + { entityCode: 'AU', permissionGroupName: 'Public' }, // Australia + { entityCode: 'PW', permissionGroupName: 'Public' }, // Palau + { entityCode: 'NU', permissionGroupName: 'Public' }, // Niue + { entityCode: 'TV', permissionGroupName: 'Public' }, // Tuvalu + ]); + + const app = builder.build(); return app; } diff --git a/packages/datatrak-web-server/src/index.ts b/packages/datatrak-web-server/src/index.ts index 9d0f4c414b..3c3ff27671 100644 --- a/packages/datatrak-web-server/src/index.ts +++ b/packages/datatrak-web-server/src/index.ts @@ -14,22 +14,23 @@ import { createApp } from './app'; configureWinston(); dotenv.config(); // Load the environment variables into process.env -/** - * Set up app with routes etc. - */ -const app = createApp(); - -/** - * Start the server - */ -const port = process.env.PORT || 8110; -http.createServer(app).listen(port); -winston.info(`Running on port ${port}`); - -/** - * Notify PM2 that we are ready - * */ -if (process.send) { - process.send('ready'); -} - +(async () => { + /** + * Set up app with routes etc. + */ + const app = await createApp(); + + /** + * Start the server + */ + const port = process.env.PORT || 8110; + http.createServer(app).listen(port); + winston.info(`Running on port ${port}`); + + /** + * Notify PM2 that we are ready + * */ + if (process.send) { + process.send('ready'); + } +})(); diff --git a/packages/datatrak-web-server/src/routes/GenerateLoginTokenRoute.ts b/packages/datatrak-web-server/src/routes/GenerateLoginTokenRoute.ts new file mode 100644 index 0000000000..16d264d3cb --- /dev/null +++ b/packages/datatrak-web-server/src/routes/GenerateLoginTokenRoute.ts @@ -0,0 +1,28 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { Request } from 'express'; +import { Route } from '@tupaia/server-boilerplate'; +import { DatatrakWebGenerateLoginTokenRequest as RequestT } from '@tupaia/types'; + +export type GenerateLoginTokenRequest = Request< + RequestT.Params, + RequestT.ResBody, + RequestT.ReqBody, + RequestT.ReqQuery +>; + +export class GenerateLoginTokenRoute extends Route { + public async buildResponse() { + const { ctx, session } = this.req; + if (!session) { + throw new Error('Cannot generate login token without known user'); + } + + const { id: userId } = await ctx.services.central.getUser(); + const oneTimeLogin = await this.req.models.oneTimeLogin.create({ user_id: userId }); + return { token: oneTimeLogin.token }; + } +} diff --git a/packages/datatrak-web-server/src/routes/ProjectRoute.ts b/packages/datatrak-web-server/src/routes/ProjectRoute.ts index 4b8e3d37c4..5add320054 100644 --- a/packages/datatrak-web-server/src/routes/ProjectRoute.ts +++ b/packages/datatrak-web-server/src/routes/ProjectRoute.ts @@ -4,7 +4,7 @@ */ import { Request } from 'express'; -import { Route } from '@tupaia/server-boilerplate'; +import { Route } from '@tupaia/server-boilerplate'; import { WebServerProjectRequest } from '@tupaia/types'; export type ProjectRequest = Request< diff --git a/packages/datatrak-web-server/src/routes/ProjectsRoute.ts b/packages/datatrak-web-server/src/routes/ProjectsRoute.ts index 09b9970601..f188d4e684 100644 --- a/packages/datatrak-web-server/src/routes/ProjectsRoute.ts +++ b/packages/datatrak-web-server/src/routes/ProjectsRoute.ts @@ -19,9 +19,7 @@ type ProjectT = DatatrakWebProjectsRequest.ResBody[0]; export class ProjectsRoute extends Route { public async buildResponse() { const { ctx } = this.req; - const { projects } = await ctx.services.webConfig.fetchProjects({ - showExcludedProjects: true, - }); + const { projects } = await ctx.services.webConfig.fetchProjects(); // Sort projects alphabetically. Sorting is not supported by the API so we do it here. return projects.sort((a: ProjectT, b: ProjectT) => a.name.localeCompare(b.name)); diff --git a/packages/datatrak-web-server/src/routes/SubmitSurvey/processSurveyResponse.ts b/packages/datatrak-web-server/src/routes/SubmitSurvey/processSurveyResponse.ts index 0f1eb97f2c..5e513f5ff7 100644 --- a/packages/datatrak-web-server/src/routes/SubmitSurvey/processSurveyResponse.ts +++ b/packages/datatrak-web-server/src/routes/SubmitSurvey/processSurveyResponse.ts @@ -2,7 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { getBrowserTimeZone, getUniqueSurveyQuestionFileName } from '@tupaia/utils'; +import { getUniqueSurveyQuestionFileName } from '@tupaia/utils'; import { DatatrakWebSubmitSurveyRequest, Entity, @@ -39,8 +39,9 @@ export const processSurveyResponse = async ( questions = [], answers = {}, startTime, + timezone, } = surveyResponseData; - const timezone = getBrowserTimeZone(); + const today = new Date(); const timestamp = today.toISOString(); // Fields to be used in the survey response diff --git a/packages/datatrak-web-server/src/routes/index.ts b/packages/datatrak-web-server/src/routes/index.ts index 5a695eca71..759aa8bf2c 100644 --- a/packages/datatrak-web-server/src/routes/index.ts +++ b/packages/datatrak-web-server/src/routes/index.ts @@ -19,3 +19,4 @@ export { } from './SingleSurveyResponseRoute'; export { LeaderboardRequest, LeaderboardRoute } from './LeaderboardRoute'; export { ActivityFeedRequest, ActivityFeedRoute } from './ActivityFeedRoute'; +export { GenerateLoginTokenRequest, GenerateLoginTokenRoute } from './GenerateLoginTokenRoute'; diff --git a/packages/datatrak-web-server/src/types.ts b/packages/datatrak-web-server/src/types.ts index 943902318d..c8607182df 100644 --- a/packages/datatrak-web-server/src/types.ts +++ b/packages/datatrak-web-server/src/types.ts @@ -3,15 +3,17 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import { ModelRegistry, EntityModel, EntityType as BaseEntityType } from '@tupaia/database'; +import { ModelRegistry, EntityModel, EntityType as BaseEntityType, OneTimeLoginModel, OneTimeLoginType as BaseOneTimeLoginType } from '@tupaia/database'; import { Model } from '@tupaia/server-boilerplate'; -import { Entity } from '@tupaia/types'; +import { Entity, OneTimeLogin } from '@tupaia/types'; import { FeedItemModel, SurveyResponseModel } from './models'; export type EntityType = BaseEntityType & Entity; +export type OneTimeLoginType = BaseOneTimeLoginType & OneTimeLogin; export interface DatatrakWebServerModelRegistry extends ModelRegistry { readonly entity: Model; readonly surveyResponse: SurveyResponseModel; readonly feedItem: FeedItemModel; + readonly oneTimeLogin: Model; } diff --git a/packages/datatrak-web/index.html b/packages/datatrak-web/index.html index 64b1bf61e2..a4fabd2eef 100644 --- a/packages/datatrak-web/index.html +++ b/packages/datatrak-web/index.html @@ -3,6 +3,7 @@ + Tupaia DataTrak @@ -37,17 +38,36 @@ <% } %> + + +