From 53cc39b5ee88144950803840a0fb699e7a4c4579 Mon Sep 17 00:00:00 2001 From: jeznorth Date: Mon, 11 Sep 2023 13:50:00 -0700 Subject: [PATCH 1/9] SIMSBIOHUB-259: General UI Fixes / Clean-up (#1088) Front-end polish and content updates --------- Co-authored-by: Alfred Rosenthal --- app/src/features/surveys/CreateSurveyPage.tsx | 4 +- .../features/surveys/components/BlockForm.tsx | 32 ++++----- .../components/CreateSurveyBlockDialog.tsx | 37 ---------- .../components/EditSurveyBlockDialog.tsx | 49 +------------ .../surveys/components/SurveyBlockSection.tsx | 72 +++++++++++-------- .../surveys/components/SurveyUserForm.tsx | 4 +- .../features/surveys/edit/EditSurveyForm.tsx | 4 +- 7 files changed, 65 insertions(+), 137 deletions(-) diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 4d65d2a87c..11cc7a80fa 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -380,8 +380,8 @@ const CreateSurveyPage = () => { } /> diff --git a/app/src/features/surveys/components/BlockForm.tsx b/app/src/features/surveys/components/BlockForm.tsx index bb1e68b7b8..cc2492057f 100644 --- a/app/src/features/surveys/components/BlockForm.tsx +++ b/app/src/features/surveys/components/BlockForm.tsx @@ -1,4 +1,3 @@ -import Typography from '@mui/material/Typography'; import { Box } from '@mui/system'; import CustomTextField from 'components/fields/CustomTextField'; import React from 'react'; @@ -12,25 +11,20 @@ export interface IBlockData { const BlockForm: React.FC = () => { return (
- - - Name and Description - - - - - + + + ); }; diff --git a/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx b/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx index 7069429f4d..0d9a5a6fb2 100644 --- a/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx @@ -1,9 +1,4 @@ -import CloseIcon from '@mui/icons-material/Close'; -import { Typography } from '@mui/material'; -import IconButton from '@mui/material/IconButton'; -import Snackbar from '@mui/material/Snackbar'; import EditDialog from 'components/dialog/EditDialog'; -import { useState } from 'react'; import BlockForm from './BlockForm'; import { BlockYupSchema } from './SurveyBlockSection'; interface ICreateBlockProps { @@ -14,15 +9,10 @@ interface ICreateBlockProps { const CreateSurveyBlockDialog: React.FC = (props) => { const { open, onSave, onClose } = props; - const [isSnackBarOpen, setIsSnackBarOpen] = useState(false); - const [blockName, setBlockName] = useState(''); return ( <> = (props) => { dialogSaveButtonLabel="Add Block" onCancel={() => onClose()} onSave={(formValues) => { - setBlockName(formValues.name); - setIsSnackBarOpen(true); onSave(formValues); }} /> - - { - setIsSnackBarOpen(false); - setBlockName(''); - }} - message={ - <> - - Block {blockName} has been added. - - - } - action={ - setIsSnackBarOpen(false)}> - - - } - /> ); }; diff --git a/app/src/features/surveys/components/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/EditSurveyBlockDialog.tsx index 570c03020a..fbe3ebf660 100644 --- a/app/src/features/surveys/components/EditSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/EditSurveyBlockDialog.tsx @@ -1,9 +1,4 @@ -import CloseIcon from '@mui/icons-material/Close'; -import { Typography } from '@mui/material'; -import IconButton from '@mui/material/IconButton'; -import Snackbar from '@mui/material/Snackbar'; import EditDialog from 'components/dialog/EditDialog'; -import { useState } from 'react'; import BlockForm from './BlockForm'; import { BlockYupSchema, IEditBlock } from './SurveyBlockSection'; @@ -16,15 +11,10 @@ interface IEditBlockProps { const EditSurveyBlockDialog: React.FC = (props) => { const { open, initialData, onSave, onClose } = props; - const [isSnackBarOpen, setIsSnackBarOpen] = useState(false); - const [blockName, setBlockName] = useState(''); return ( <> = (props) => { }} dialogSaveButtonLabel="Save" onCancel={() => { - setBlockName(''); - setIsSnackBarOpen(true); onClose(); }} onSave={(formValues) => { - setBlockName(formValues.name); - setIsSnackBarOpen(true); onSave(formValues, initialData?.index); }} /> - - { - setIsSnackBarOpen(false); - setBlockName(''); - }} - message={ - <> - - {initialData?.block.survey_block_id ? ( - <> - Block {blockName} has been updated. - - ) : ( - <> - Block {blockName} has been added. - - )} - - - } - action={ - setIsSnackBarOpen(false)}> - - - } - /> ); }; diff --git a/app/src/features/surveys/components/SurveyBlockSection.tsx b/app/src/features/surveys/components/SurveyBlockSection.tsx index cfb194e0b9..01bf12b6d2 100644 --- a/app/src/features/surveys/components/SurveyBlockSection.tsx +++ b/app/src/features/surveys/components/SurveyBlockSection.tsx @@ -2,14 +2,16 @@ import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { ListItemIcon, Menu, MenuItem, MenuProps, Typography } from '@mui/material'; -import Box from '@mui/material/Box'; +// import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Card from '@mui/material/Card'; import CardHeader from '@mui/material/CardHeader'; +import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; import { useFormikContext } from 'formik'; import { ICreateSurveyRequest } from 'interfaces/useSurveyApi.interface'; import React, { useState } from 'react'; +import { TransitionGroup } from 'react-transition-group'; import yup from 'utils/YupSchema'; import CreateSurveyBlockDialog from './CreateSurveyBlockDialog'; import EditSurveyBlockDialog from './EditSurveyBlockDialog'; @@ -20,8 +22,8 @@ export const SurveyBlockInitialValues = { // Form validation for Block Item export const BlockYupSchema = yup.object({ - name: yup.string().required().max(50, 'Maximum 50 characters'), - description: yup.string().required().max(250, 'Maximum 250 characters') + name: yup.string().required('Name is required').max(50, 'Maximum 50 characters'), + description: yup.string().required('Description is required').max(250, 'Maximum 250 characters') }); export const SurveyBlockYupSchema = yup.array(BlockYupSchema); @@ -92,16 +94,19 @@ const SurveyBlockSection: React.FC = () => { sx={{ marginBottom: '14px' }}> - Define Blocks + Define Blocks{' '} + + (optional) + - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam at porttitor sem. Aliquam erat volutpat. Donec - placerat nisl magna, et faucibus arcu condimentum sed. + Enter a name and description for each block used in this survey. {
+ + {values.blocks.map((item, index) => { + return ( + + + ) => + handleMenuClick(event, index) + } + aria-label="settings"> + + + } + title={item.name} + subheader={item.description} + /> + + + ); + })} + - - {values.blocks.map((item, index) => { - return ( - - ) => - handleMenuClick(event, index) - } - aria-label="settings"> - - - } - title={item.name} - subheader={item.description} - /> - - ); - })} -
); diff --git a/app/src/features/surveys/components/SurveyUserForm.tsx b/app/src/features/surveys/components/SurveyUserForm.tsx index 178f39a621..dad7fc5d29 100644 --- a/app/src/features/surveys/components/SurveyUserForm.tsx +++ b/app/src/features/surveys/components/SurveyUserForm.tsx @@ -146,7 +146,9 @@ const SurveyUserForm: React.FC = (props) => {
{errors?.['participants'] && selectedUsers.length > 0 && ( - + + + )} = (props) => { } /> From 0106937b88aaec7fddca0065e7e21ad4e71b9d2f Mon Sep 17 00:00:00 2001 From: Kjartan <35311998+KjartanE@users.noreply.github.com> Date: Mon, 11 Sep 2023 14:22:34 -0700 Subject: [PATCH 2/9] SIMSBIOHUB-194: Seed SIMS with Users from SPI (#1086) Added service account capability to endpoints, to allow a script to utilize the SIMS SYSTEM account to insert a collected list of users. DB migration was required to insert new user role for SYSTEM and a SIMS service account --- api/src/__mocks__/db.ts | 15 + api/src/app.ts | 16 +- api/src/constants/database.ts | 18 +- api/src/database/db-utils.test.ts | 116 +-- api/src/database/db-utils.ts | 77 -- api/src/database/db.test.ts | 7 +- api/src/database/db.ts | 151 ++-- api/src/models/index.ts | 1 + api/src/models/models.ts | 1 + api/src/models/survey-view.test.ts | 4 +- api/src/models/survey-view.ts | 4 +- api/src/models/user/index.ts | 1 + api/src/models/user/user.test.ts | 82 +++ api/src/models/user/user.ts | 19 + api/src/paths/administrative-activity.ts | 10 +- .../attachments/{attachmentId}/delete.ts | 4 +- .../paths/project/{projectId}/survey/list.ts | 4 +- .../attachments/{attachmentId}/delete.ts | 7 +- api/src/paths/user/add.test.ts | 102 +-- api/src/paths/user/add.ts | 73 +- .../database/user-context-queries.test.ts | 42 ++ .../queries/database/user-context-queries.ts | 15 + api/src/repositories/user-repository.ts | 36 +- .../security/authentication.ts | 19 +- .../security/authorization.test.ts | 694 +----------------- .../security/authorization.ts | 382 +--------- .../services/authorization-service.test.ts | 554 ++++++++++++++ api/src/services/authorization-service.ts | 323 ++++++++ api/src/services/user-service.ts | 34 +- api/src/utils/keycloak-utils.test.ts | 625 ++-------------- api/src/utils/keycloak-utils.ts | 198 ++--- ...0230905000000_update_user_source_system.ts | 25 + 32 files changed, 1471 insertions(+), 2188 deletions(-) create mode 100644 api/src/models/index.ts create mode 100644 api/src/models/models.ts create mode 100644 api/src/models/user/index.ts create mode 100644 api/src/models/user/user.test.ts create mode 100644 api/src/models/user/user.ts create mode 100644 api/src/queries/database/user-context-queries.test.ts create mode 100644 api/src/queries/database/user-context-queries.ts create mode 100644 api/src/services/authorization-service.test.ts create mode 100644 api/src/services/authorization-service.ts create mode 100644 database/src/migrations/20230905000000_update_user_source_system.ts diff --git a/api/src/__mocks__/db.ts b/api/src/__mocks__/db.ts index 17c30e9247..752f61fe0a 100644 --- a/api/src/__mocks__/db.ts +++ b/api/src/__mocks__/db.ts @@ -1,8 +1,23 @@ import { Request, Response } from 'express'; import { QueryResult } from 'pg'; import sinon from 'sinon'; +import * as db from '../database/db'; import { IDBConnection } from '../database/db'; +/** + * Registers and returns a mock `IDBConnection` with empty methods. + * + * @param {Partial} [config] Initial method overrides + * @return {*} {IDBConnection} + */ +export const registerMockDBConnection = (config?: Partial): IDBConnection => { + const mockDBConnection = getMockDBConnection(config); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + return mockDBConnection; +}; + /** * Returns a mock `IDBConnection` with empty methods. * diff --git a/api/src/app.ts b/api/src/app.ts index 8874216c72..39e7445c43 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -6,7 +6,7 @@ import swaggerUIExperss from 'swagger-ui-express'; import { defaultPoolConfig, initDBPool } from './database/db'; import { ensureHTTPError, HTTPErrorType } from './errors/http-error'; import { rootAPIDoc } from './openapi/root-api-doc'; -import { authenticateRequest } from './request-handlers/security/authentication'; +import { authenticateRequest, authenticateRequestOptional } from './request-handlers/security/authentication'; import { getLogger } from './utils/logger'; const defaultLog = getLogger('app'); @@ -26,7 +26,7 @@ const app: express.Express = express(); // Enable CORS app.use(function (req: Request, res: Response, next: NextFunction) { - defaultLog.info({ label: 'req', message: `${req.method} ${req.url}` }); + defaultLog.info(`${req.method} ${req.url}`); res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Authorization, responseType'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE, HEAD'); @@ -75,9 +75,13 @@ const openAPIFramework = initialize({ 'application/x-www-form-urlencoded': express.urlencoded({ limit: MAX_REQ_BODY_SIZE, extended: true }) }, securityHandlers: { - // authenticates the request bearer token, for endpoints that specify `Bearer` security Bearer: async function (req: any) { + // authenticates the request bearer token, for endpoints that specify `Bearer` security return authenticateRequest(req); + }, + OptionalBearer: async function (req: any) { + // authenticates the request bearer token, if one exists, for endpoints that specify `OptionalBearer` security + return authenticateRequestOptional(req); } }, errorTransformer: function (openapiError: object, ajvError: object): object { @@ -88,14 +92,14 @@ const openAPIFramework = initialize({ // If `next` is not included express will silently skip calling the `errorMiddleware` entirely. // eslint-disable-next-line @typescript-eslint/no-unused-vars errorMiddleware: function (error, req, res, next) { + // Ensure all errors (intentionally thrown or not) are in the same format as specified by the schema + const httpError = ensureHTTPError(error); + if (res.headersSent) { // response has already been sent return; } - // Ensure all errors (intentionally thrown or not) are in the same format as specified by the schema - const httpError = ensureHTTPError(error); - res .status(httpError.status) .json({ name: httpError.name, status: httpError.status, message: httpError.message, errors: httpError.errors }); diff --git a/api/src/constants/database.ts b/api/src/constants/database.ts index 3cc31dbd75..0eb06aef26 100644 --- a/api/src/constants/database.ts +++ b/api/src/constants/database.ts @@ -9,10 +9,26 @@ export enum SYSTEM_IDENTITY_SOURCE { IDIR = 'IDIR', BCEID_BASIC = 'BCEIDBASIC', BCEID_BUSINESS = 'BCEIDBUSINESS', - UNVERIFIED = 'UNVERIFIED' + UNVERIFIED = 'UNVERIFIED', + SYSTEM = 'SYSTEM' } export enum SCHEMAS { API = 'BIOHUB_DAPI_V1', DATA = 'BIOHUB' } + +/** + * The source system of a DwCA data set submission. + * + * Typically an external system that is participating in BioHub by submitting data to the BioHub Platform Backbone. + * + * Sources are based on the client id of the keycloak service account the participating system uses to authenticate with + * the BioHub Platform Backbone. + * + * @export + * @enum {number} + */ +export enum SOURCE_SYSTEM { + 'SIMS-SVC-4464' = 'SIMS-SVC-4464' +} diff --git a/api/src/database/db-utils.test.ts b/api/src/database/db-utils.test.ts index 65a8f12d75..a73408bf4c 100644 --- a/api/src/database/db-utils.test.ts +++ b/api/src/database/db-utils.test.ts @@ -1,15 +1,6 @@ -import { expect } from 'chai'; import { QueryResult } from 'pg'; -import sinon from 'sinon'; import { z } from 'zod'; -import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; -import { - BceidBasicUserInformation, - BceidBusinessUserInformation, - DatabaseUserInformation, - IdirUserInformation -} from '../utils/keycloak-utils'; -import { getGenericizedKeycloakUserInformation, getZodQueryResult } from './db-utils'; +import { getZodQueryResult } from './db-utils'; /** * Enforces that a zod schema satisfies an existing type definition. @@ -58,110 +49,5 @@ describe('getZodQueryResult', () => { // Not a traditional test: will just cause a compile error if the zod schema doesn't satisfy the `QueryResult` type zodImplements().with(zodQueryResult.shape); - - // Dummy assertion to satisfy linter - expect(true).to.be.true; - }); -}); - -describe('getGenericizedKeycloakUserInformation', () => { - afterEach(() => { - sinon.restore(); - }); - - it('identifies a database user information object and returns null', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const result = getGenericizedKeycloakUserInformation(keycloakUserInformation); - - expect(result).to.be.null; - }); - - it('identifies an idir user information object and returns a genericized object', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'testuser', - email_verified: false, - name: 'test user', - preferred_username: 'testguid@idir', - display_name: 'test user', - given_name: 'test', - family_name: 'user', - email: 'email@email.com' - }; - - const result = getGenericizedKeycloakUserInformation(keycloakUserInformation); - - expect(result).to.eql({ - user_guid: keycloakUserInformation.idir_user_guid, - user_identifier: keycloakUserInformation.idir_username, - user_identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, - display_name: keycloakUserInformation.display_name, - email: keycloakUserInformation.email, - given_name: keycloakUserInformation.given_name, - family_name: keycloakUserInformation.family_name - }); - }); - - it('identifies a bceid business user information object and returns a genericized object', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const result = getGenericizedKeycloakUserInformation(keycloakUserInformation); - - expect(result).to.eql({ - user_guid: keycloakUserInformation.bceid_user_guid, - user_identifier: keycloakUserInformation.bceid_username, - user_identity_source: SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS, - display_name: keycloakUserInformation.display_name, - email: keycloakUserInformation.email, - given_name: keycloakUserInformation.given_name, - family_name: keycloakUserInformation.family_name, - agency: keycloakUserInformation.bceid_business_name - }); - }); - - it('identifies a bceid basic user information object and returns a genericized object', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const result = getGenericizedKeycloakUserInformation(keycloakUserInformation); - - expect(result).to.eql({ - user_guid: keycloakUserInformation.bceid_user_guid, - user_identifier: keycloakUserInformation.bceid_username, - user_identity_source: SYSTEM_IDENTITY_SOURCE.BCEID_BASIC, - display_name: keycloakUserInformation.display_name, - email: keycloakUserInformation.email, - given_name: keycloakUserInformation.given_name, - family_name: keycloakUserInformation.family_name - }); }); }); diff --git a/api/src/database/db-utils.ts b/api/src/database/db-utils.ts index f6f1030d4e..d010e8b70d 100644 --- a/api/src/database/db-utils.ts +++ b/api/src/database/db-utils.ts @@ -1,26 +1,5 @@ import { z } from 'zod'; -import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { - isBceidBusinessUserInformation, - isDatabaseUserInformation, - isIdirUserInformation, - KeycloakUserInformation -} from '../utils/keycloak-utils'; - -/** - * A type for a set of generic keycloak user information properties. - */ -type GenericizedKeycloakUserInformation = { - user_guid: string; - user_identifier: string; - user_identity_source: SYSTEM_IDENTITY_SOURCE; - display_name: string; - email: string; - given_name: string; - family_name: string; - agency?: string; -}; /** * An asynchronous wrapper function that will catch any exceptions thrown by the wrapped function @@ -102,59 +81,3 @@ export const getZodQueryResult = (zodQueryResultRow: T) => }) ) }); - -/** - * Converts a type specific keycloak user information object with type specific properties into a new object with - * generic properties. - * - * @param {KeycloakUserInformation} keycloakUserInformation - * @return {*} {(GenericizedKeycloakUserInformation | null)} - */ -export const getGenericizedKeycloakUserInformation = ( - keycloakUserInformation: KeycloakUserInformation -): GenericizedKeycloakUserInformation | null => { - let data: GenericizedKeycloakUserInformation | null; - - if (isDatabaseUserInformation(keycloakUserInformation)) { - // Don't patch internal database user records - return null; - } - - // We don't yet know at this point what kind of token was used (idir vs bceid basic, etc). - // Determine which type it is, and parse the information into a generic structure that is supported by the - // database patch function - if (isIdirUserInformation(keycloakUserInformation)) { - data = { - user_guid: keycloakUserInformation.idir_user_guid, - user_identifier: keycloakUserInformation.idir_username, - user_identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, - display_name: keycloakUserInformation.display_name, - email: keycloakUserInformation.email, - given_name: keycloakUserInformation.given_name, - family_name: keycloakUserInformation.family_name - }; - } else if (isBceidBusinessUserInformation(keycloakUserInformation)) { - data = { - user_guid: keycloakUserInformation.bceid_user_guid, - user_identifier: keycloakUserInformation.bceid_username, - user_identity_source: SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS, - display_name: keycloakUserInformation.display_name, - email: keycloakUserInformation.email, - given_name: keycloakUserInformation.given_name, - family_name: keycloakUserInformation.family_name, - agency: keycloakUserInformation.bceid_business_name - }; - } else { - data = { - user_guid: keycloakUserInformation.bceid_user_guid, - user_identifier: keycloakUserInformation.bceid_username, - user_identity_source: SYSTEM_IDENTITY_SOURCE.BCEID_BASIC, - display_name: keycloakUserInformation.display_name, - email: keycloakUserInformation.email, - given_name: keycloakUserInformation.given_name, - family_name: keycloakUserInformation.family_name - }; - } - - return data; -}; diff --git a/api/src/database/db.test.ts b/api/src/database/db.test.ts index 79fa0921d6..8199a8282a 100644 --- a/api/src/database/db.test.ts +++ b/api/src/database/db.test.ts @@ -375,12 +375,9 @@ describe('db', () => { getAPIUserDBConnection(); - const DB_USERNAME = process.env.DB_USER_API; - expect(getDBConnectionStub).to.have.been.calledWith({ - database_user_guid: DB_USERNAME, - identity_provider: SYSTEM_IDENTITY_SOURCE.DATABASE.toLowerCase(), - username: DB_USERNAME + preferred_username: `undefined@${SYSTEM_IDENTITY_SOURCE.DATABASE}`, + identity_provider: SYSTEM_IDENTITY_SOURCE.DATABASE }); }); }); diff --git a/api/src/database/db.ts b/api/src/database/db.ts index 24f39dd6f7..ca5845a979 100644 --- a/api/src/database/db.ts +++ b/api/src/database/db.ts @@ -1,43 +1,34 @@ import knex, { Knex } from 'knex'; import * as pg from 'pg'; -import SQL, { SQLStatement } from 'sql-template-strings'; +import { SQLStatement } from 'sql-template-strings'; import { z } from 'zod'; -import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; +import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../constants/database'; import { ApiExecuteSQLError, ApiGeneralError } from '../errors/api-error'; -import { - getKeycloakUserInformationFromKeycloakToken, - getUserGuid, - getUserIdentitySource, - KeycloakUserInformation -} from '../utils/keycloak-utils'; +import * as UserQueries from '../queries/database/user-context-queries'; +import { getUserGuid, getUserIdentitySource } from '../utils/keycloak-utils'; import { getLogger } from '../utils/logger'; -import { - asyncErrorWrapper, - getGenericizedKeycloakUserInformation, - getZodQueryResult, - syncErrorWrapper -} from './db-utils'; +import { asyncErrorWrapper, getZodQueryResult, syncErrorWrapper } from './db-utils'; + +export const DB_CLIENT = 'pg'; const defaultLog = getLogger('database/db'); -const getDbHost = () => process.env.DB_HOST; -const getDbPort = () => Number(process.env.DB_PORT); -const getDbUsername = () => process.env.DB_USER_API; -const getDbPassword = () => process.env.DB_USER_API_PASS; -const getDbDatabase = () => process.env.DB_DATABASE; +const DB_HOST = process.env.DB_HOST; +const DB_PORT = Number(process.env.DB_PORT); +const DB_USERNAME = process.env.DB_USER_API; +const DB_PASSWORD = process.env.DB_USER_API_PASS; +const DB_DATABASE = process.env.DB_DATABASE; const DB_POOL_SIZE: number = Number(process.env.DB_POOL_SIZE) || 20; const DB_CONNECTION_TIMEOUT: number = Number(process.env.DB_CONNECTION_TIMEOUT) || 0; const DB_IDLE_TIMEOUT: number = Number(process.env.DB_IDLE_TIMEOUT) || 10000; -export const DB_CLIENT = 'pg'; - export const defaultPoolConfig: pg.PoolConfig = { - user: getDbUsername(), - password: getDbPassword(), - database: getDbDatabase(), - port: getDbPort(), - host: getDbHost(), + user: DB_USERNAME, + password: DB_PASSWORD, + database: DB_DATABASE, + port: DB_PORT, + host: DB_HOST, max: DB_POOL_SIZE, connectionTimeoutMillis: DB_CONNECTION_TIMEOUT, idleTimeoutMillis: DB_IDLE_TIMEOUT @@ -133,11 +124,9 @@ export interface IDBConnection { /** * Performs a query against this connection, returning the results. * - * @param {string} text SQL text - * @param {any[]} [values] SQL values array (optional) + * @param {SQLStatement} sqlStatement SQL statement object * @return {*} {(Promise>)} * @throws If the connection is not open. - * @deprecated Prefer using `.sql` (pass entire statement object) or `.knex` (pass quiery builder object) * @memberof IDBConnection */ query: (text: string, values?: any[]) => Promise>; @@ -366,87 +355,36 @@ export const getDBConnection = function (keycloakToken: object): IDBConnection { /** * Set the user context. * - * Sets the `_systemUserId` if successful. - * - * @return {*} {Promise} + * Sets the _systemUserId if successful. */ - const _setUserContext = async (): Promise => { - const keycloakUserInformation = getKeycloakUserInformationFromKeycloakToken(_token); + const _setUserContext = async () => { + const userGuid = getUserGuid(_token); - if (!keycloakUserInformation) { + const userIdentitySource = getUserIdentitySource(_token); + + if (!userGuid || !userIdentitySource) { throw new ApiGeneralError('Failed to identify authenticated user'); } - defaultLog.silly({ label: '_setUserContext', keycloakUserInformation }); + // Set the user context for all queries made using this connection + const setSystemUserContextSQLStatement = UserQueries.setSystemUserContextSQL(userGuid, userIdentitySource); - // Update the logged in user with their latest information from Keyclaok (if it has changed) - await _updateSystemUserInformation(keycloakUserInformation); + if (!setSystemUserContextSQLStatement) { + throw new ApiExecuteSQLError('Failed to build SQL user context statement'); + } try { - // Set the user context in the database, so database queries are aware of the calling user when writing to audit - // tables, etc. - _systemUserId = await _setSystemUserContext( - getUserGuid(keycloakUserInformation), - getUserIdentitySource(keycloakUserInformation) + const response = await _client.query( + setSystemUserContextSQLStatement.text, + setSystemUserContextSQLStatement.values ); + + _systemUserId = response?.rows?.[0].api_set_context; } catch (error) { throw new ApiExecuteSQLError('Failed to set user context', [error as object]); } }; - /** - * Update a system user's record with the latest information from a verified Keycloak token. - * - * Note: Does nothing if the user is an internal database user. - * - * @param {KeycloakUserInformation} keycloakUserInformation - * @return {*} {Promise} - */ - const _updateSystemUserInformation = async (keycloakUserInformation: KeycloakUserInformation): Promise => { - const data = getGenericizedKeycloakUserInformation(keycloakUserInformation); - - if (!data) { - return; - } - - const patchSystemUserSQLStatement = SQL` - SELECT api_patch_system_user( - ${data.user_guid}, - ${data.user_identifier}, - ${data.user_identity_source}, - ${data.email}, - ${data.display_name}, - ${data.given_name || null}, - ${data.family_name || null}, - ${data.agency || null} - ) - `; - - await _client.query(patchSystemUserSQLStatement.text, patchSystemUserSQLStatement.values); - }; - - /** - * Set the user context for all queries made using this connection. - * - * This is necessary in order for the database audit triggers to function properly. - * - * @param {string} userGuid - * @param {SYSTEM_IDENTITY_SOURCE} userIdentitySource - * @return {*} - */ - const _setSystemUserContext = async (userGuid: string, userIdentitySource: SYSTEM_IDENTITY_SOURCE) => { - const setSystemUserContextSQLStatement = SQL` - SELECT api_set_context(${userGuid}, ${userIdentitySource}); - `; - - const response = await _client.query( - setSystemUserContextSQLStatement.text, - setSystemUserContextSQLStatement.values - ); - - return response?.rows?.[0].api_set_context; - }; - return { open: asyncErrorWrapper(_open), query: asyncErrorWrapper(_query), @@ -469,9 +407,24 @@ export const getDBConnection = function (keycloakToken: object): IDBConnection { */ export const getAPIUserDBConnection = (): IDBConnection => { return getDBConnection({ - database_user_guid: getDbUsername(), - identity_provider: SYSTEM_IDENTITY_SOURCE.DATABASE.toLowerCase(), - username: getDbUsername() + preferred_username: `${DB_USERNAME}@${SYSTEM_IDENTITY_SOURCE.DATABASE}`, + identity_provider: SYSTEM_IDENTITY_SOURCE.DATABASE + }); +}; + +/** + * Returns an IDBConnection where the system user context is set to a service client user. + * + * Note: Use of this should be limited to requests that are sent by an external system that is participating in BioHub + * by submitting data to the BioHub Platform Backbone. + * + * @param {SOURCE_SYSTEM} sourceSystem + * @return {*} {IDBConnection} + */ +export const getServiceAccountDBConnection = (sourceSystem: SOURCE_SYSTEM): IDBConnection => { + return getDBConnection({ + preferred_username: `${sourceSystem}@${SYSTEM_IDENTITY_SOURCE.SYSTEM}`, + identity_provider: SYSTEM_IDENTITY_SOURCE.SYSTEM }); }; diff --git a/api/src/models/index.ts b/api/src/models/index.ts new file mode 100644 index 0000000000..44b3ae674e --- /dev/null +++ b/api/src/models/index.ts @@ -0,0 +1 @@ +export * as Models from './models'; diff --git a/api/src/models/models.ts b/api/src/models/models.ts new file mode 100644 index 0000000000..faa2ca1ef7 --- /dev/null +++ b/api/src/models/models.ts @@ -0,0 +1 @@ +export * as user from './user'; diff --git a/api/src/models/survey-view.test.ts b/api/src/models/survey-view.test.ts index beb9dac938..573d2cc730 100644 --- a/api/src/models/survey-view.test.ts +++ b/api/src/models/survey-view.test.ts @@ -26,11 +26,11 @@ describe('GetSurveyData', () => { }); it('sets end_date', () => { - expect(data.end_date).to.equal(null); + expect(data.end_date).to.equal('undefined'); }); it('sets start_date', () => { - expect(data.start_date).to.equal(null); + expect(data.start_date).to.equal('undefined'); }); it('sets geojson', () => { diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index 5358b3c3a0..a2b77962b7 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -41,8 +41,8 @@ export class GetSurveyData { this.project_id = obj?.project_id || null; this.uuid = obj?.uuid || null; this.survey_name = obj?.name || ''; - this.start_date = obj?.start_date || null; - this.end_date = obj?.end_date || null; + this.start_date = String(obj?.start_date) || ''; + this.end_date = String(obj?.end_date) || ''; this.geometry = (obj?.geojson?.length && obj.geojson) || []; this.biologist_first_name = obj?.lead_first_name || ''; this.biologist_last_name = obj?.lead_last_name || ''; diff --git a/api/src/models/user/index.ts b/api/src/models/user/index.ts new file mode 100644 index 0000000000..e5abc85650 --- /dev/null +++ b/api/src/models/user/index.ts @@ -0,0 +1 @@ +export * from './user'; diff --git a/api/src/models/user/user.test.ts b/api/src/models/user/user.test.ts new file mode 100644 index 0000000000..c8a145e25b --- /dev/null +++ b/api/src/models/user/user.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import { UserObject } from './user'; + +describe('UserObject', () => { + describe('No values provided', () => { + let data: UserObject; + + before(() => { + data = new UserObject((null as unknown) as any); + }); + + it('sets system_user_id', function () { + expect(data.system_user_id).to.equal(null); + }); + + it('sets user_identifier', function () { + expect(data.user_identifier).to.equal(null); + }); + + it('sets role_names', function () { + expect(data.role_names).to.eql([]); + }); + }); + + describe('valid values provided, no roles', () => { + let data: UserObject; + + const userObject = { system_user_id: 1, user_identifier: 'test name', role_ids: [], role_names: [] }; + + before(() => { + data = new UserObject(userObject); + }); + + it('sets system_user_id', function () { + expect(data.system_user_id).to.equal(1); + }); + + it('sets user_identifier', function () { + expect(data.user_identifier).to.equal('test name'); + }); + + it('sets role_ids', function () { + expect(data.role_ids).to.eql([]); + }); + + it('sets role_names', function () { + expect(data.role_names).to.eql([]); + }); + }); + + describe('valid values provided', () => { + let data: UserObject; + + const userObject = { + system_user_id: 1, + user_identifier: 'test name', + role_ids: [1, 2], + role_names: ['role 1', 'role 2'] + }; + + before(() => { + data = new UserObject(userObject); + }); + + it('sets system_user_id', function () { + expect(data.system_user_id).to.equal(1); + }); + + it('sets user_identifier', function () { + expect(data.user_identifier).to.equal('test name'); + }); + + it('sets role_ids', function () { + expect(data.role_ids).to.eql([1, 2]); + }); + + it('sets role_names', function () { + expect(data.role_names).to.eql(['role 1', 'role 2']); + }); + }); +}); diff --git a/api/src/models/user/user.ts b/api/src/models/user/user.ts new file mode 100644 index 0000000000..e205bc23fd --- /dev/null +++ b/api/src/models/user/user.ts @@ -0,0 +1,19 @@ +export class UserObject { + system_user_id: number; + user_identifier: string | null; + user_guid: string | null; + identity_source: string | null; + record_end_date: string | null; + role_ids: number[]; + role_names: string[]; + + constructor(obj?: any) { + this.system_user_id = obj?.system_user_id || null; + this.user_identifier = obj?.user_identifier || null; + this.user_guid = obj?.user_guid || ''; + this.identity_source = obj?.identity_source || null; + this.record_end_date = obj?.record_end_date || null; + this.role_ids = (obj?.role_ids?.length && obj.role_ids) || []; + this.role_names = (obj?.role_names?.length && obj.role_names) || []; + } +} diff --git a/api/src/paths/administrative-activity.ts b/api/src/paths/administrative-activity.ts index 4e795cdc4a..a3f7ff1d0a 100644 --- a/api/src/paths/administrative-activity.ts +++ b/api/src/paths/administrative-activity.ts @@ -3,7 +3,7 @@ import { Operation } from 'express-openapi'; import { getAPIUserDBConnection } from '../database/db'; import { HTTP400, HTTP500 } from '../errors/http-error'; import { AdministrativeActivityService } from '../services/administrative-activity-service'; -import { getKeycloakUserInformationFromKeycloakToken, getUserIdentifier } from '../utils/keycloak-utils'; +import { getUserGuid } from '../utils/keycloak-utils'; import { getLogger } from '../utils/logger'; const defaultLog = getLogger('paths/administrative-activity-request'); @@ -166,15 +166,13 @@ export function getAdministrativeActivityStanding(): RequestHandler { const connection = getAPIUserDBConnection(); try { - const keycloakUserInformation = getKeycloakUserInformationFromKeycloakToken(req['keycloak_token']); + // TODO Update to use user guid instead of identifier + const userIdentifier = getUserGuid(req['keycloak_token']); - if (!keycloakUserInformation) { + if (!userIdentifier) { throw new HTTP400('Failed to identify user'); } - // TODO Update to use user guid instead of identifier - const userIdentifier = getUserIdentifier(keycloakUserInformation); - await connection.open(); const administrativeActivityService = new AdministrativeActivityService(connection); diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts index fc09f78547..2d643a2b2f 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts @@ -3,7 +3,7 @@ import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../constants/roles'; import { getDBConnection } from '../../../../../database/db'; import { SystemUser } from '../../../../../repositories/user-repository'; -import { authorizeRequestHandler, getSystemUserObject } from '../../../../../request-handlers/security/authorization'; +import { authorizeRequestHandler } from '../../../../../request-handlers/security/authorization'; import { AttachmentService } from '../../../../../services/attachment-service'; import { getLogger } from '../../../../../utils/logger'; import { attachmentApiDocObject } from '../../../../../utils/shared-api-docs'; @@ -103,7 +103,7 @@ export function deleteAttachment(): RequestHandler { const attachmentService = new AttachmentService(connection); - const systemUserObject: SystemUser = req['system_user'] || (await getSystemUserObject(connection)); + const systemUserObject: SystemUser = req['system_user']; const isAdmin = systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN) || systemUserObject.role_names.includes(SYSTEM_ROLE.DATA_ADMINISTRATOR); diff --git a/api/src/paths/project/{projectId}/survey/list.ts b/api/src/paths/project/{projectId}/survey/list.ts index e407a00309..852b6a4c6c 100644 --- a/api/src/paths/project/{projectId}/survey/list.ts +++ b/api/src/paths/project/{projectId}/survey/list.ts @@ -79,7 +79,6 @@ GET.apiDoc = { type: 'object', required: [ 'survey_name', - 'start_date', 'biologist_first_name', 'biologist_last_name', 'survey_types', @@ -91,7 +90,8 @@ GET.apiDoc = { }, start_date: { oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], - description: 'ISO 8601 date string for the funding end_date' + description: 'ISO 8601 date string for the funding end_date', + nullable: true }, end_date: { oneOf: [{ type: 'object' }, { type: 'string', format: 'date' }], diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts index a0845010a0..393b117676 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts @@ -3,10 +3,7 @@ import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { SystemUser } from '../../../../../../../repositories/user-repository'; -import { - authorizeRequestHandler, - getSystemUserObject -} from '../../../../../../../request-handlers/security/authorization'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { AttachmentService } from '../../../../../../../services/attachment-service'; import { getLogger } from '../../../../../../../utils/logger'; import { attachmentApiDocObject } from '../../../../../../../utils/shared-api-docs'; @@ -112,7 +109,7 @@ export function deleteAttachment(): RequestHandler { const attachmentService = new AttachmentService(connection); - const systemUserObject: SystemUser = req['system_user'] || (await getSystemUserObject(connection)); + const systemUserObject: SystemUser = req['system_user']; const isAdmin = systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN) || systemUserObject.role_names.includes(SYSTEM_ROLE.DATA_ADMINISTRATOR); diff --git a/api/src/paths/user/add.test.ts b/api/src/paths/user/add.test.ts index a633505e35..9ca3104357 100644 --- a/api/src/paths/user/add.test.ts +++ b/api/src/paths/user/add.test.ts @@ -4,9 +4,9 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; import * as db from '../../database/db'; -import { HTTPError } from '../../errors/http-error'; import { SystemUser } from '../../repositories/user-repository'; import { UserService } from '../../services/user-service'; +import * as keycloakUtils from '../../utils/keycloak-utils'; import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; import * as user from './add'; @@ -18,108 +18,11 @@ describe('user', () => { sinon.restore(); }); - it('should throw a 400 error when no req body', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.body = undefined; - - try { - const requestHandler = user.addSystemRoleUser(); - - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required body param: userIdentifier'); - } - }); - - it('should throw a 400 error when no userIdentifier', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.body = { - userGuid: 'aaaa', - identitySource: SYSTEM_IDENTITY_SOURCE.IDIR, - displayName: 'display name', - email: 'email', - roleId: 1 - }; - - try { - const requestHandler = user.addSystemRoleUser(); - - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required body param: userIdentifier'); - } - }); - - it('should throw a 400 error when no identitySource', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.body = { - userGuid: 'aaaa', - userIdentifier: 'username', - displayName: 'display name', - email: 'email', - roleId: 1 - }; - - try { - const requestHandler = user.addSystemRoleUser(); - - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required body param: identitySource'); - } - }); - - it('should throw a 400 error when no roleId', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.body = { - userGuid: 'aaaa', - userIdentifier: 'username', - identitySource: SYSTEM_IDENTITY_SOURCE.IDIR, - displayName: 'display name', - email: 'email' - }; - - try { - const requestHandler = user.addSystemRoleUser(); - - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required body param: roleId'); - } - }); - it('adds a system user and returns 200 on success', async () => { const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + sinon.stub(keycloakUtils, 'getKeycloakSource').resolves(true); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -161,6 +64,7 @@ describe('user', () => { const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + sinon.stub(keycloakUtils, 'getKeycloakSource').resolves(true); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); diff --git a/api/src/paths/user/add.ts b/api/src/paths/user/add.ts index a26d970446..ab81ca477c 100644 --- a/api/src/paths/user/add.ts +++ b/api/src/paths/user/add.ts @@ -1,11 +1,12 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; +import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; import { SYSTEM_ROLE } from '../../constants/roles'; -import { getDBConnection } from '../../database/db'; +import { getDBConnection, getServiceAccountDBConnection } from '../../database/db'; import { HTTP400 } from '../../errors/http-error'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; import { UserService } from '../../services/user-service'; +import { getKeycloakSource } from '../../utils/keycloak-utils'; import { getLogger } from '../../utils/logger'; const defaultLog = getLogger('paths/user/add'); @@ -13,10 +14,14 @@ const defaultLog = getLogger('paths/user/add'); export const POST: Operation = [ authorizeRequestHandler(() => { return { - and: [ + or: [ { validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], discriminator: 'SystemRole' + }, + { + validServiceClientIDs: [SOURCE_SYSTEM['SIMS-SVC-4464']], + discriminator: 'ServiceClient' } ] }; @@ -39,7 +44,7 @@ POST.apiDoc = { schema: { title: 'User Response Object', type: 'object', - required: ['userIdentifier', 'identitySource', 'displayName', 'email', 'roleId'], + required: ['userIdentifier', 'identitySource', 'displayName', 'email'], properties: { userGuid: { type: 'string', @@ -54,7 +59,8 @@ POST.apiDoc = { enum: [ SYSTEM_IDENTITY_SOURCE.IDIR, SYSTEM_IDENTITY_SOURCE.BCEID_BASIC, - SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS + SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS, + SYSTEM_IDENTITY_SOURCE.UNVERIFIED ] }, displayName: { @@ -68,6 +74,17 @@ POST.apiDoc = { roleId: { type: 'number', minimum: 1 + }, + given_name: { + type: 'string', + description: 'The given name for the user.' + }, + family_name: { + type: 'string', + description: 'The family name for the user.' + }, + role_name: { + type: 'string' } } } @@ -103,35 +120,29 @@ POST.apiDoc = { */ export function addSystemRoleUser(): RequestHandler { return async (req, res) => { - const connection = getDBConnection(req['keycloak_token']); - const userGuid: string | null = req.body?.userGuid || null; - const userIdentifier: string | null = req.body?.userIdentifier || null; - const identitySource: string | null = req.body?.identitySource || null; - const displayName: string | null = req.body?.displayName || null; - const email: string | null = req.body?.email || null; + const userIdentifier: string = req.body?.userIdentifier || ''; + const identitySource: string = req.body?.identitySource || ''; + const displayName: string = req.body?.displayName || ''; + const email: string = req.body?.email || ''; const roleId = req.body?.roleId || null; - if (!userIdentifier) { - throw new HTTP400('Missing required body param: userIdentifier'); - } - - if (!identitySource) { - throw new HTTP400('Missing required body param: identitySource'); - } + const given_name: string = req.body?.given_name; + const family_name: string = req.body?.family_name; + const role_name: string = req.body?.role_name; - if (!displayName) { - throw new HTTP400('Missing required body param: identitySource'); - } + const sourceSystem = getKeycloakSource(req['keycloak_token']); - if (!email) { - throw new HTTP400('Missing required body param: identitySource'); + if (!sourceSystem) { + throw new HTTP400('Failed to identify known submission source system', [ + 'token did not contain a clientId/azp or clientId/azp value is unknown' + ]); } - if (!roleId) { - throw new HTTP400('Missing required body param: roleId'); - } + const connection = sourceSystem + ? getServiceAccountDBConnection(sourceSystem) + : getDBConnection(req['keycloak_token']); try { await connection.open(); @@ -143,11 +154,17 @@ export function addSystemRoleUser(): RequestHandler { userIdentifier, identitySource, displayName, - email + email, + given_name, + family_name ); if (userObject) { - await userService.addUserSystemRoles(userObject.system_user_id, [roleId]); + if (role_name) { + await userService.addUserSystemRoleByName(userObject.system_user_id, role_name); + } else { + await userService.addUserSystemRoles(userObject.system_user_id, [roleId]); + } } await connection.commit(); diff --git a/api/src/queries/database/user-context-queries.test.ts b/api/src/queries/database/user-context-queries.test.ts new file mode 100644 index 0000000000..c7f12f0fe7 --- /dev/null +++ b/api/src/queries/database/user-context-queries.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import { describe } from 'mocha'; +import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; +import { setSystemUserContextSQL } from './user-context-queries'; + +describe('setSystemUserContextSQL', () => { + it('has empty userIdentifier', () => { + const response = setSystemUserContextSQL('', SYSTEM_IDENTITY_SOURCE.IDIR); + + expect(response).to.be.null; + }); + + it('identifies an IDIR user', () => { + const response = setSystemUserContextSQL('idir-user', SYSTEM_IDENTITY_SOURCE.IDIR); + + expect(response).not.to.be.null; + }); + + it('identifies a BCEID basic user', () => { + const response = setSystemUserContextSQL('bceid-basic-user', SYSTEM_IDENTITY_SOURCE.BCEID_BASIC); + + expect(response).not.to.be.null; + }); + + it('identifies a BCEID business user', () => { + const response = setSystemUserContextSQL('bceid-business-user', SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS); + + expect(response).not.to.be.null; + }); + + it('identifies a database user', () => { + const response = setSystemUserContextSQL('database-user', SYSTEM_IDENTITY_SOURCE.DATABASE); + + expect(response).not.to.be.null; + }); + + it('identifies a system user', () => { + const response = setSystemUserContextSQL('system-user', SYSTEM_IDENTITY_SOURCE.SYSTEM); + + expect(response).not.to.be.null; + }); +}); diff --git a/api/src/queries/database/user-context-queries.ts b/api/src/queries/database/user-context-queries.ts new file mode 100644 index 0000000000..6331803705 --- /dev/null +++ b/api/src/queries/database/user-context-queries.ts @@ -0,0 +1,15 @@ +import { SQL, SQLStatement } from 'sql-template-strings'; +import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; + +export const setSystemUserContextSQL = ( + userGuid: string, + systemUserType: SYSTEM_IDENTITY_SOURCE +): SQLStatement | null => { + if (!userGuid) { + return null; + } + + return SQL` + SELECT api_set_context(${userGuid}, ${systemUserType}); +`; +}; diff --git a/api/src/repositories/user-repository.ts b/api/src/repositories/user-repository.ts index 58b119f441..c8f771906e 100644 --- a/api/src/repositories/user-repository.ts +++ b/api/src/repositories/user-repository.ts @@ -244,7 +244,9 @@ export class UserRepository extends BaseRepository { userIdentifier: string, identitySource: string, displayName: string, - email: string + email: string, + givenName?: string, + familyName?: string ): Promise<{ system_user_id: number }> { const sqlStatement = SQL` INSERT INTO @@ -254,6 +256,8 @@ export class UserRepository extends BaseRepository { user_identity_source_id, user_identifier, display_name, + given_name, + family_name, email, record_effective_date ) @@ -269,6 +273,8 @@ export class UserRepository extends BaseRepository { ), ${userIdentifier.toLowerCase()}, ${displayName}, + ${givenName || null}, + ${familyName || null}, ${email.toLowerCase()}, now() ) @@ -451,6 +457,34 @@ export class UserRepository extends BaseRepository { } } + /** + * Adds the specified roles by name to the user. + * + * @param {number} systemUserId + * @param {string} roleName + * @memberof UserRepository + */ + async addUserSystemRoleByName(systemUserId: number, roleName: string) { + const sqlStatement = SQL` + INSERT INTO system_user_role ( + system_user_id, + system_role_id + ) VALUES ( + ${systemUserId}, + (SELECT system_role_id FROM system_role WHERE name = ${roleName}) + ); + `; + + const response = await this.connection.sql(sqlStatement); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to insert user system roles', [ + 'UserRepository->addUserSystemRoleByName', + 'rowCount was null or undefined, expected rowCount = 1' + ]); + } + } + async deleteAllProjectRoles(systemUserId: number) { const sqlStatement = SQL` DELETE FROM diff --git a/api/src/request-handlers/security/authentication.ts b/api/src/request-handlers/security/authentication.ts index 783bbc83b4..5e136aadc3 100644 --- a/api/src/request-handlers/security/authentication.ts +++ b/api/src/request-handlers/security/authentication.ts @@ -84,7 +84,24 @@ export const authenticateRequest = async function (req: Request): Promise return true; } catch (error) { - defaultLog.warn({ label: 'authenticate', message: 'error', error }); + defaultLog.warn({ label: 'authenticate', message: `unexpected error - ${(error as Error).message}`, error }); throw new HTTP401('Access Denied'); } }; + +/** + * optionally authenticate the request by validating the authorization bearer token (JWT), if one exists on the request. + * + * If a valid token exists, assign the bearer token to `req.keycloak_token`, return true. + * + * If a valid token does not exist, return true. + * + * Why? This authentication method should be used for endpoints where authentication is optional, but the response is + * different based on whether or not the request is authenticated. + * + * @param {Request} req + * @return {*} {Promise} + */ +export const authenticateRequestOptional = async function (req: Request): Promise { + return authenticateRequest(req).catch(() => true); +}; diff --git a/api/src/request-handlers/security/authorization.test.ts b/api/src/request-handlers/security/authorization.test.ts index ec267d9f47..51586c969b 100644 --- a/api/src/request-handlers/security/authorization.test.ts +++ b/api/src/request-handlers/security/authorization.test.ts @@ -1,16 +1,12 @@ import chai, { expect } from 'chai'; import { Request } from 'express'; import { describe } from 'mocha'; -import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { PROJECT_ROLE, SYSTEM_ROLE } from '../../constants/roles'; -import * as db from '../../database/db'; import { HTTPError } from '../../errors/http-error'; -import { ProjectUser } from '../../repositories/project-participation-repository'; -import { SystemUser } from '../../repositories/user-repository'; -import { UserService } from '../../services/user-service'; -import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { Models } from '../../models'; +import { AuthorizationService } from '../../services/authorization-service'; +import { getRequestHandlerMocks, registerMockDBConnection } from '../../__mocks__/db'; import * as authorization from './authorization'; chai.use(sinonChai); @@ -21,8 +17,7 @@ describe('authorizeRequestHandler', function () { }); it('throws a 403 error if the user is not authorized', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -46,8 +41,7 @@ describe('authorizeRequestHandler', function () { }); it('calls next if the user is authorized', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); @@ -71,11 +65,10 @@ describe('authorizeRequest', function () { }); it('returns false if systemUserObject is null', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); - const mockSystemUserObject = (undefined as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockSystemUserObject); + const mockSystemUserObject = (undefined as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); const mockReq = ({ authorization_scheme: {} } as unknown) as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); @@ -84,13 +77,12 @@ describe('authorizeRequest', function () { }); it('returns true if the user is a system administrator', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockSystemUserObject); + const mockSystemUserObject = ({ role_names: [] } as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); - sinon.stub(authorization, 'authorizeSystemAdministrator').resolves(true); + sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(true); const mockReq = ({ authorization_scheme: {} } as unknown) as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); @@ -99,13 +91,12 @@ describe('authorizeRequest', function () { }); it('returns true if the authorization_scheme is undefined', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockSystemUserObject); + const mockSystemUserObject = ({ role_names: [] } as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); - sinon.stub(authorization, 'authorizeSystemAdministrator').resolves(false); + sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(false); const mockReq = ({ authorization_scheme: undefined } as unknown) as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); @@ -114,15 +105,14 @@ describe('authorizeRequest', function () { }); it('returns true if the user is authorized against the authorization_scheme', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockSystemUserObject); + const mockSystemUserObject = ({ role_names: [] } as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); - sinon.stub(authorization, 'authorizeSystemAdministrator').resolves(false); + sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(false); - sinon.stub(authorization, 'executeAuthorizationScheme').resolves(true); + sinon.stub(AuthorizationService.prototype, 'executeAuthorizationScheme').resolves(true); const mockReq = ({ authorization_scheme: {} } as unknown) as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); @@ -131,15 +121,14 @@ describe('authorizeRequest', function () { }); it('returns false if the user is not authorized against the authorization_scheme', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + registerMockDBConnection(); - const mockSystemUserObject = ({ role_names: [] } as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockSystemUserObject); + const mockSystemUserObject = ({ role_names: [] } as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockSystemUserObject); - sinon.stub(authorization, 'authorizeSystemAdministrator').resolves(false); + sinon.stub(AuthorizationService.prototype, 'authorizeSystemAdministrator').resolves(false); - sinon.stub(authorization, 'executeAuthorizationScheme').resolves(false); + sinon.stub(AuthorizationService.prototype, 'executeAuthorizationScheme').resolves(false); const mockReq = ({ authorization_scheme: {} } as unknown) as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); @@ -148,12 +137,11 @@ describe('authorizeRequest', function () { }); it('returns false if an error is thrown', async function () { - const mockDBConnection = getMockDBConnection({ - open: () => { + registerMockDBConnection({ + open: sinon.stub().callsFake(() => { throw new Error('Test Error'); - } + }) }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); const mockReq = ({ authorization_scheme: {} } as unknown) as Request; const isAuthorized = await authorization.authorizeRequest(mockReq); @@ -161,627 +149,3 @@ describe('authorizeRequest', function () { expect(isAuthorized).to.equal(false); }); }); - -describe('executeAuthorizationScheme', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns false if any AND authorizationScheme rules return false', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizationScheme = ({ and: [] } as unknown) as authorization.AuthorizationScheme; - const mockDBConnection = getMockDBConnection(); - - sinon.stub(authorization, 'executeAuthorizeConfig').resolves([true, false, true]); - - const isAuthorized = await authorization.executeAuthorizationScheme( - mockReq, - mockAuthorizationScheme, - mockDBConnection - ); - - expect(isAuthorized).to.equal(false); - }); - - it('returns true if all AND authorizationScheme rules return true', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizationScheme = ({ and: [] } as unknown) as authorization.AuthorizationScheme; - const mockDBConnection = getMockDBConnection(); - - sinon.stub(authorization, 'executeAuthorizeConfig').resolves([true, true, true]); - - const isAuthorized = await authorization.executeAuthorizationScheme( - mockReq, - mockAuthorizationScheme, - mockDBConnection - ); - - expect(isAuthorized).to.equal(true); - }); - - it('returns false if all OR authorizationScheme rules return false', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizationScheme = ({ or: [] } as unknown) as authorization.AuthorizationScheme; - const mockDBConnection = getMockDBConnection(); - - sinon.stub(authorization, 'executeAuthorizeConfig').resolves([false, false, false]); - - const isAuthorized = await authorization.executeAuthorizationScheme( - mockReq, - mockAuthorizationScheme, - mockDBConnection - ); - - expect(isAuthorized).to.equal(false); - }); - - it('returns true if any OR authorizationScheme rules return true', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizationScheme = ({ or: [] } as unknown) as authorization.AuthorizationScheme; - const mockDBConnection = getMockDBConnection(); - - sinon.stub(authorization, 'executeAuthorizeConfig').resolves([false, true, false]); - - const isAuthorized = await authorization.executeAuthorizationScheme( - mockReq, - mockAuthorizationScheme, - mockDBConnection - ); - - expect(isAuthorized).to.equal(true); - }); -}); - -describe('executeAuthorizeConfig', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns an array of authorizeRule results', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeRules: authorization.AuthorizeRule[] = [ - { - validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR], - discriminator: 'SystemRole' - }, - { - validProjectRoles: [PROJECT_ROLE.COORDINATOR], - projectId: 1, - discriminator: 'ProjectRole' - }, - { - discriminator: 'SystemUser' - } - ]; - const mockDBConnection = getMockDBConnection(); - - sinon.stub(authorization, 'authorizeBySystemRole').resolves(true); - sinon.stub(authorization, 'authorizeByProjectRole').resolves(false); - sinon.stub(authorization, 'authorizeBySystemUser').resolves(true); - - const authorizeResults = await authorization.executeAuthorizeConfig(mockReq, mockAuthorizeRules, mockDBConnection); - - expect(authorizeResults).to.eql([true, false, true]); - }); -}); - -describe('authorizeBySystemRole', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns false if `authorizeSystemRoles` is null', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeSystemRoles = (null as unknown) as authorization.AuthorizeBySystemRoles; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemRole( - mockReq, - mockAuthorizeSystemRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns false if `systemUserObject` is null', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeSystemRoles: authorization.AuthorizeBySystemRoles = { - validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR], - discriminator: 'SystemRole' - }; - const mockDBConnection = getMockDBConnection(); - - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemRole( - mockReq, - mockAuthorizeSystemRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns true if `authorizeSystemRoles` specifies no valid roles', async function () { - const mockReq = ({ system_user: {} } as unknown) as Request; - const mockAuthorizeSystemRoles: authorization.AuthorizeBySystemRoles = { - validSystemRoles: [], - discriminator: 'SystemRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemRole( - mockReq, - mockAuthorizeSystemRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(true); - }); - - it('returns false if the user does not have any valid roles', async function () { - const mockReq = ({ system_user: { role_names: [] } } as unknown) as Request; - const mockAuthorizeSystemRoles: authorization.AuthorizeBySystemRoles = { - validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR], - discriminator: 'SystemRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemRole( - mockReq, - mockAuthorizeSystemRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns true if the user has at least one of the valid roles', async function () { - const mockReq = ({ system_user: { role_names: [SYSTEM_ROLE.PROJECT_CREATOR] } } as unknown) as Request; - const mockAuthorizeSystemRoles: authorization.AuthorizeBySystemRoles = { - validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR], - discriminator: 'SystemRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemRole( - mockReq, - mockAuthorizeSystemRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(true); - }); -}); - -describe('authorizeByProjectRole', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns false if `authorizeByProjectRole` is null', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeProjectRoles = (null as unknown) as authorization.AuthorizeByProjectRoles; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeByProjectRole( - mockReq, - mockAuthorizeProjectRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns false if `authorizeProjectRoles.projectId` is null', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeProjectRoles: authorization.AuthorizeByProjectRoles = { - validProjectRoles: [PROJECT_ROLE.COORDINATOR], - projectId: (null as unknown) as number, - discriminator: 'ProjectRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeByProjectRole( - mockReq, - mockAuthorizeProjectRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns true if `authorizeByProjectRole` specifies no valid roles', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeProjectRoles: authorization.AuthorizeByProjectRoles = { - validProjectRoles: [], - projectId: 1, - discriminator: 'ProjectRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeByProjectRole( - mockReq, - mockAuthorizeProjectRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(true); - }); - - it('returns false if it fails to fetch the users project role information', async function () { - const mockReq = ({} as unknown) as Request; - const mockAuthorizeProjectRoles: authorization.AuthorizeByProjectRoles = { - validProjectRoles: [PROJECT_ROLE.COORDINATOR], - projectId: 1, - discriminator: 'ProjectRole' - }; - const mockDBConnection = getMockDBConnection(); - - const mockProjectUserObject = (undefined as unknown) as ProjectUser; - sinon.stub(authorization, 'getProjectUserObject').resolves(mockProjectUserObject); - - const isAuthorizedBySystemRole = await authorization.authorizeByProjectRole( - mockReq, - mockAuthorizeProjectRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns false if the user does not have any valid roles', async function () { - const mockProjectUserObject = ({ project_role_names: [] } as unknown) as ProjectUser; - const mockReq = ({ project_user: mockProjectUserObject } as unknown) as Request; - const mockAuthorizeProjectRoles: authorization.AuthorizeByProjectRoles = { - validProjectRoles: [PROJECT_ROLE.COORDINATOR], - projectId: 1, - discriminator: 'ProjectRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeByProjectRole( - mockReq, - mockAuthorizeProjectRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns true if the user has at lest one of the valid roles', async function () { - const mockProjectUserObject = ({ - project_role_names: [PROJECT_ROLE.COORDINATOR] - } as unknown) as ProjectUser; - const mockReq = ({ project_user: mockProjectUserObject } as unknown) as Request; - const mockAuthorizeProjectRoles: authorization.AuthorizeByProjectRoles = { - validProjectRoles: [PROJECT_ROLE.COORDINATOR], - projectId: 1, - discriminator: 'ProjectRole' - }; - const mockDBConnection = getMockDBConnection(); - - const isAuthorizedBySystemRole = await authorization.authorizeByProjectRole( - mockReq, - mockAuthorizeProjectRoles, - mockDBConnection - ); - - expect(isAuthorizedBySystemRole).to.equal(true); - }); -}); - -describe('authorizeBySystemUser', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns false if `systemUserObject` is null', async function () { - const mockReq = ({} as unknown) as Request; - const mockDBConnection = getMockDBConnection(); - - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemUser(mockReq, mockDBConnection); - - expect(isAuthorizedBySystemRole).to.equal(false); - }); - - it('returns true if `systemUserObject` is not null', async function () { - const mockReq = ({ system_user: {} } as unknown) as Request; - const mockDBConnection = getMockDBConnection(); - - const mockGetSystemUsersObjectResponse = (null as unknown) as SystemUser; - sinon.stub(authorization, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); - - const isAuthorizedBySystemRole = await authorization.authorizeBySystemUser(mockReq, mockDBConnection); - - expect(isAuthorizedBySystemRole).to.equal(true); - }); -}); - -describe('userHasValidRole', () => { - describe('validSystemRoles is a string', () => { - describe('userSystemRoles is a string', () => { - it('returns true if the valid roles is empty', () => { - const response = authorization.userHasValidRole('', ''); - - expect(response).to.be.true; - }); - - it('returns false if the user has no roles', () => { - const response = authorization.userHasValidRole('admin', ''); - - expect(response).to.be.false; - }); - - it('returns false if the user has no matching roles', () => { - const response = authorization.userHasValidRole('admin', 'user'); - - expect(response).to.be.false; - }); - - it('returns true if the user has a matching role', () => { - const response = authorization.userHasValidRole('admin', 'admin'); - - expect(response).to.be.true; - }); - }); - - describe('userSystemRoles is an array', () => { - it('returns true if the valid roles is empty', () => { - const response = authorization.userHasValidRole('', []); - - expect(response).to.be.true; - }); - - it('returns false if the user has no matching roles', () => { - const response = authorization.userHasValidRole('admin', []); - - expect(response).to.be.false; - }); - - it('returns false if the user has no matching roles', () => { - const response = authorization.userHasValidRole('admin', ['user']); - - expect(response).to.be.false; - }); - - it('returns true if the user has a matching role', () => { - const response = authorization.userHasValidRole('admin', ['admin']); - - expect(response).to.be.true; - }); - }); - }); - - describe('validSystemRoles is an array', () => { - describe('userSystemRoles is a string', () => { - it('returns true if the valid roles is empty', () => { - const response = authorization.userHasValidRole([], ''); - - expect(response).to.be.true; - }); - - it('returns false if the user has no roles', () => { - const response = authorization.userHasValidRole(['admin'], ''); - - expect(response).to.be.false; - }); - - it('returns false if the user has no matching roles', () => { - const response = authorization.userHasValidRole(['admin'], 'user'); - - expect(response).to.be.false; - }); - - it('returns true if the user has a matching role', () => { - const response = authorization.userHasValidRole(['admin'], 'admin'); - - expect(response).to.be.true; - }); - }); - - describe('userSystemRoles is an array', () => { - it('returns true if the valid roles is empty', () => { - const response = authorization.userHasValidRole([], []); - - expect(response).to.be.true; - }); - - it('returns false if the user has no matching roles', () => { - const response = authorization.userHasValidRole(['admin'], []); - - expect(response).to.be.false; - }); - - it('returns false if the user has no matching roles', () => { - const response = authorization.userHasValidRole(['admin'], ['user']); - - expect(response).to.be.false; - }); - - it('returns true if the user has a matching role', () => { - const response = authorization.userHasValidRole(['admin'], ['admin']); - - expect(response).to.be.true; - }); - }); - }); -}); - -describe('getSystemUserObject', function () { - afterEach(() => { - sinon.restore(); - }); - - it('throws an HTTP500 error if fetching the system user throws an error', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - sinon.stub(authorization, 'getSystemUserWithRoles').callsFake(() => { - throw new Error('Test Error'); - }); - - try { - await authorization.getSystemUserObject(mockDBConnection); - expect.fail(); - } catch (error) { - expect((error as HTTPError).message).to.equal('failed to get system user'); - expect((error as HTTPError).status).to.equal(500); - } - }); - - it('throws an HTTP500 error if the system user is null', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSystemUserWithRolesResponse = null; - sinon.stub(authorization, 'getSystemUserWithRoles').resolves(mockSystemUserWithRolesResponse); - - try { - await authorization.getSystemUserObject(mockDBConnection); - expect.fail(); - } catch (error) { - expect((error as HTTPError).message).to.equal('system user was null'); - expect((error as HTTPError).status).to.equal(500); - } - }); - - it('returns a `User`', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSystemUserWithRolesResponse: SystemUser = { - system_user_id: 1, - user_identifier: 'identifier', - user_guid: 'aaaa', - identity_source: 'idir', - record_end_date: null, - role_ids: [1, 2], - role_names: ['role 1', 'role 2'], - email: 'email@email.com', - display_name: 'test name', - agency: null - }; - sinon.stub(authorization, 'getSystemUserWithRoles').resolves(mockSystemUserWithRolesResponse); - - const systemUserObject = await authorization.getSystemUserObject(mockDBConnection); - - expect(systemUserObject).to.equal(mockSystemUserWithRolesResponse); - }); -}); - -describe('getSystemUserWithRoles', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns null if the system user id is null', async function () { - const mockDBConnection = getMockDBConnection({ systemUserId: () => (null as unknown) as number }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const result = await authorization.getSystemUserWithRoles(mockDBConnection); - - expect(result).to.be.null; - }); - - it('returns a User', async function () { - const mockDBConnection = getMockDBConnection({ systemUserId: () => 1 }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockUsersByIdSQLResponse = (null as unknown) as SystemUser; - sinon.stub(UserService.prototype, 'getUserById').resolves(mockUsersByIdSQLResponse); - - const result = await authorization.getSystemUserWithRoles(mockDBConnection); - - expect(result).to.equal(mockUsersByIdSQLResponse); - }); -}); - -describe('getProjectUserObject', function () { - afterEach(() => { - sinon.restore(); - }); - - it('throws an HTTP500 error if fetching the system user throws an error', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - sinon.stub(authorization, 'getProjectUserWithRoles').callsFake(() => { - throw new Error('Test Error'); - }); - - try { - await authorization.getProjectUserObject(1, mockDBConnection); - expect.fail(); - } catch (error) { - expect((error as HTTPError).message).to.equal('failed to get project user'); - expect((error as HTTPError).status).to.equal(500); - } - }); - - it('throws an HTTP500 error if the system user is null', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSystemUserWithRolesResponse = null; - sinon.stub(authorization, 'getProjectUserWithRoles').resolves(mockSystemUserWithRolesResponse); - - try { - await authorization.getProjectUserObject(1, mockDBConnection); - expect.fail(); - } catch (error) { - expect((error as HTTPError).message).to.equal('project user was null'); - expect((error as HTTPError).status).to.equal(500); - } - }); - - it('returns a `ProjectUser`', async function () { - const mockDBConnection = getMockDBConnection(); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const mockSystemUserWithRolesResponse: ProjectUser = { - project_participation_id: 1, - project_id: 2, - system_user_id: 1, - project_role_ids: [1, 2], - project_role_names: ['role 1', 'role 2'], - project_role_permissions: ['permission 1', 'permission 2'] - }; - sinon.stub(authorization, 'getProjectUserWithRoles').resolves(mockSystemUserWithRolesResponse); - - const systemUserObject = await authorization.getProjectUserObject(1, mockDBConnection); - - expect(systemUserObject).to.be.eql(mockSystemUserWithRolesResponse); - }); -}); - -describe('getProjectUserWithRoles', function () { - afterEach(() => { - sinon.restore(); - }); - - it('returns null if the system user id is null', async function () { - const mockDBConnection = getMockDBConnection({ systemUserId: () => (null as unknown) as number }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const result = await authorization.getProjectUserWithRoles(1, mockDBConnection); - - expect(result).to.be.null; - }); - - it('returns the first row of the response', async function () { - const mockResponseRow = { 'Test Column': 'Test Value' }; - const mockQueryResponse = ({ rowCount: 1, rows: [mockResponseRow] } as unknown) as QueryResult; - const mockDBConnection = getMockDBConnection({ systemUserId: () => 1, sql: async () => mockQueryResponse }); - sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - - const result = await authorization.getProjectUserWithRoles(1, mockDBConnection); - - expect(result).to.eql(mockResponseRow); - }); -}); diff --git a/api/src/request-handlers/security/authorization.ts b/api/src/request-handlers/security/authorization.ts index 61bc75fbf2..56102b9294 100644 --- a/api/src/request-handlers/security/authorization.ts +++ b/api/src/request-handlers/security/authorization.ts @@ -1,59 +1,11 @@ import { Request, RequestHandler } from 'express'; -import SQL from 'sql-template-strings'; -import { PROJECT_PERMISSION, PROJECT_ROLE, SYSTEM_ROLE } from '../../constants/roles'; -import { getDBConnection, IDBConnection } from '../../database/db'; -import { HTTP403, HTTP500 } from '../../errors/http-error'; -import { ProjectUser } from '../../repositories/project-participation-repository'; -import { SystemUser } from '../../repositories/user-repository'; -import { UserService } from '../../services/user-service'; +import { getAPIUserDBConnection } from '../../database/db'; +import { HTTP403 } from '../../errors/http-error'; +import { AuthorizationScheme, AuthorizationService } from '../../services/authorization-service'; import { getLogger } from '../../utils/logger'; const defaultLog = getLogger('request-handlers/security/authorization'); -export enum AuthorizeOperator { - AND = 'and', - OR = 'or' -} - -export interface AuthorizeBySystemRoles { - validSystemRoles: SYSTEM_ROLE[]; - discriminator: 'SystemRole'; -} - -export interface AuthorizeByProjectRoles { - validProjectRoles: PROJECT_ROLE[]; - projectId: number; - discriminator: 'ProjectRole'; -} - -export interface AuthorizeByProjectPermissions { - validProjectPermissions: PROJECT_PERMISSION[]; - projectId: number; - discriminator: 'ProjectPermission'; -} - -export interface AuthorizeBySystemUser { - discriminator: 'SystemUser'; -} - -export type AuthorizeRule = - | AuthorizeBySystemRoles - | AuthorizeByProjectRoles - | AuthorizeBySystemUser - | AuthorizeByProjectPermissions; - -export type AuthorizeConfigOr = { - [AuthorizeOperator.AND]?: never; - [AuthorizeOperator.OR]: AuthorizeRule[]; -}; - -export type AuthorizeConfigAnd = { - [AuthorizeOperator.AND]: AuthorizeRule[]; - [AuthorizeOperator.OR]?: never; -}; - -export type AuthorizationScheme = AuthorizeConfigAnd | AuthorizeConfigOr; - export type AuthorizationSchemeCallback = (req: Request) => AuthorizationScheme; /** @@ -69,7 +21,6 @@ export type AuthorizationSchemeCallback = (req: Request) => AuthorizationScheme; export function authorizeRequestHandler(authorizationSchemeCallback: AuthorizationSchemeCallback): RequestHandler { return async (req, res, next) => { req['authorization_scheme'] = authorizationSchemeCallback(req); - const isAuthorized = await authorizeRequest(req); if (!isAuthorized) { @@ -92,7 +43,7 @@ export function authorizeRequestHandler(authorizationSchemeCallback: Authorizati * @return {*} {Promise} */ export const authorizeRequest = async (req: Request): Promise => { - const connection = getDBConnection(req['keycloak_token']); + const connection = getAPIUserDBConnection(); try { const authorizationScheme: AuthorizationScheme = req['authorization_scheme']; @@ -104,9 +55,17 @@ export const authorizeRequest = async (req: Request): Promise => { await connection.open(); + const authorizationService = new AuthorizationService(connection, { + systemUser: req['system_user'], + keycloakToken: req['keycloak_token'] + }); + const isAuthorized = - (await authorizeSystemAdministrator(req, connection)) || - (await executeAuthorizationScheme(req, authorizationScheme, connection)); + (await authorizationService.authorizeSystemAdministrator()) || + (await authorizationService.executeAuthorizationScheme(authorizationScheme)); + + // Add the system_user to the request for future use, if needed + req['system_user'] = authorizationService.systemUser; await connection.commit(); @@ -120,208 +79,6 @@ export const authorizeRequest = async (req: Request): Promise => { } }; -/** - * Execute the `authorizationScheme` against the current user, and return `true` if they have access, `false` otherwise. - * - * @param {Request} req - * @param {UserObject} systemUserObject - * @param {AuthorizationScheme} authorizationScheme - * @param {IDBConnection} connection - * @return {*} {Promise} `true` if the `authorizationScheme` indicates the user has access, `false` otherwise. - */ -export const executeAuthorizationScheme = async ( - req: Request, - authorizationScheme: AuthorizationScheme, - connection: IDBConnection -): Promise => { - if (authorizationScheme.and) { - return (await executeAuthorizeConfig(req, authorizationScheme.and, connection)).every((item) => item); - } else { - return (await executeAuthorizeConfig(req, authorizationScheme.or, connection)).some((item) => item); - } -}; - -/** - * Execute an array of `AuthorizeRule`, returning an array of boolean results. - * - * @param {Request} req - * @param {AuthorizeRule[]} authorizeRules - * @param {IDBConnection} connection - * @return {*} {Promise} - */ -export const executeAuthorizeConfig = async ( - req: Request, - authorizeRules: AuthorizeRule[], - connection: IDBConnection -): Promise => { - const authorizeResults: boolean[] = []; - - for (const authorizeRule of authorizeRules) { - switch (authorizeRule.discriminator) { - case 'SystemRole': - authorizeResults.push(await authorizeBySystemRole(req, authorizeRule, connection).catch(() => false)); - break; - case 'ProjectRole': - authorizeResults.push(await authorizeByProjectRole(req, authorizeRule, connection).catch(() => false)); - break; - case 'ProjectPermission': - authorizeResults.push(await authorizeByProjectPermission(req, authorizeRule, connection).catch(() => false)); - break; - case 'SystemUser': - authorizeResults.push(await authorizeBySystemUser(req, connection).catch(() => false)); - break; - } - } - - return authorizeResults; -}; - -/** - * Check if the user has the system administrator role. - * - * @param {UserObject} systemUserObject - * @return {*} {boolean} `true` if the user is a system administrator, `false` otherwise. - */ -export const authorizeSystemAdministrator = async (req: Request, connection: IDBConnection): Promise => { - const systemUserObject: SystemUser = req['system_user'] || (await getSystemUserObject(connection)); - - // Add the system_user to the request for future use, if needed - req['system_user'] = systemUserObject; - - if (!systemUserObject) { - // Cannot verify user roles - return false; - } - - return systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN); -}; - -/** - * Check that the user has at least one of the valid system roles specified in `authorizeSystemRoles.validSystemRoles`. - * - * @param {UserObject} systemUserObject - * @param {AuthorizeBySystemRoles} authorizeSystemRoles - * @return {*} {boolean} `true` if the user has at least one valid system role role, or no valid system roles are - * specified; `false` otherwise. - */ -export const authorizeBySystemRole = async ( - req: Request, - authorizeSystemRoles: AuthorizeBySystemRoles, - connection: IDBConnection -): Promise => { - if (!authorizeSystemRoles) { - // Cannot verify user roles - return false; - } - - const systemUserObject: SystemUser = req['system_user'] || (await getSystemUserObject(connection)); - - // Add the system_user to the request for future use, if needed - req['system_user'] = systemUserObject; - - if (!systemUserObject) { - // Cannot verify user roles - return false; - } - - if (systemUserObject.record_end_date) { - //system user has an expired record - return false; - } - - // Check if the user has at least 1 of the valid roles - return userHasValidRole(authorizeSystemRoles.validSystemRoles, systemUserObject?.role_names); -}; - -export const authorizeByProjectPermission = async ( - req: Request, - authorizeProjectPermissions: AuthorizeByProjectPermissions, - connection: IDBConnection -): Promise => { - if (!authorizeProjectPermissions?.projectId) { - // No project id to verify roles for - return false; - } - - if (!authorizeProjectPermissions?.validProjectPermissions.length) { - // No valid rules specified - return true; - } - - const projectUserObject: ProjectUser = - req['project_user'] || (await getProjectUserObject(authorizeProjectPermissions.projectId, connection)); - - // Add the project_user to the request for future use, if needed - req['project_user'] = projectUserObject; - - if (!projectUserObject) { - defaultLog.warn({ label: 'getProjectUser', message: 'project user was null' }); - return false; - } - - return userHasValidRole(authorizeProjectPermissions.validProjectPermissions, projectUserObject.project_role_names); -}; - -/** - * Check that the user has at least on of the valid project roles specified in `authorizeProjectRoles.validProjectRoles`. - * - * @param {Request} req - * @param {AuthorizeByProjectRoles} authorizeProjectRoles - * @param {IDBConnection} connection - * @return {*} {Promise} `Promise` if the user has at least one valid project role, or no valid project - * roles are specified; `Promise` otherwise. - */ -export const authorizeByProjectRole = async ( - req: Request, - authorizeProjectRoles: AuthorizeByProjectRoles, - connection: IDBConnection -): Promise => { - if (!authorizeProjectRoles?.projectId) { - // No project id to verify roles for - return false; - } - - if (!authorizeProjectRoles?.validProjectRoles.length) { - // No valid rules specified - return true; - } - - const projectUserObject: ProjectUser = - req['project_user'] || (await getProjectUserObject(authorizeProjectRoles.projectId, connection)); - - // Add the project_user to the request for future use, if needed - req['project_user'] = projectUserObject; - - if (!projectUserObject) { - defaultLog.warn({ label: 'getProjectUser', message: 'project user was null' }); - return false; - } - - return userHasValidRole(authorizeProjectRoles.validProjectRoles, projectUserObject.project_role_names); -}; - -/** - * Check if the user is a valid system user. - * - * @param {Request} req - * @param {IDBConnection} connection - * @return {*} {Promise} `Promise` if the user is a valid system user, `Promise` otherwise. - */ -export const authorizeBySystemUser = async (req: Request, connection: IDBConnection): Promise => { - const systemUserObject: SystemUser = req['system_user'] || (await getSystemUserObject(connection)); - - // Add the system_user to the request for future use, if needed - req['system_user'] = systemUserObject; - - if (!systemUserObject) { - // Cannot verify user roles - return false; - } - - // User is a valid system user - return true; -}; - /** * Compares an array of user roles against an array of valid roles. * @@ -351,114 +108,3 @@ export const userHasValidRole = function (validRoles: string | string[], userRol return false; }; - -export const getSystemUserObject = async (connection: IDBConnection): Promise => { - let systemUserWithRoles; - - try { - systemUserWithRoles = await getSystemUserWithRoles(connection); - } catch { - throw new HTTP500('failed to get system user'); - } - - if (!systemUserWithRoles) { - throw new HTTP500('system user was null'); - } - - return systemUserWithRoles; -}; - -/** - * Finds a single user based on their keycloak token information. - * - * @param {IDBConnection} connection - * @return {*} {(Promise)} - * @return {*} - */ -export const getSystemUserWithRoles = async (connection: IDBConnection): Promise => { - const systemUserId = connection.systemUserId(); - - if (!systemUserId) { - return null; - } - - const userService = new UserService(connection); - - return userService.getUserById(systemUserId); -}; - -export const getProjectUserObject = async (projectId: number, connection: IDBConnection): Promise => { - let projectUserWithRoles; - - try { - projectUserWithRoles = await getProjectUserWithRoles(projectId, connection); - } catch { - throw new HTTP500('failed to get project user'); - } - - if (!projectUserWithRoles) { - throw new HTTP500('project user was null'); - } - - return projectUserWithRoles; -}; - -/** - * Get a user's project roles, for a single project. - * - * @param {number} projectId - * @param {IDBConnection} connection - * @return {*} {Promise} - */ -export const getProjectUserWithRoles = async function ( - projectId: number, - connection: IDBConnection -): Promise { - const systemUserId = connection.systemUserId(); - - if (!systemUserId || !projectId) { - return null; - } - - const sqlStatement = SQL` - SELECT - pp.project_id, - pp.system_user_id, - su.record_end_date, - array_remove(array_agg(pr.project_role_id), NULL) AS project_role_ids, - array_remove(array_agg(pr.name), NULL) AS project_role_names, - array_remove(array_agg(pp2.name), NULL) as project_role_permissions - FROM - project_participation pp - LEFT JOIN - project_role pr - ON - pp.project_role_id = pr.project_role_id - LEFT JOIN - system_user su - ON - pp.system_user_id = su.system_user_id - LEFT JOIN project_role_permission prp - ON prp.project_role_id = pp.project_role_id - LEFT JOIN project_permission pp2 - ON pp2.project_permission_id = prp.project_permission_id - WHERE - pp.project_id = ${projectId} - AND - pp.system_user_id = ${systemUserId} - AND - su.record_end_date is NULL - GROUP BY - pp.project_id, - pp.system_user_id, - su.record_end_date; - `; - - if (!sqlStatement) { - return null; - } - - const response = await connection.sql(sqlStatement, ProjectUser); - - return response.rows[0] || null; -}; diff --git a/api/src/services/authorization-service.test.ts b/api/src/services/authorization-service.test.ts new file mode 100644 index 0000000000..0341df4289 --- /dev/null +++ b/api/src/services/authorization-service.test.ts @@ -0,0 +1,554 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SOURCE_SYSTEM } from '../constants/database'; +import { SYSTEM_ROLE } from '../constants/roles'; +import * as db from '../database/db'; +import { Models } from '../models'; +import { + AuthorizationScheme, + AuthorizationService, + AuthorizeByServiceClient, + AuthorizeBySystemRoles, + AuthorizeRule +} from '../services/authorization-service'; +import { UserService } from '../services/user-service'; +import { getMockDBConnection } from '../__mocks__/db'; + +chai.use(sinonChai); + +describe('executeAuthorizationScheme', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns false if any AND authorizationScheme rules return false', async function () { + const mockAuthorizationScheme = ({ and: [] } as unknown) as AuthorizationScheme; + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([true, false, true]); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorized = await authorizationService.executeAuthorizationScheme(mockAuthorizationScheme); + + expect(isAuthorized).to.equal(false); + }); + + it('returns true if all AND authorizationScheme rules return true', async function () { + const mockAuthorizationScheme = ({ and: [] } as unknown) as AuthorizationScheme; + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([true, true, true]); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorized = await authorizationService.executeAuthorizationScheme(mockAuthorizationScheme); + + expect(isAuthorized).to.equal(true); + }); + + it('returns false if all OR authorizationScheme rules return false', async function () { + const mockAuthorizationScheme = ({ or: [] } as unknown) as AuthorizationScheme; + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([false, false, false]); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorized = await authorizationService.executeAuthorizationScheme(mockAuthorizationScheme); + + expect(isAuthorized).to.equal(false); + }); + + it('returns true if any OR authorizationScheme rules return true', async function () { + const mockAuthorizationScheme = ({ or: [] } as unknown) as AuthorizationScheme; + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'executeAuthorizeConfig').resolves([false, true, false]); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorized = await authorizationService.executeAuthorizationScheme(mockAuthorizationScheme); + + expect(isAuthorized).to.equal(true); + }); +}); + +describe('executeAuthorizeConfig', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns an array of authorizeRule results', async function () { + const mockAuthorizeRules: AuthorizeRule[] = [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + }, + { + discriminator: 'SystemUser' + }, + { + validServiceClientIDs: [SOURCE_SYSTEM['SIMS-SVC-4464']], + discriminator: 'ServiceClient' + } + ]; + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'authorizeBySystemRole').resolves(false); + sinon.stub(AuthorizationService.prototype, 'authorizeBySystemUser').resolves(true); + sinon.stub(AuthorizationService.prototype, 'authorizeByServiceClient').resolves(true); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const authorizeResults = await authorizationService.executeAuthorizeConfig(mockAuthorizeRules); + + expect(authorizeResults).to.eql([false, true, true]); + }); +}); + +describe('authorizeByServiceClient', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns false if `systemUserObject` is null', async function () { + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(null); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorizedByServiceClient = await authorizationService.authorizeSystemAdministrator(); + + expect(isAuthorizedByServiceClient).to.equal(false); + }); + + it('returns true if `systemUserObject` is not null and includes admin role', async function () { + const mockDBConnection = getMockDBConnection(); + + const mockGetSystemUsersObjectResponse = ({ + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] + } as unknown) as Models.user.UserObject; + + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorizedByServiceClient = await authorizationService.authorizeSystemAdministrator(); + + expect(isAuthorizedByServiceClient).to.equal(true); + }); +}); + +describe('authorizeBySystemRole', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns false if `authorizeSystemRoles` is null', async function () { + const mockAuthorizeSystemRoles = (null as unknown) as AuthorizeBySystemRoles; + const mockDBConnection = getMockDBConnection(); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); + + expect(isAuthorizedBySystemRole).to.equal(false); + }); + + it('returns false if `systemUserObject` is null', async function () { + const mockAuthorizeSystemRoles: AuthorizeBySystemRoles = { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + }; + const mockDBConnection = getMockDBConnection(); + + const mockGetSystemUsersObjectResponse = (null as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); + + expect(isAuthorizedBySystemRole).to.equal(false); + }); + + it('returns false if `record_end_date` is null', async function () { + const mockAuthorizeSystemRoles: AuthorizeBySystemRoles = { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + }; + const mockDBConnection = getMockDBConnection(); + + const mockGetSystemUsersObjectResponse = ({ record_end_date: 'datetime' } as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); + + expect(isAuthorizedBySystemRole).to.equal(false); + }); + + it('returns true if `authorizeSystemRoles` specifies no valid roles', async function () { + const mockAuthorizeSystemRoles: AuthorizeBySystemRoles = { + validSystemRoles: [], + discriminator: 'SystemRole' + }; + const mockDBConnection = getMockDBConnection(); + + const authorizationService = new AuthorizationService(mockDBConnection, { + systemUser: ({} as unknown) as Models.user.UserObject + }); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); + + expect(isAuthorizedBySystemRole).to.equal(true); + }); + + it('returns false if the user does not have any valid roles', async function () { + const mockAuthorizeSystemRoles: AuthorizeBySystemRoles = { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + }; + const mockDBConnection = getMockDBConnection(); + + const authorizationService = new AuthorizationService(mockDBConnection, { + systemUser: ({ role_names: [] } as unknown) as Models.user.UserObject + }); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); + + expect(isAuthorizedBySystemRole).to.equal(false); + }); + + it('returns true if the user has at least one of the valid roles', async function () { + const mockAuthorizeSystemRoles: AuthorizeBySystemRoles = { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + }; + const mockDBConnection = getMockDBConnection(); + + const authorizationService = new AuthorizationService(mockDBConnection, { + systemUser: ({ role_names: [SYSTEM_ROLE.SYSTEM_ADMIN] } as unknown) as Models.user.UserObject + }); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemRole(mockAuthorizeSystemRoles); + + expect(isAuthorizedBySystemRole).to.equal(true); + }); +}); + +describe('authorizeBySystemUser', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns false if `systemUserObject` is null', async function () { + const mockDBConnection = getMockDBConnection(); + + const mockGetSystemUsersObjectResponse = (null as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemUser(); + + expect(isAuthorizedBySystemRole).to.equal(false); + }); + + it('returns true if `systemUserObject` is not null', async function () { + const mockDBConnection = getMockDBConnection(); + + const mockGetSystemUsersObjectResponse = (null as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); + + const authorizationService = new AuthorizationService(mockDBConnection, { + systemUser: ({} as unknown) as Models.user.UserObject + }); + + const isAuthorizedBySystemRole = await authorizationService.authorizeBySystemUser(); + + expect(isAuthorizedBySystemRole).to.equal(true); + }); +}); + +describe('authorizeByServiceClient', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns false if the keycloak token is null', async function () { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const authorizeByServiceClientData = ({ + validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], + discriminator: 'ServiceClient' + } as unknown) as AuthorizeByServiceClient; + + const result = await authorizationService.authorizeByServiceClient(authorizeByServiceClientData); + + expect(result).to.be.false; + }); + + it('returns null if the system user identifier is null', async function () { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const authorizationService = new AuthorizationService(mockDBConnection, { + keycloakToken: { preferred_username: '' } + }); + + const authorizeByServiceClientData = ({ + validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], + discriminator: 'ServiceClient' + } as unknown) as AuthorizeByServiceClient; + + const result = await authorizationService.authorizeByServiceClient(authorizeByServiceClientData); + + expect(result).to.be.false; + }); + + it('returns false if `systemUserObject` is null', async function () { + const mockDBConnection = getMockDBConnection(); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const authorizeByServiceClientData = ({ + validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], + discriminator: 'ServiceClient' + } as unknown) as AuthorizeByServiceClient; + + const isAuthorizedBySystemRole = await authorizationService.authorizeByServiceClient(authorizeByServiceClientData); + + expect(isAuthorizedBySystemRole).to.equal(false); + }); + + it('returns true if `systemUserObject` hasAtLeastOneValidValue', async function () { + const mockDBConnection = getMockDBConnection(); + + const mockGetSystemUsersObjectResponse = (null as unknown) as Models.user.UserObject; + sinon.stub(AuthorizationService.prototype, 'getSystemUserObject').resolves(mockGetSystemUsersObjectResponse); + + const authorizationService = new AuthorizationService(mockDBConnection, { + keycloakToken: { clientId: SOURCE_SYSTEM['SIMS-SVC-4464'] } + }); + + const authorizeByServiceClientData = ({ + validServiceClientIDs: SOURCE_SYSTEM['SIMS-SVC-4464'], + discriminator: 'ServiceClient' + } as unknown) as AuthorizeByServiceClient; + + const isAuthorizedBySystemRole = await authorizationService.authorizeByServiceClient(authorizeByServiceClientData); + + expect(isAuthorizedBySystemRole).to.equal(true); + }); +}); + +describe('hasAtLeastOneValidValue', () => { + describe('validValues is a string', () => { + describe('incomingValues is a string', () => { + it('returns true if the valid roles is empty', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('', ''); + + expect(response).to.be.true; + }); + + it('returns false if the user has no roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('admin', ''); + + expect(response).to.be.false; + }); + + it('returns false if the user has no matching roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('admin', 'user'); + + expect(response).to.be.false; + }); + + it('returns true if the user has a matching role', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('admin', 'admin'); + + expect(response).to.be.true; + }); + }); + + describe('incomingValues is an array', () => { + it('returns true if the valid roles is empty', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('', []); + + expect(response).to.be.true; + }); + + it('returns false if the user has no matching roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('admin', []); + + expect(response).to.be.false; + }); + + it('returns false if the user has no matching roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('admin', ['user']); + + expect(response).to.be.false; + }); + + it('returns true if the user has a matching role', () => { + const response = AuthorizationService.hasAtLeastOneValidValue('admin', ['admin']); + + expect(response).to.be.true; + }); + }); + }); + + describe('validValues is an array', () => { + describe('incomingValues is a string', () => { + it('returns true if the valid roles is empty', () => { + const response = AuthorizationService.hasAtLeastOneValidValue([], ''); + + expect(response).to.be.true; + }); + + it('returns false if the user has no roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue(['admin'], ''); + + expect(response).to.be.false; + }); + + it('returns false if the user has no matching roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue(['admin'], 'user'); + + expect(response).to.be.false; + }); + + it('returns true if the user has a matching role', () => { + const response = AuthorizationService.hasAtLeastOneValidValue(['admin'], 'admin'); + + expect(response).to.be.true; + }); + }); + + describe('incomingValues is an array', () => { + it('returns true if the valid roles is empty', () => { + const response = AuthorizationService.hasAtLeastOneValidValue([], []); + + expect(response).to.be.true; + }); + + it('returns false if the user has no matching roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue(['admin'], []); + + expect(response).to.be.false; + }); + + it('returns false if the user has no matching roles', () => { + const response = AuthorizationService.hasAtLeastOneValidValue(['admin'], ['user']); + + expect(response).to.be.false; + }); + + it('returns true if the user has a matching role', () => { + const response = AuthorizationService.hasAtLeastOneValidValue(['admin'], ['admin']); + + expect(response).to.be.true; + }); + }); + }); +}); + +describe('getSystemUserObject', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns null if fetching the system user throws an error', async function () { + const mockDBConnection = getMockDBConnection(); + + sinon.stub(AuthorizationService.prototype, 'getSystemUserWithRoles').callsFake(() => { + throw new Error('Test Error'); + }); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const systemUserObject = await authorizationService.getSystemUserObject(); + + expect(systemUserObject).to.equal(null); + }); + + it('returns null if the system user is null or undefined', async function () { + const mockDBConnection = getMockDBConnection(); + + const mockSystemUserWithRolesResponse = null; + sinon.stub(AuthorizationService.prototype, 'getSystemUserWithRoles').resolves(mockSystemUserWithRolesResponse); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const systemUserObject = await authorizationService.getSystemUserObject(); + + expect(systemUserObject).to.equal(null); + }); + + it('returns a `UserObject`', async function () { + const mockDBConnection = getMockDBConnection(); + + const mockSystemUserWithRolesResponse = new Models.user.UserObject(); + sinon.stub(AuthorizationService.prototype, 'getSystemUserWithRoles').resolves(mockSystemUserWithRolesResponse); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const systemUserObject = await authorizationService.getSystemUserObject(); + + expect(systemUserObject).to.equal(mockSystemUserWithRolesResponse); + }); +}); + +describe('getSystemUserWithRoles', function () { + afterEach(() => { + sinon.restore(); + }); + + it('returns null if the keycloak token is null', async function () { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const authorizationService = new AuthorizationService(mockDBConnection); + + const result = await authorizationService.getSystemUserWithRoles(); + + expect(result).to.be.null; + }); + + it('returns null if the system user identifier is null', async function () { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const authorizationService = new AuthorizationService(mockDBConnection, { + keycloakToken: { preferred_username: '' } + }); + + const result = await authorizationService.getSystemUserWithRoles(); + + expect(result).to.be.null; + }); + + it('returns a UserObject', async function () { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const userObjectMock = new Models.user.UserObject(); + sinon.stub(UserService.prototype, 'getUserByGuid').resolves((userObjectMock as unknown) as any); + + const authorizationService = new AuthorizationService(mockDBConnection, { + keycloakToken: { preferred_username: 'userIdentifier@IDIR' } + }); + + const result = await authorizationService.getSystemUserWithRoles(); + + expect(result).to.equal(userObjectMock); + }); +}); diff --git a/api/src/services/authorization-service.ts b/api/src/services/authorization-service.ts new file mode 100644 index 0000000000..7af5a400e6 --- /dev/null +++ b/api/src/services/authorization-service.ts @@ -0,0 +1,323 @@ +import { SOURCE_SYSTEM } from '../constants/database'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../constants/roles'; +import { IDBConnection } from '../database/db'; +import { Models } from '../models'; +import { getKeycloakSource, getUserGuid } from '../utils/keycloak-utils'; +import { DBService } from './db-service'; +import { UserService } from './user-service'; + +export enum AuthorizeOperator { + AND = 'and', + OR = 'or' +} + +/** + * Authorization rule that checks if a user's system role matches at least one of the required system roles. + * + * @export + * @interface AuthorizeBySystemRoles + */ +export interface AuthorizeBySystemRoles { + validSystemRoles: SYSTEM_ROLE[]; + discriminator: 'SystemRole'; +} + +/** + * Authorization rule that checks if a user is a known and active user of the system. + * + * @export + * @interface AuthorizeBySystemUser + */ +export interface AuthorizeBySystemUser { + discriminator: 'SystemUser'; +} + +/** + * Authorization rule that checks if a jwt token's client id matches at least one of the required client ids. + * + * Note: This is specifically for system-to-system communication. + * + * @export + * @interface AuthorizeByServiceClient + */ +export interface AuthorizeByServiceClient { + validServiceClientIDs: SOURCE_SYSTEM[]; + discriminator: 'ServiceClient'; +} + +export interface AuthorizeByProjectPermission { + validProjectPermissions: PROJECT_PERMISSION[]; + projectId: number; + discriminator: 'ProjectPermission'; +} + +export type AuthorizeRule = + | AuthorizeBySystemRoles + | AuthorizeBySystemUser + | AuthorizeByServiceClient + | AuthorizeByProjectPermission; + +export type AuthorizeConfigOr = { + [AuthorizeOperator.AND]?: never; + [AuthorizeOperator.OR]: AuthorizeRule[]; +}; + +export type AuthorizeConfigAnd = { + [AuthorizeOperator.AND]: AuthorizeRule[]; + [AuthorizeOperator.OR]?: never; +}; + +export type AuthorizationScheme = AuthorizeConfigAnd | AuthorizeConfigOr; + +export class AuthorizationService extends DBService { + _userService = new UserService(this.connection); + _systemUser: Models.user.UserObject | undefined = undefined; + _keycloakToken: object | undefined = undefined; + + constructor(connection: IDBConnection, init?: { systemUser?: Models.user.UserObject; keycloakToken?: object }) { + super(connection); + + this._systemUser = init?.systemUser; + this._keycloakToken = init?.keycloakToken; + } + + get systemUser(): Models.user.UserObject | undefined { + return this._systemUser; + } + + /** + * Execute the `authorizationScheme` against the current user, and return `true` if they have access, `false` otherwise. + * + * @param {UserObject} systemUserObject + * @param {AuthorizationScheme} authorizationScheme + * @param {IDBConnection} connection + * @return {*} {Promise} `true` if the `authorizationScheme` indicates the user has access, `false` otherwise. + */ + async executeAuthorizationScheme(authorizationScheme: AuthorizationScheme): Promise { + if (authorizationScheme.and) { + return (await this.executeAuthorizeConfig(authorizationScheme.and)).every((item) => item); + } else { + return (await this.executeAuthorizeConfig(authorizationScheme.or)).some((item) => item); + } + } + + /** + * Execute an array of `AuthorizeRule`, returning an array of boolean results. + * + * @param {AuthorizeRule[]} authorizeRules + * @return {*} {Promise} + */ + async executeAuthorizeConfig(authorizeRules: AuthorizeRule[]): Promise { + const authorizeResults: boolean[] = []; + + for (const authorizeRule of authorizeRules) { + switch (authorizeRule.discriminator) { + case 'SystemRole': + authorizeResults.push(await this.authorizeBySystemRole(authorizeRule)); + break; + case 'SystemUser': + authorizeResults.push(await this.authorizeBySystemUser()); + break; + case 'ServiceClient': + authorizeResults.push(await this.authorizeByServiceClient(authorizeRule)); + break; + case 'ProjectPermission': + authorizeResults.push(await this.authorizeByProjectPermission(authorizeRule)); + break; + } + } + + return authorizeResults; + } + + async authorizeByProjectPermission(authorizeProjectPermission: AuthorizeByProjectPermission): Promise { + if (!authorizeProjectPermission) { + // Cannot verify user roles + return false; + } + + const systemUserObject = this._systemUser || (await this.getSystemUserObject()); + + if (!systemUserObject) { + // Cannot verify user roles + return false; + } + + // Cache the _systemUser for future use, if needed + this._systemUser = systemUserObject; + + if (systemUserObject.record_end_date) { + //system user has an expired record + return false; + } + + // Check if the user has at least 1 of the valid roles + return AuthorizationService.hasAtLeastOneValidValue( + authorizeProjectPermission.validProjectPermissions, + systemUserObject?.role_names + ); + } + + /** + * Check if the user has the system administrator role. + * + * @return {*} {boolean} `true` if the user is a system administrator, `false` otherwise. + */ + async authorizeSystemAdministrator(): Promise { + const systemUserObject = this._systemUser || (await this.getSystemUserObject()); + + if (!systemUserObject) { + // Cannot verify user roles + return false; + } + + // Cache the _systemUser for future use, if needed + this._systemUser = systemUserObject; + + return systemUserObject.role_names.includes(SYSTEM_ROLE.SYSTEM_ADMIN); + } + + /** + * Check that the user has at least one of the valid system roles specified in `authorizeSystemRoles.validSystemRoles`. + * + * @param {AuthorizeBySystemRoles} authorizeSystemRoles + * @return {*} {boolean} `true` if the user has at least one valid system role role, or no valid system roles are + * specified; `false` otherwise. + */ + async authorizeBySystemRole(authorizeSystemRoles: AuthorizeBySystemRoles): Promise { + if (!authorizeSystemRoles) { + // Cannot verify user roles + return false; + } + + const systemUserObject = this._systemUser || (await this.getSystemUserObject()); + + if (!systemUserObject) { + // Cannot verify user roles + return false; + } + + // Cache the _systemUser for future use, if needed + this._systemUser = systemUserObject; + + if (systemUserObject.record_end_date) { + //system user has an expired record + return false; + } + + // Check if the user has at least 1 of the valid roles + return AuthorizationService.hasAtLeastOneValidValue( + authorizeSystemRoles.validSystemRoles, + systemUserObject?.role_names + ); + } + + /** + * Check if the user is a valid system user. + * + * @return {*} {Promise} `Promise` if the user is a valid system user, `Promise` otherwise. + */ + async authorizeBySystemUser(): Promise { + const systemUserObject = this._systemUser || (await this.getSystemUserObject()); + + if (!systemUserObject) { + // Cannot verify user roles + return false; + } + + // Cache the _systemUser for future use, if needed + this._systemUser = systemUserObject; + + // User is a valid system user + return true; + } + + /** + * Check if the user is a valid system client. + * + * @return {*} {Promise} `Promise` if the user is a valid system user, `Promise` otherwise. + */ + async authorizeByServiceClient(authorizeServiceClient: AuthorizeByServiceClient): Promise { + if (!this._keycloakToken) { + // Cannot verify token source + return false; + } + + const source = getKeycloakSource(this._keycloakToken); + + if (!source) { + // Cannot verify token source + return false; + } + + return AuthorizationService.hasAtLeastOneValidValue(authorizeServiceClient.validServiceClientIDs, source); + } + + /** + * Compares an array of incoming values against an array of valid values. + * + * @param {(string | string[])} validValues valid values to match against + * @param {(string | string[])} incomingValues incoming values to check against the valid values + * @return {*} {boolean} true if the incomingValues has at least 1 of the validValues or no valid values are + * specified, false otherwise + */ + static hasAtLeastOneValidValue = function ( + validValues: string | string[], + incomingValues: string | string[] + ): boolean { + if (!validValues || !validValues.length) { + return true; + } + + if (!Array.isArray(validValues)) { + validValues = [validValues]; + } + + if (!Array.isArray(incomingValues)) { + incomingValues = [incomingValues]; + } + + for (const validRole of validValues) { + if (incomingValues.includes(validRole)) { + return true; + } + } + + return false; + }; + + async getSystemUserObject(): Promise { + let systemUserWithRoles; + + try { + systemUserWithRoles = await this.getSystemUserWithRoles(); + } catch { + return null; + } + + if (!systemUserWithRoles) { + return null; + } + + return systemUserWithRoles; + } + + /** + * Finds a single user based on their keycloak token information. + * + * @return {*} {(Promise)} + */ + async getSystemUserWithRoles(): Promise { + if (!this._keycloakToken) { + return null; + } + + const userGuid = getUserGuid(this._keycloakToken); + + if (!userGuid) { + return null; + } + + return this._userService.getUserByGuid(userGuid); + } +} diff --git a/api/src/services/user-service.ts b/api/src/services/user-service.ts index e8bc69e763..9a6460a144 100644 --- a/api/src/services/user-service.ts +++ b/api/src/services/user-service.ts @@ -85,14 +85,18 @@ export class UserService extends DBService { userIdentifier: string, identitySource: string, displayName: string, - email: string + email: string, + givenName?: string, + familyName?: string ): Promise<{ system_user_id: number }> { const response = await this.userRepository.addSystemUser( userGuid, userIdentifier, identitySource, displayName, - email + email, + givenName, + familyName ); return response; @@ -127,7 +131,9 @@ export class UserService extends DBService { userIdentifier: string, identitySource: string, displayName: string, - email: string + email: string, + givenName?: string, + familyName?: string ): Promise { // Check if the user exists in SIMS const existingUser = userGuid @@ -143,7 +149,15 @@ export class UserService extends DBService { } // Found no existing user, add them - const newUserId = await this.addSystemUser(userGuid, userIdentifier, identitySource, displayName, email); + const newUserId = await this.addSystemUser( + userGuid, + userIdentifier, + identitySource, + displayName, + email, + givenName, + familyName + ); // fetch the new user object return this.getUserById(newUserId.system_user_id); @@ -206,6 +220,18 @@ export class UserService extends DBService { return this.userRepository.addUserSystemRoles(systemUserId, roleIds); } + /** + * Adds the specified role by name to the user. + * + * @param {number} systemUserId + * @param {string} roleName + * @return {*} + * @memberof UserService + */ + async addUserSystemRoleByName(systemUserId: number, roleName: string) { + return this.userRepository.addUserSystemRoleByName(systemUserId, roleName); + } + /** * Delete all project participation roles for the specified system user. * diff --git a/api/src/utils/keycloak-utils.test.ts b/api/src/utils/keycloak-utils.test.ts index 30059cbd3d..c1f235db41 100644 --- a/api/src/utils/keycloak-utils.test.ts +++ b/api/src/utils/keycloak-utils.test.ts @@ -1,239 +1,109 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; -import { - BceidBasicUserInformation, - BceidBusinessUserInformation, - coerceUserIdentitySource, - DatabaseUserInformation, - getUserGuid, - getUserIdentifier, - getUserIdentitySource, - IdirUserInformation, - isBceidBasicUserInformation, - isBceidBusinessUserInformation, - isDatabaseUserInformation, - isIdirUserInformation -} from './keycloak-utils'; +import { coerceUserIdentitySource, getUserGuid, getUserIdentifier, getUserIdentitySource } from './keycloak-utils'; describe('keycloakUtils', () => { describe('getUserGuid', () => { - it('returns idir guid', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserGuid(keycloakUserInformation); - - expect(response).to.equal('123456789'); - }); + it('returns null response when null keycloakToken provided', () => { + const response = getUserGuid((null as unknown) as object); - it('returns bceid basic guid', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserGuid(keycloakUserInformation); - - expect(response).to.equal('123456789'); + expect(response).to.be.null; }); - it('returns bceid business guid', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserGuid(keycloakUserInformation); - - expect(response).to.equal('123456789'); - }); + it('returns null response when a keycloakToken is provided with a missing preferred_username field', () => { + const response = getUserGuid({ idir_username: 'username' }); - it('returns database guid', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; + expect(response).to.be.null; + }); - const response = getUserGuid(keycloakUserInformation); + it('returns their guid', () => { + const response = getUserGuid({ preferred_username: 'aaaaa@idir' }); - expect(response).to.equal('123456789'); + expect(response).to.equal('aaaaa'); }); }); describe('getUserIdentifier', () => { - it('returns idir username', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentifier(keycloakUserInformation); - - expect(response).to.equal('tname'); - }); + it('returns null response when null keycloakToken provided', () => { + const response = getUserIdentifier((null as unknown) as object); - it('returns bceid basic username', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentifier(keycloakUserInformation); - - expect(response).to.equal('tname'); + expect(response).to.be.null; }); - it('returns bceid business username', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentifier(keycloakUserInformation); - - expect(response).to.equal('tname'); + it('returns null response when a keycloakToken is provided with a missing username field', () => { + const response = getUserIdentifier({ preferred_username: 'aaaaa@idir' }); + + expect(response).to.be.null; }); - it('returns database username', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; + it('returns the identifier from their IDIR username', () => { + const response = getUserIdentifier({ preferred_username: 'aaaaa@idir', idir_username: 'idiruser' }); - const response = getUserIdentifier(keycloakUserInformation); + expect(response).to.equal('idiruser'); + }); + + it('returns the identifier from their BCeID username', () => { + const response = getUserIdentifier({ preferred_username: 'aaaaa@idir', bceid_username: 'bceiduser' }); - expect(response).to.equal('biohub_dapi_v1'); + expect(response).to.equal('bceiduser'); }); }); describe('getUserIdentitySource', () => { - it('returns idir source', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); + it('returns non null response when null keycloakToken provided', () => { + const response = getUserIdentitySource((null as unknown) as object); + + expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); + }); + + it('returns non null response when valid keycloakToken provided with no preferred_username', () => { + const response = getUserIdentitySource({}); + + expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); + }); + + it('returns non null response when valid keycloakToken provided with null preferred_username', () => { + const response = getUserIdentitySource({ preferred_username: null }); + + expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); + }); + + it('returns non null response when valid keycloakToken provided with no source', () => { + const response = getUserIdentitySource({ preferred_username: 'username' }); + + expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); + }); + + it('returns non null response when valid keycloakToken provided with idir source', () => { + const response = getUserIdentitySource({ preferred_username: 'username@idir' }); expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.IDIR); }); - it('returns bceid basic source', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); + it('returns non null response when valid keycloakToken provided with bceid basic source', () => { + const response = getUserIdentitySource({ preferred_username: 'username@bceidbasic' }); expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.BCEID_BASIC); }); - it('returns bceid business source', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); + it('returns non null response when valid keycloakToken provided with bceid business source', () => { + const response = getUserIdentitySource({ preferred_username: 'username@bceidbusiness' }); expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS); }); - it('returns database source', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = getUserIdentitySource(keycloakUserInformation); + it('returns non null response when valid keycloakToken provided with database source', () => { + const response = getUserIdentitySource({ preferred_username: 'username@database' }); expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); }); + + it('returns non null response when valid keycloakToken provided with system source', () => { + const response = getUserIdentitySource({ preferred_username: 'username@system' }); + + expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.SYSTEM); + }); }); describe('coerceUserIdentitySource', () => { @@ -266,370 +136,13 @@ describe('keycloakUtils', () => { const response = coerceUserIdentitySource('database'); expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.DATABASE); }); - }); - - describe('getKeycloakUserInformationFromKeycloakToken', () => { - it('returns valid idir token information', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - it('returns valid bceid basic token information', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - - it('returns valid bceid business token information', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; - }); - - it('returns valid database token information', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = getUserIdentitySource(keycloakUserInformation); - - expect(response).not.to.be.null; + it('should coerce system user identity to SYSTEM', () => { + const response = coerceUserIdentitySource('system'); + expect(response).to.equal(SYSTEM_IDENTITY_SOURCE.SYSTEM); }); }); - - describe('isIdirUserInformation', () => { - it('returns true when idir token information provided', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isIdirUserInformation(keycloakUserInformation); - - expect(response).to.be.true; - }); - - it('returns false when bceid basic token information provided', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isIdirUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns false when bceid business token information provided', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isIdirUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns false when database token information provided', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = isIdirUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - }); - - describe('isBceidBasicUserInformation', () => { - it('returns false when idir token information provided', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isBceidBasicUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns true when bceid basic token information provided', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isBceidBasicUserInformation(keycloakUserInformation); - - expect(response).to.be.true; - }); - - it('returns false when bceid business token information provided', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isBceidBasicUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns false when database token information provided', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = isBceidBasicUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - }); - - describe('isBceidBusinessUserInformation', () => { - it('returns false when idir token information provided', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isBceidBusinessUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns false when bceid basic token information provided', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isBceidBusinessUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns true when bceid business token information provided', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isBceidBusinessUserInformation(keycloakUserInformation); - - expect(response).to.be.true; - }); - - it('returns false when database token information provided', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = isBceidBusinessUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - }); - - describe('isDatabaseUserInformation', () => { - it('returns false when idir token information provided', () => { - const keycloakUserInformation: IdirUserInformation = { - idir_user_guid: '123456789', - identity_provider: 'idir', - idir_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@idir', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isDatabaseUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns false when bceid basic token information provided', () => { - const keycloakUserInformation: BceidBasicUserInformation = { - bceid_user_guid: '123456789', - identity_provider: 'bceidbasic', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbasic', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isDatabaseUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns false when bceid business token information provided', () => { - const keycloakUserInformation: BceidBusinessUserInformation = { - bceid_business_guid: '1122334455', - bceid_business_name: 'Business Name', - bceid_user_guid: '123456789', - identity_provider: 'bceidbusiness', - bceid_username: 'tname', - name: 'Test Name', - preferred_username: '123456789@bceidbusiness', - display_name: 'Test Name', - email: 'email@email.com', - email_verified: false, - given_name: 'Test', - family_name: '' - }; - - const response = isDatabaseUserInformation(keycloakUserInformation); - - expect(response).to.be.false; - }); - - it('returns true when database token information provided', () => { - const keycloakUserInformation: DatabaseUserInformation = { - database_user_guid: '123456789', - identity_provider: 'database', - username: 'biohub_dapi_v1' - }; - - const response = isDatabaseUserInformation(keycloakUserInformation); - - expect(response).to.be.true; - }); + describe('getKeycloakSource', () => { + //TODO }); }); diff --git a/api/src/utils/keycloak-utils.ts b/api/src/utils/keycloak-utils.ts index f8d4986d11..8453c158b0 100644 --- a/api/src/utils/keycloak-utils.ts +++ b/api/src/utils/keycloak-utils.ts @@ -1,112 +1,40 @@ -import { z } from 'zod'; -import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; +import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../constants/database'; /** - * User information from a verified IDIR Keycloak token. - */ -export const IdirUserInformation = z.object({ - idir_user_guid: z.string().toLowerCase(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.IDIR.toLowerCase()), - idir_username: z.string().toLowerCase(), - email_verified: z.boolean(), - name: z.string(), - preferred_username: z.string(), - display_name: z.string(), - given_name: z.string(), - family_name: z.string(), - email: z.string() -}); -export type IdirUserInformation = z.infer; - -/** - * User information from a verified BCeID Basic Keycloak token. - */ -export const BceidBasicUserInformation = z.object({ - bceid_user_guid: z.string().toLowerCase(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.BCEID_BASIC.toLowerCase()), - bceid_username: z.string().toLowerCase(), - email_verified: z.boolean(), - name: z.string(), - preferred_username: z.string(), - display_name: z.string(), - given_name: z.string(), - family_name: z.string(), - email: z.string() -}); -export type BceidBasicUserInformation = z.infer; - -/** - * User information from a verified BCeID Keycloak token. - */ -export const BceidBusinessUserInformation = z.object({ - bceid_business_guid: z.string().toLowerCase(), - bceid_business_name: z.string(), - bceid_user_guid: z.string().toLowerCase(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS.toLowerCase()), - bceid_username: z.string().toLowerCase(), - email_verified: z.boolean(), - name: z.string(), - preferred_username: z.string(), - display_name: z.string(), - given_name: z.string(), - family_name: z.string(), - email: z.string() -}); -export type BceidBusinessUserInformation = z.infer; - -/** - * User information for an internal database user. + * Parses out the user's GUID from a keycloak token, which is extracted from the + * `preferred_username` property. * - * Note: Spoofs a keycloak token in order to leverage the same keycloak/database code that would normally be - * called when queries are executed on behalf of a real human user. - */ -export const DatabaseUserInformation = z.object({ - database_user_guid: z.string(), - identity_provider: z.literal(SYSTEM_IDENTITY_SOURCE.DATABASE.toLowerCase()), - username: z.string() -}); -export type DatabaseUserInformation = z.infer; - -/** - * User information from either an IDIR or BCeID Basic or BCeID Business Keycloak token. - */ -export const KeycloakUserInformation = z.discriminatedUnion('identity_provider', [ - IdirUserInformation, - BceidBasicUserInformation, - BceidBusinessUserInformation, - DatabaseUserInformation -]); -export type KeycloakUserInformation = z.infer; - -/** - * Returns the user information guid. + * @example getUserGuid({ preferred_username: 'aaabbaaa@idir' }) // => 'aaabbaaa' * - * @param {KeycloakUserInformation} keycloakUserInformation - * @return {*} {(string | null)} + * @param {object} keycloakToken + * @return {*} {(string | null)} */ -export const getUserGuid = (keycloakUserInformation: KeycloakUserInformation): string => { - if (isIdirUserInformation(keycloakUserInformation)) { - return keycloakUserInformation.idir_user_guid; - } +export const getUserGuid = (keycloakToken: object): string | null => { + const userIdentifier = keycloakToken?.['preferred_username']?.split('@')?.[0]; - if (isBceidBusinessUserInformation(keycloakUserInformation) || isBceidBasicUserInformation(keycloakUserInformation)) { - return keycloakUserInformation.bceid_user_guid; + if (!userIdentifier) { + return null; } - return keycloakUserInformation.database_user_guid; + return userIdentifier; }; /** - * Returns the user information identity source ('idir', 'bceidbasic', 'database, etc) and maps it to a known - * `SYSTEM_IDENTITY_SOURCE`. + * Parses out the preferred_username identity source ('idir', 'bceidbasic', etc.) from the token and maps it to a known + * `SYSTEM_IDENTITY_SOURCE`. If the `identity_provider` field in the keycloak token object is undefined, then the + * identity source is inferred from the `preferred_username` field as a contingency. * - * @example getUserIdentitySource({ identity_provider: 'idir' }) => SYSTEM_IDENTITY_SOURCE.IDIR + * @example getUserIdentitySource({ ...token, identity_provider: 'bceidbasic' }) => SYSTEM_IDENTITY_SOURCE.BCEID_BASIC + * @example getUserIdentitySource({ preferred_username: 'aaaa@idir' }) => SYSTEM_IDENTITY_SOURCE.IDIR * - * @param {Record} keycloakToken - * @return {*} {SYSTEM_IDENTITY_SOURCE} the identity source belonging to type SYSTEM_IDENTITY_SOURCE + * @param {object} keycloakToken + * @return {*} {SYSTEM_IDENTITY_SOURCE} */ -export const getUserIdentitySource = (keycloakUserInformation: KeycloakUserInformation): SYSTEM_IDENTITY_SOURCE => { - return coerceUserIdentitySource(keycloakUserInformation.identity_provider); +export const getUserIdentitySource = (keycloakToken: object): SYSTEM_IDENTITY_SOURCE => { + const userIdentitySource: string = + keycloakToken?.['identity_provider'] || keycloakToken?.['preferred_username']?.split('@')?.[1]; + + return coerceUserIdentitySource(userIdentitySource); }; /** @@ -116,20 +44,23 @@ export const getUserIdentitySource = (keycloakUserInformation: KeycloakUserInfor * * @example coerceUserIdentitySource('idir') => 'idir' satisfies SYSTEM_IDENTITY_SOURCE.IDIR * - * @param {string} userIdentitySource - * @return {*} {SYSTEM_IDENTITY_SOURCE} the identity source belonging to type SYSTEM_IDENTITY_SOURCE + * @param userIdentitySource the identity source string + * @returns {*} {SYSTEM_IDENTITY_SOURCE} the identity source belonging to type SYSTEM_IDENTITY_SOURCE */ -export const coerceUserIdentitySource = (userIdentitySource: string): SYSTEM_IDENTITY_SOURCE => { +export const coerceUserIdentitySource = (userIdentitySource: string | null): SYSTEM_IDENTITY_SOURCE => { switch (userIdentitySource?.toUpperCase()) { - case SYSTEM_IDENTITY_SOURCE.IDIR: - return SYSTEM_IDENTITY_SOURCE.IDIR; - case SYSTEM_IDENTITY_SOURCE.BCEID_BASIC: return SYSTEM_IDENTITY_SOURCE.BCEID_BASIC; case SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS: return SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS; + case SYSTEM_IDENTITY_SOURCE.IDIR: + return SYSTEM_IDENTITY_SOURCE.IDIR; + + case SYSTEM_IDENTITY_SOURCE.SYSTEM: + return SYSTEM_IDENTITY_SOURCE.SYSTEM; + case SYSTEM_IDENTITY_SOURCE.DATABASE: return SYSTEM_IDENTITY_SOURCE.DATABASE; @@ -140,63 +71,42 @@ export const coerceUserIdentitySource = (userIdentitySource: string): SYSTEM_IDE }; /** - * Returns the user information identifier (aka: username). + * Parses out the user's identifier from a keycloak token. * - * @example getUserIdentifier({ idir_username: 'jsmith' }) => 'jsmith' + * @example getUserIdentifier({ ....token, bceid_username: 'jsmith@idir' }) => 'jsmith' * - * @param {KeycloakUserInformation} keycloakUserInformation - * @return {*} {string} + * @param {object} keycloakToken + * @return {*} {(string | null)} */ -export const getUserIdentifier = (keycloakUserInformation: KeycloakUserInformation): string => { - if (isIdirUserInformation(keycloakUserInformation)) { - return keycloakUserInformation.idir_username; - } +export const getUserIdentifier = (keycloakToken: object): string | null => { + const userIdentifier = keycloakToken?.['idir_username'] || keycloakToken?.['bceid_username']; - if (isBceidBusinessUserInformation(keycloakUserInformation) || isBceidBasicUserInformation(keycloakUserInformation)) { - return keycloakUserInformation.bceid_username; + if (!userIdentifier) { + return null; } - return keycloakUserInformation.username; + return userIdentifier; }; /** - * Get a `KeycloakUserInformation` object from a Keycloak Bearer Token (IDIR or BCeID Basic or BCeID Business token). + * Parses out the `clientId` and `azp` fields from the token and maps them to a known `SOURCE_SYSTEM`, or null if no + * match is found. * - * @param {Record} keycloakToken - * @return {*} {(KeycloakUserInformation | null)} + * @param {object} keycloakToken + * @return {*} {(SOURCE_SYSTEM | null)} */ -export const getKeycloakUserInformationFromKeycloakToken = ( - keycloakToken: Record -): KeycloakUserInformation | null => { - const result = KeycloakUserInformation.safeParse(keycloakToken); +export const getKeycloakSource = (keycloakToken: object): SOURCE_SYSTEM | null => { + const clientId = keycloakToken?.['clientId']?.toUpperCase(); - if (!result.success) { + const azp = keycloakToken?.['azp']?.toUpperCase(); + + if (!clientId && !azp) { return null; } - return result.data; -}; - -export const isIdirUserInformation = ( - keycloakUserInformation: KeycloakUserInformation -): keycloakUserInformation is IdirUserInformation => { - return keycloakUserInformation.identity_provider === SYSTEM_IDENTITY_SOURCE.IDIR.toLowerCase(); -}; - -export const isBceidBasicUserInformation = ( - keycloakUserInformation: KeycloakUserInformation -): keycloakUserInformation is BceidBasicUserInformation => { - return keycloakUserInformation.identity_provider === SYSTEM_IDENTITY_SOURCE.BCEID_BASIC.toLowerCase(); -}; - -export const isBceidBusinessUserInformation = ( - keycloakUserInformation: KeycloakUserInformation -): keycloakUserInformation is BceidBusinessUserInformation => { - return keycloakUserInformation.identity_provider === SYSTEM_IDENTITY_SOURCE.BCEID_BUSINESS.toLowerCase(); -}; + if ([clientId, azp].includes(SOURCE_SYSTEM['SIMS-SVC-4464'])) { + return SOURCE_SYSTEM['SIMS-SVC-4464']; + } -export const isDatabaseUserInformation = ( - keycloakUserInformation: KeycloakUserInformation -): keycloakUserInformation is DatabaseUserInformation => { - return keycloakUserInformation.identity_provider === SYSTEM_IDENTITY_SOURCE.DATABASE.toLowerCase(); + return null; }; diff --git a/database/src/migrations/20230905000000_update_user_source_system.ts b/database/src/migrations/20230905000000_update_user_source_system.ts new file mode 100644 index 0000000000..b74b82907a --- /dev/null +++ b/database/src/migrations/20230905000000_update_user_source_system.ts @@ -0,0 +1,25 @@ +import { Knex } from 'knex'; + +/** + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET search_path=biohub; + + -- Populate new identity source + INSERT INTO user_identity_source (name, description, notes, record_effective_date) VALUES ('SYSTEM', 'SYSTEM user source system.', 'A system user.', now()); + + -- Populate new SIM service account user + insert into system_user (user_identity_source_id, user_identifier, user_guid, display_name, email, record_effective_date, create_date, create_user) + values ((select user_identity_source_id from user_identity_source where name = 'SYSTEM' and record_end_date is null), 'service-account-SIMS-SVC-4464', 'SIMS-SVC-4464', 'service-account-SIMS-SVC-4464', 'sims@email.com', now(), now(), 1); + +`); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} From 9d6f347bb6e61972c4639fd7cb96027134810595 Mon Sep 17 00:00:00 2001 From: Kjartan <35311998+KjartanE@users.noreply.github.com> Date: Tue, 12 Sep 2023 14:43:22 -0700 Subject: [PATCH 3/9] fix regular insert user (#1089) --- api/src/paths/user/add.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/api/src/paths/user/add.ts b/api/src/paths/user/add.ts index ab81ca477c..29c9692812 100644 --- a/api/src/paths/user/add.ts +++ b/api/src/paths/user/add.ts @@ -3,7 +3,6 @@ import { Operation } from 'express-openapi'; import { SOURCE_SYSTEM, SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; import { SYSTEM_ROLE } from '../../constants/roles'; import { getDBConnection, getServiceAccountDBConnection } from '../../database/db'; -import { HTTP400 } from '../../errors/http-error'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; import { UserService } from '../../services/user-service'; import { getKeycloakSource } from '../../utils/keycloak-utils'; @@ -134,12 +133,6 @@ export function addSystemRoleUser(): RequestHandler { const sourceSystem = getKeycloakSource(req['keycloak_token']); - if (!sourceSystem) { - throw new HTTP400('Failed to identify known submission source system', [ - 'token did not contain a clientId/azp or clientId/azp value is unknown' - ]); - } - const connection = sourceSystem ? getServiceAccountDBConnection(sourceSystem) : getDBConnection(req['keycloak_token']); From 9e3686b9808d4c0439a5d4fd288bd60e84f2f7a3 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Tue, 12 Sep 2023 15:02:30 -0700 Subject: [PATCH 4/9] SIMSBIOHUB-192: Add Site Selection Strategy + stratums to survey form (#1083) * Added survey site sampling strategies and stratums to the Create Survey and Edit Survey pages * Added service and repo methods for creating, updating and deleting survey site selection strategies and stratums * Created survey site selection strategy API schema and model properties --- api/src/models/survey-create.ts | 13 + api/src/models/survey-update.ts | 13 + api/src/models/survey-view.ts | 2 + api/src/paths/codes.ts | 17 +- .../project/{projectId}/survey/create.ts | 28 ++ .../{projectId}/survey/{surveyId}/update.ts | 28 ++ .../survey/{surveyId}/view.test.ts | 8 + .../{projectId}/survey/{surveyId}/view.ts | 29 +++ api/src/repositories/code-repository.ts | 19 +- .../site-selection-strategy-repository.ts | 244 ++++++++++++++++++ api/src/services/code-service.test.ts | 1 + api/src/services/code-service.ts | 43 +-- api/src/services/eml-service.test.ts | 1 + .../site-selection-strategy-service.test.ts | 189 ++++++++++++++ .../site-selection-strategy-service.ts | 149 +++++++++++ api/src/services/survey-service.test.ts | 19 ++ api/src/services/survey-service.ts | 44 ++++ app/src/features/surveys/CreateSurveyPage.tsx | 11 +- .../components/SamplingMethodsForm.tsx | 43 +++ .../components/StratumCreateOrEditDialog.tsx | 90 +++++++ .../surveys/components/SurveyBlockSection.tsx | 4 +- .../components/SurveySiteSelectionForm.tsx | 128 +++++++++ .../surveys/components/SurveyStratumForm.tsx | 204 +++++++++++++++ .../features/surveys/edit/EditSurveyForm.tsx | 9 +- .../features/surveys/view/SurveyAnimals.tsx | 6 +- app/src/interfaces/useCodesApi.interface.ts | 1 + app/src/interfaces/useSurveyApi.interface.ts | 4 +- app/src/test-helpers/code-helpers.ts | 4 + app/src/test-helpers/survey-helpers.ts | 4 + app/src/utils/Utils.test.ts | 30 ++- app/src/utils/Utils.ts | 18 ++ 31 files changed, 1368 insertions(+), 35 deletions(-) create mode 100644 api/src/repositories/site-selection-strategy-repository.ts create mode 100644 api/src/services/site-selection-strategy-service.test.ts create mode 100644 api/src/services/site-selection-strategy-service.ts create mode 100644 app/src/features/surveys/components/SamplingMethodsForm.tsx create mode 100644 app/src/features/surveys/components/StratumCreateOrEditDialog.tsx create mode 100644 app/src/features/surveys/components/SurveySiteSelectionForm.tsx create mode 100644 app/src/features/surveys/components/SurveyStratumForm.tsx diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 0ef9b427f7..fbbc381c2c 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { SurveyStratum } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; export class PostSurveyObject { @@ -12,6 +13,7 @@ export class PostSurveyObject { agreements: PostAgreementsData; participants: PostParticipationData[]; partnerships: PostPartnershipsData; + site_selection: PostSiteSelectionData; blocks: PostSurveyBlock[]; constructor(obj?: any) { @@ -28,10 +30,21 @@ export class PostSurveyObject { this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PostParticipationData(p))) || []; this.partnerships = (obj?.partnerships && new PostPartnershipsData(obj.partnerships)) || null; + this.site_selection = (obj?.site_selection && new PostSiteSelectionData(obj)) || null; this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } } +export class PostSiteSelectionData { + strategies: string[]; + stratums: SurveyStratum[]; + + constructor(obj?: any) { + this.strategies = obj?.site_selection?.strategies ?? []; + this.stratums = obj?.site_selection?.stratums ?? []; + } +} + /** * Processes POST /project partnerships data * diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index e54623a04a..255a5cf08d 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { SurveyStratum, SurveyStratumRecord } from '../repositories/site-selection-strategy-repository'; import { PostSurveyBlock } from '../repositories/survey-block-repository'; export class PutSurveyObject { @@ -11,6 +12,7 @@ export class PutSurveyObject { location: PutSurveyLocationData; participants: PutSurveyParticipantsData[]; partnerships: PutPartnershipsData; + site_selection: PutSiteSelectionData; blocks: PostSurveyBlock[]; constructor(obj?: any) { @@ -26,10 +28,21 @@ export class PutSurveyObject { this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PutSurveyParticipantsData(p))) || []; this.partnerships = (obj?.partnerships && new PutPartnershipsData(obj.partnerships)) || null; + this.site_selection = (obj?.site_selection && new PutSiteSelectionData(obj)) || null; this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } } +export class PutSiteSelectionData { + strategies: string[]; + stratums: Array; + + constructor(obj?: any) { + this.strategies = obj?.site_selection?.strategies ?? []; + this.stratums = obj?.site_selection?.stratums ?? []; + } +} + export class PutPartnershipsData { indigenous_partnerships: number[]; stakeholder_partnerships: string[]; diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index a2b77962b7..d0aab6ac01 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -1,6 +1,7 @@ import { Feature } from 'geojson'; import { SurveyMetadataPublish } from '../repositories/history-publish-repository'; import { IPermitModel } from '../repositories/permit-repository'; +import { SiteSelectionData } from '../repositories/site-selection-strategy-repository'; import { SurveyBlockRecord } from '../repositories/survey-block-repository'; import { SurveyUser } from '../repositories/survey-participation-repository'; @@ -14,6 +15,7 @@ export type SurveyObject = { location: GetSurveyLocationData; participants: SurveyUser[]; partnerships: ISurveyPartnerships; + site_selection: SiteSelectionData; blocks: SurveyBlockRecord[]; }; diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 32d2e6e054..34f3a7bc5e 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -36,7 +36,8 @@ GET.apiDoc = { 'field_methods', 'ecological_seasons', 'intended_outcomes', - 'vantage_codes' + 'vantage_codes', + 'site_selection_strategies' ], properties: { management_action_type: { @@ -317,6 +318,20 @@ GET.apiDoc = { } } } + }, + site_selection_strategies: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'number' + }, + name: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index 0115b203aa..71b8de12c5 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -63,6 +63,7 @@ POST.apiDoc = { 'partnerships', 'proprietor', 'purpose_and_methodology', + 'site_selection', 'location', 'agreements', 'participants' @@ -219,6 +220,33 @@ POST.apiDoc = { } } }, + site_selection: { + type: 'object', + required: ['strategies', 'stratums'], + properties: { + strategies: { + type: 'array', + items: { + type: 'string' + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + }, location: { type: 'object', properties: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts index d1a7b49fb9..048fcf21f0 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts @@ -72,6 +72,7 @@ PUT.apiDoc = { 'partnerships', 'proprietor', 'purpose_and_methodology', + 'site_selection', 'location' ], properties: { @@ -266,6 +267,33 @@ PUT.apiDoc = { } } }, + site_selection: { + type: 'object', + required: ['strategies', 'stratums'], + properties: { + strategies: { + type: 'array', + items: { + type: 'string' + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + }, location: { type: 'object', required: ['survey_area_name', 'geometry'], diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts index 21a0c58e65..4cedaa982e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts @@ -62,6 +62,10 @@ describe('survey/{surveyId}/view', () => { indigenous_partnerships: [], stakeholder_partnerships: [] }, + site_selection: { + strategies: ['strat1'], + stratums: [{ name: 'startum1', description: 'desc' }] + }, location: { survey_area_name: 'location', geometry: [] @@ -124,6 +128,10 @@ describe('survey/{surveyId}/view', () => { indigenous_partnerships: [], stakeholder_partnerships: [] }, + site_selection: { + strategies: ['strat1'], + stratums: [{ name: 'startum1', description: null }] + }, location: { survey_area_name: 'location', geometry: [] diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts index a6a18b8684..7f3957f817 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts @@ -80,6 +80,7 @@ GET.apiDoc = { 'partnerships', 'proprietor', 'purpose_and_methodology', + 'site_selection', 'location' ], properties: { @@ -313,6 +314,34 @@ GET.apiDoc = { } } }, + site_selection: { + type: 'object', + required: ['strategies', 'stratums'], + properties: { + strategies: { + type: 'array', + items: { + type: 'string' + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } + } + } + } + }, location: { description: 'Survey location Details', type: 'object', diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index ac6d0a9fec..b6dc4054bc 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -35,7 +35,8 @@ export const IAllCodeSets = z.object({ ecological_seasons: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), intended_outcomes: CodeSet(z.object({ id: z.number(), name: z.string(), description: z.string() }).shape), vantage_codes: CodeSet(), - survey_jobs: CodeSet() + survey_jobs: CodeSet(), + site_selection_strategies: CodeSet() }); export type IAllCodeSets = z.infer; @@ -413,6 +414,22 @@ export class CodeRepository extends BaseRepository { return response.rows; } + async getSiteSelectionStrategies() { + const sqlStatement = SQL` + SELECT + ss.site_strategy_id as id, + ss.name + FROM + site_strategy ss + WHERE + record_end_date is null; + `; + + const response = await this.connection.sql(sqlStatement); + + return response.rows; + } + /** * Fetch administrative activity status type codes. * diff --git a/api/src/repositories/site-selection-strategy-repository.ts b/api/src/repositories/site-selection-strategy-repository.ts new file mode 100644 index 0000000000..f45ddc3535 --- /dev/null +++ b/api/src/repositories/site-selection-strategy-repository.ts @@ -0,0 +1,244 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getLogger } from '../utils/logger'; +import { BaseRepository } from './base-repository'; + +export const SurveyStratum = z.object({ + name: z.string(), + description: z.string() +}); + +export const SurveyStratumRecord = z.object({ + name: z.string(), + description: z.string().nullable(), + survey_id: z.number(), + survey_stratum_id: z.number(), + revision_count: z.number(), + update_date: z.string().nullable() +}); + +export type SurveyStratumRecord = z.infer; + +export type SurveyStratum = z.infer; + +export const SiteSelectionData = z.object({ + strategies: z.array(z.string()), + stratums: z.array(SurveyStratumRecord) +}); + +export type SiteSelectionData = z.infer; + +const defaultLog = getLogger('repositories/site-selection-strategy-repository'); + +/** + * A repository class for accessing Survey site selection strategies and Stratums data. + * + * @export + * @class SiteSelectionStrategyRepository + * @extends {BaseRepository} + */ +export class SiteSelectionStrategyRepository extends BaseRepository { + /** + * Retreives the site selection strategies and stratums for the given survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async getSiteSelectionDataBySurveyId(surveyId: number): Promise { + defaultLog.debug({ label: 'getSiteSelectionDataBySurveyId', surveyId }); + + const strategiesQuery = getKnex() + .select('ss.name') + .from('survey_site_strategy as sss') + .where('sss.survey_id', 1) + .leftJoin('site_strategy as ss', 'ss.site_strategy_id', 'sss.site_strategy_id'); + + const strategiesResponse = await this.connection.knex<{ name: string }>(strategiesQuery); + const strategies = strategiesResponse.rows.map((row) => row.name); + + const stratumsQuery = getKnex().select().from('survey_stratum').where('survey_id', surveyId); + + const stratumsResponse = await this.connection.knex(stratumsQuery); + const stratums = stratumsResponse.rows; + + return { strategies, stratums }; + } + + /** + * Deletes all site selection strategies belonging to a survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async deleteSurveySiteSelectionStrategies(surveyId: number): Promise { + defaultLog.debug({ label: 'deleteSurveySiteSelectionStrategies', surveyId }); + + const deleteStatement = SQL` + DELETE FROM + survey_site_strategy + WHERE + survey_id = ${surveyId} + RETURNING *; + `; + + const response = await this.connection.sql(deleteStatement); + + return response.rowCount; + } + + /** + * Inserts site selection strategies (by name) for a survey + * + * @param {number} surveyId + * @param {string[]} strategies + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async insertSurveySiteSelectionStrategies(surveyId: number, strategies: string[]): Promise { + defaultLog.debug({ label: 'insertSurveySiteSelectionStrategies', surveyId, strategies }); + + const insertQuery = SQL` + WITH + strategies + AS ( + SELECT + ss.site_strategy_id + FROM + site_strategy ss + WHERE + ss.name + IN + (`; + + strategies.forEach((strategy, index) => { + insertQuery.append(`'${strategy}'`); + if (index < strategies.length - 1) { + insertQuery.append(', '); + } + }); + + insertQuery.append(SQL` + ) + ) + + INSERT INTO + survey_site_strategy (survey_id, site_strategy_id) + ( + SELECT + ${surveyId} AS survey_id, + site_strategy_id + FROM + strategies + ) + RETURNING *; + `); + + const response = await this.connection.sql(insertQuery); + + if (response.rowCount !== strategies.length) { + throw new ApiExecuteSQLError('Failed to insert survey site selection strategies', [ + 'SurveyRepository->insertSurveySiteSelectionStrategies', + `rowCount was ${response.rowCount}, expected rowCount = ${strategies.length}` + ]); + } + } + + /** + * Deletes the given survey stratums by ID + * + * @param {number[]} stratumIds + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async deleteSurveyStratums(stratumIds: number[]): Promise { + defaultLog.debug({ label: 'deleteSurveyStratums', stratumIds }); + + const deleteQuery = getKnex() + .delete() + .from('survey_stratum') + .whereIn('survey_stratum_id', stratumIds) + .returning('*'); + + await this.connection.knex(deleteQuery, SurveyStratumRecord); + } + + /** + * Inserts the given survey stratums for the given survey + * + * @param {number} surveyId + * @param {SurveyStratum[]} stratums + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async insertSurveyStratums(surveyId: number, stratums: SurveyStratum[]): Promise { + defaultLog.debug({ label: 'insertSurveyStratums', surveyId }); + + const insertQuery = getKnex() + .table('survey_stratum') + .insert( + stratums.map((stratum) => ({ + survey_id: surveyId, + name: stratum.name, + description: stratum.description + })) + ) + .returning('*'); + + const response = await this.connection.knex(insertQuery, SurveyStratumRecord); + + if (response.rowCount !== stratums.length) { + throw new ApiExecuteSQLError('Failed to insert survey stratums', [ + 'SurveyRepository->insertSurveyStratums', + `rowCount was ${response.rowCount}, expected rowCount = ${stratums.length}` + ]); + } + + return response.rows; + } + + /** + * Performs a batch update for survey stratum records + * + * @param {number} surveyId + * @param {SurveyStratumRecord[]} stratums + * @return {*} {Promise} + * @memberof SurveyRepository + */ + async updateSurveyStratums(surveyId: number, stratums: SurveyStratumRecord[]): Promise { + defaultLog.debug({ label: 'updateSurveyStratums', surveyId }); + + const makeUpdateQuery = (stratum: SurveyStratumRecord) => { + return getKnex() + .table('survey_stratum') + .update({ + survey_id: surveyId, + name: stratum.name, + description: stratum.description, + update_date: 'now()' + }) + .where('survey_stratum_id', stratum.survey_stratum_id) + .returning('*'); + }; + + const responses = await Promise.all( + stratums.map((stratum) => this.connection.knex(makeUpdateQuery(stratum), SurveyStratumRecord)) + ); + + const records = responses.reduce((acc: SurveyStratumRecord[], queryResult) => { + return [...acc, ...queryResult.rows]; + }, []); + + if (records.length !== stratums.length) { + throw new ApiExecuteSQLError('Failed to update survey stratums', [ + 'SurveyRepository->updateSurveyStratums', + `Total rowCount was ${records.length}, expected ${stratums.length} rows` + ]); + } + + return records; + } +} diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 60f020cb77..5da005390a 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -43,6 +43,7 @@ describe('CodeService', () => { 'field_methods', 'intended_outcomes', 'vantage_codes', + 'site_selection_strategies', 'survey_jobs' ); }); diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 4d2352f7b6..4641b24c19 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -40,7 +40,8 @@ export class CodeService extends DBService { ecological_seasons, intended_outcomes, vantage_codes, - survey_jobs + survey_jobs, + site_selection_strategies ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -59,28 +60,30 @@ export class CodeService extends DBService { await this.codeRepository.getEcologicalSeasons(), await this.codeRepository.getIntendedOutcomes(), await this.codeRepository.getVantageCodes(), - await this.codeRepository.getSurveyJobs() + await this.codeRepository.getSurveyJobs(), + await this.codeRepository.getSiteSelectionStrategies() ]); return { - management_action_type: management_action_type, - first_nations: first_nations, - agency: agency, - investment_action_category: investment_action_category, - type: type, - iucn_conservation_action_level_1_classification: iucn_conservation_action_level_1_classification, - iucn_conservation_action_level_2_subclassification: iucn_conservation_action_level_2_subclassification, - iucn_conservation_action_level_3_subclassification: iucn_conservation_action_level_3_subclassification, - program: program, - proprietor_type: proprietor_type, - system_roles: system_roles, - project_roles: project_roles, - administrative_activity_status_type: administrative_activity_status_type, - field_methods: field_methods, - ecological_seasons: ecological_seasons, - intended_outcomes: intended_outcomes, - vantage_codes: vantage_codes, - survey_jobs: survey_jobs + management_action_type, + first_nations, + agency, + investment_action_category, + type, + iucn_conservation_action_level_1_classification, + iucn_conservation_action_level_2_subclassification, + iucn_conservation_action_level_3_subclassification, + program, + proprietor_type, + system_roles, + project_roles, + administrative_activity_status_type, + field_methods, + ecological_seasons, + intended_outcomes, + vantage_codes, + survey_jobs, + site_selection_strategies }; } } diff --git a/api/src/services/eml-service.test.ts b/api/src/services/eml-service.test.ts index b0fe472dda..bc3d688a41 100644 --- a/api/src/services/eml-service.test.ts +++ b/api/src/services/eml-service.test.ts @@ -990,6 +990,7 @@ describe.skip('EmlService', () => { field_methods: [], intended_outcomes: [], vantage_codes: [], + site_selection_strategies: [], survey_jobs: [] }; diff --git a/api/src/services/site-selection-strategy-service.test.ts b/api/src/services/site-selection-strategy-service.test.ts new file mode 100644 index 0000000000..4383c351c2 --- /dev/null +++ b/api/src/services/site-selection-strategy-service.test.ts @@ -0,0 +1,189 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { + SiteSelectionStrategyRepository, + SurveyStratum, + SurveyStratumRecord +} from '../repositories/site-selection-strategy-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SiteSelectionStrategyService } from './site-selection-strategy-service'; + +describe('SiteSelectionStrategyService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getSiteSelectionDataBySurveyId', () => { + it('should return site selection data', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const siteSelectionStrategyRepoStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'getSiteSelectionDataBySurveyId') + .resolves({ strategies: ['A'], stratums: [{ name: 'A', description: 'A' } as SurveyStratumRecord] }); + + const response = await siteSelectionStrategyService.getSiteSelectionDataBySurveyId(1); + + expect(siteSelectionStrategyRepoStub).to.be.calledOnceWith(1); + expect(response).to.eql({ strategies: ['A'], stratums: [{ name: 'A', description: 'A' }] }); + }); + }); + + describe('insertSurveySiteSelectionStrategies', () => { + it('should insert site selection strategies', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const siteSelectionStrategyRepoStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'insertSurveySiteSelectionStrategies') + .resolves(); + + await siteSelectionStrategyService.insertSurveySiteSelectionStrategies(2, ['Strat1']); + + expect(siteSelectionStrategyRepoStub).to.be.calledOnceWith(2, ['Strat1']); + }); + }); + + describe('replaceSurveySiteSelectionStrategies', () => { + it('should replace site selection strategies', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const strategyDeleteStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'deleteSurveySiteSelectionStrategies') + .resolves(); + + const strategyInsertStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'insertSurveySiteSelectionStrategies') + .resolves(); + + await siteSelectionStrategyService.replaceSurveySiteSelectionStrategies(3, ['Strat2']); + + expect(strategyDeleteStub).to.be.calledOnceWith(3); + expect(strategyInsertStub).to.be.calledOnceWith(3, ['Strat2']); + }); + + it('should not insert new site selection strategies if an empty array is passed', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const strategyDeleteStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'deleteSurveySiteSelectionStrategies') + .resolves(); + + const strategyInsertStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'insertSurveySiteSelectionStrategies') + .resolves(); + + await siteSelectionStrategyService.replaceSurveySiteSelectionStrategies(4, []); + + expect(strategyDeleteStub).to.be.calledOnceWith(4); + expect(strategyInsertStub).to.not.be.called; + }); + }); + + describe('insertSurveyStratums', () => { + it('should insert survey stratums', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const siteSelectionStrategyRepoStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'insertSurveyStratums') + .resolves(); + + await siteSelectionStrategyService.insertSurveyStratums(7, [{ name: 'A', description: 'A' } as SurveyStratum]); + + expect(siteSelectionStrategyRepoStub).to.be.calledOnceWith(7, [{ name: 'A', description: 'A' }]); + }); + }); + + describe('updateSurveyStratums', () => { + it('should update survey stratums', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const siteSelectionStrategyRepoStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'updateSurveyStratums') + .resolves(); + + await siteSelectionStrategyService.updateSurveyStratums(8, [ + { name: 'B', description: 'B' } as SurveyStratumRecord + ]); + + expect(siteSelectionStrategyRepoStub).to.be.calledOnceWith(8, [{ name: 'B', description: 'B' }]); + }); + }); + + describe('deleteSurveyStratums', () => { + it('should delete survey stratums', async () => { + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const siteSelectionStrategyRepoStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'deleteSurveyStratums') + .resolves(); + + await siteSelectionStrategyService.deleteSurveyStratums([9]); + + expect(siteSelectionStrategyRepoStub).to.be.calledOnceWith([9]); + }); + }); + + describe('replaceSurveySiteSelectionStratums', () => { + it('should sort stratums into insert, update and delete lists', async () => { + // Setup + const mockDbConnection = getMockDBConnection(); + const siteSelectionStrategyService = new SiteSelectionStrategyService(mockDbConnection); + + const stratums: Array = [ + { name: 'A', description: '' }, + { name: 'B', description: '', survey_stratum_id: 1 } as SurveyStratumRecord, + { name: 'C', description: '' }, + { name: 'D', description: '', survey_stratum_id: 2 } as SurveyStratumRecord, + { name: 'E', description: '' }, + { name: 'F', description: '', survey_stratum_id: 3 } as SurveyStratumRecord + ]; + + const insertStratumStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'insertSurveyStratums') + .resolves(); + + const updateStratumStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'updateSurveyStratums') + .resolves(); + + const deleteStratumStub = sinon + .stub(SiteSelectionStrategyRepository.prototype, 'deleteSurveyStratums') + .resolves(); + + sinon.stub(SiteSelectionStrategyRepository.prototype, 'getSiteSelectionDataBySurveyId').resolves({ + strategies: ['Stratified'], + stratums: [ + { name: 'B', description: '', survey_stratum_id: 1 }, + { name: 'D', description: '', survey_stratum_id: 2 }, + { name: 'F', description: '', survey_stratum_id: 3 }, + { name: 'G', description: '', survey_stratum_id: 4 }, + { name: 'H', description: '', survey_stratum_id: 5 } + ] as SurveyStratumRecord[] + }); + + // Act + await siteSelectionStrategyService.replaceSurveySiteSelectionStratums(10, stratums); + + // Assert + expect(insertStratumStub).to.be.calledOnceWith(10, [ + { name: 'A', description: '' }, + { name: 'C', description: '' }, + { name: 'E', description: '' } + ]); + + expect(updateStratumStub).to.be.calledOnceWith(10, [ + { name: 'B', description: '', survey_stratum_id: 1 }, + { name: 'D', description: '', survey_stratum_id: 2 }, + { name: 'F', description: '', survey_stratum_id: 3 } + ]); + + expect(deleteStratumStub).to.be.calledOnceWith([4, 5]); + }); + }); +}); diff --git a/api/src/services/site-selection-strategy-service.ts b/api/src/services/site-selection-strategy-service.ts new file mode 100644 index 0000000000..e942d92c8f --- /dev/null +++ b/api/src/services/site-selection-strategy-service.ts @@ -0,0 +1,149 @@ +import { IDBConnection } from '../database/db'; +import { + SiteSelectionData, + SiteSelectionStrategyRepository, + SurveyStratum, + SurveyStratumRecord +} from '../repositories/site-selection-strategy-repository'; +import { getLogger } from '../utils/logger'; +import { DBService } from './db-service'; + +const defaultLog = getLogger('repositories/site-selection-strategy-repository'); + +/** + * Service for managing survey site selection strategies and stratums + * + * @export + * @class SiteSelectionStrategyService + * @extends {DBService} + */ +export class SiteSelectionStrategyService extends DBService { + siteSelectionStrategyRepository: SiteSelectionStrategyRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.siteSelectionStrategyRepository = new SiteSelectionStrategyRepository(connection); + } + + /** + * Retrieves site selection strategies and stratums + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SiteSelectionStrategyService + */ + async getSiteSelectionDataBySurveyId(surveyId: number): Promise { + return this.siteSelectionStrategyRepository.getSiteSelectionDataBySurveyId(surveyId); + } + + /** + * Attaches numerous site selection strategies to the given survey + * + * @param {number} surveyId + * @param {string[]} strategies + * @return {*} {Promise} + * @memberof SiteSelectionStrategyService + */ + async insertSurveySiteSelectionStrategies(surveyId: number, strategies: string[]): Promise { + return this.siteSelectionStrategyRepository.insertSurveySiteSelectionStrategies(surveyId, strategies); + } + + /** + * Replaces all of the site selection strategies for the given survey, first by deleting + * all existing records, then inserting the new ones + * + * @param {number} surveyId + * @param {string[]} strategies + * @return {*} {Promise} + * @memberof SiteSelectionStrategyService + */ + async replaceSurveySiteSelectionStrategies(surveyId: number, strategies: string[]): Promise { + await this.siteSelectionStrategyRepository.deleteSurveySiteSelectionStrategies(surveyId); + + if (strategies.length > 0) { + await this.insertSurveySiteSelectionStrategies(surveyId, strategies); + } + } + + /** + * Receives an array of all stratums, that should be persisted for a particular survey, then + * deletes, inserts and updates stratum records accordingly. + * + * @param {number} surveyId + * @param {(Array)} stratums + * @return {*} {Promise} + * @memberof SurveyService + */ + async replaceSurveySiteSelectionStratums( + surveyId: number, + stratums: Array + ): Promise { + defaultLog.debug({ label: 'replaceSurveySiteSelectionStratums' }); + + const insertStratums: SurveyStratum[] = []; + const updateStratums: SurveyStratumRecord[] = []; + const existingSiteSelectionStrategies = await this.siteSelectionStrategyRepository.getSiteSelectionDataBySurveyId( + surveyId + ); + + stratums.forEach((stratum) => { + if ('survey_stratum_id' in stratum) { + updateStratums.push(stratum); + } else { + insertStratums.push(stratum); + } + }); + + const removeStratums = existingSiteSelectionStrategies.stratums.filter((stratum) => { + return !updateStratums.some((updateStratum) => updateStratum.survey_stratum_id === stratum.survey_stratum_id); + }); + + if (removeStratums.length) { + await this.deleteSurveyStratums(removeStratums.map((stratum) => stratum.survey_stratum_id)); + } + + if (updateStratums.length) { + await this.updateSurveyStratums(surveyId, updateStratums); + } + + if (insertStratums.length) { + await this.insertSurveyStratums(surveyId, insertStratums); + } + } + + /** + * Inserts new survey stratums + * + * @param {number} surveyId + * @param {SurveyStratum[]} stratums + * @return {*} {Promise} + * @memberof SiteSelectionStrategyService + */ + async insertSurveyStratums(surveyId: number, stratums: SurveyStratum[]): Promise { + return this.siteSelectionStrategyRepository.insertSurveyStratums(surveyId, stratums); + } + + /** + * Updates all of the given survey stratums + * + * @param {number} surveyId + * @param {SurveyStratumRecord[]} stratums + * @return {*} {Promise} + * @memberof SiteSelectionStrategyService + */ + async updateSurveyStratums(surveyId: number, stratums: SurveyStratumRecord[]): Promise { + return this.siteSelectionStrategyRepository.updateSurveyStratums(surveyId, stratums); + } + + /** + * Deletes all of the given survey stratums by the survey stratum ID + * + * @param {number[]} stratumIds + * @return {*} {Promise} + * @memberof SiteSelectionStrategyService + */ + async deleteSurveyStratums(stratumIds: number[]): Promise { + return this.siteSelectionStrategyRepository.deleteSurveyStratums(stratumIds); + } +} diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index b0d1683538..ba842d423a 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -33,6 +33,7 @@ import { getMockDBConnection } from '../__mocks__/db'; import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; import { PlatformService } from './platform-service'; +import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; import { SurveyParticipationService } from './survey-participation-service'; import { SurveyService } from './survey-service'; @@ -80,6 +81,9 @@ describe('SurveyService', () => { .stub(SurveyParticipationService.prototype, 'getSurveyParticipants') .resolves([{ data: 'participantData' } as any]); const getSurveyBlockStub = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([]); + const getSiteSelectionDataStub = sinon + .stub(SiteSelectionStrategyService.prototype, 'getSiteSelectionDataBySurveyId') + .resolves({ strategies: [], stratums: [] }); const getSurveyPartnershipsDataStub = sinon.stub(SurveyService.prototype, 'getSurveyPartnershipsData').resolves({ indigenous_partnerships: [], @@ -98,6 +102,7 @@ describe('SurveyService', () => { expect(getSurveyParticipantsStub).to.be.calledOnce; expect(getSurveyPartnershipsDataStub).to.be.calledOnce; expect(getSurveyBlockStub).to.be.calledOnce; + expect(getSiteSelectionDataStub).to.be.calledOnce; expect(response).to.eql({ survey_details: { data: 'surveyData' }, @@ -112,6 +117,7 @@ describe('SurveyService', () => { }, participants: [{ data: 'participantData' } as any], location: { data: 'locationData' }, + site_selection: { stratums: [], strategies: [] }, blocks: [] }); }); @@ -142,6 +148,9 @@ describe('SurveyService', () => { .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); sinon.stub(SurveyBlockService.prototype, 'upsertSurveyBlocks').resolves(); + const updateSurveyStratumsStub = sinon + .stub(SiteSelectionStrategyService.prototype, 'updateSurveyStratums') + .resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -158,6 +167,7 @@ describe('SurveyService', () => { expect(updateSurveyProprietorDataStub).not.to.have.been.called; expect(insertRegionStub).not.to.have.been.called; expect(upsertSurveyParticipantDataStub).not.to.have.been.called; + expect(updateSurveyStratumsStub).not.to.have.been.called; }); it('updates everything when all data provided', async () => { @@ -181,6 +191,12 @@ describe('SurveyService', () => { .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); const upsertBlocks = sinon.stub(SurveyBlockService.prototype, 'upsertSurveyBlocks').resolves(); + const replaceSurveyStratumsStub = sinon + .stub(SiteSelectionStrategyService.prototype, 'replaceSurveySiteSelectionStratums') + .resolves(); + const replaceSiteStrategiesStub = sinon + .stub(SiteSelectionStrategyService.prototype, 'replaceSurveySiteSelectionStrategies') + .resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -193,6 +209,7 @@ describe('SurveyService', () => { proprietor: {}, purpose_and_methodology: {}, location: {}, + site_selection: { stratums: [], strategies: [] }, participants: [{}], blocks: [{}] }); @@ -209,6 +226,8 @@ describe('SurveyService', () => { expect(updateSurveyRegionStub).to.have.been.calledOnce; expect(upsertSurveyParticipantDataStub).to.have.been.calledOnce; expect(upsertBlocks).to.have.been.calledOnce; + expect(replaceSurveyStratumsStub).to.have.been.calledOnce; + expect(replaceSiteStrategiesStub).to.have.been.calledOnce; }); }); diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index 4c3b9a4424..c780ac01a9 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -36,6 +36,7 @@ import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; import { PlatformService } from './platform-service'; import { RegionService } from './region-service'; +import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; import { SurveyParticipationService } from './survey-participation-service'; import { TaxonomyService } from './taxonomy-service'; @@ -55,6 +56,7 @@ export class SurveyService extends DBService { platformService: PlatformService; historyPublishService: HistoryPublishService; fundingSourceService: FundingSourceService; + siteSelectionStrategyService: SiteSelectionStrategyService; surveyParticipationService: SurveyParticipationService; constructor(connection: IDBConnection) { @@ -65,6 +67,7 @@ export class SurveyService extends DBService { this.platformService = new PlatformService(connection); this.historyPublishService = new HistoryPublishService(connection); this.fundingSourceService = new FundingSourceService(connection); + this.siteSelectionStrategyService = new SiteSelectionStrategyService(connection); this.surveyParticipationService = new SurveyParticipationService(connection); } @@ -96,6 +99,7 @@ export class SurveyService extends DBService { purpose_and_methodology: await this.getSurveyPurposeAndMethodology(surveyId), proprietor: await this.getSurveyProprietorDataForView(surveyId), location: await this.getSurveyLocationData(surveyId), + site_selection: await this.siteSelectionStrategyService.getSiteSelectionDataBySurveyId(surveyId), participants: await this.surveyParticipationService.getSurveyParticipants(surveyId), blocks: await this.getSurveyBlocksForSurveyId(surveyId) }; @@ -453,6 +457,25 @@ export class SurveyService extends DBService { promises.push(this.insertRegion(surveyId, postSurveyData.location.geometry)); } + // Handle site selection strategies + + if (postSurveyData.site_selection.strategies.length > 0) { + promises.push( + this.siteSelectionStrategyService.insertSurveySiteSelectionStrategies( + surveyId, + postSurveyData.site_selection.strategies + ) + ); + } + + // Handle stratums + if (postSurveyData.site_selection.stratums.length > 0) { + promises.push( + this.siteSelectionStrategyService.insertSurveyStratums(surveyId, postSurveyData.site_selection.stratums) + ); + } + + // Handle blocks if (postSurveyData.blocks) { promises.push(this.upsertBlocks(surveyId, postSurveyData.blocks)); } @@ -679,6 +702,27 @@ export class SurveyService extends DBService { promises.push(this.upsertSurveyParticipantData(surveyId, putSurveyData)); } + // Handle site selection strategies + if (putSurveyData?.site_selection?.strategies) { + promises.push( + this.siteSelectionStrategyService.replaceSurveySiteSelectionStrategies( + surveyId, + putSurveyData.site_selection.strategies + ) + ); + } + + // Handle stratums + if (putSurveyData?.site_selection?.stratums) { + promises.push( + this.siteSelectionStrategyService.replaceSurveySiteSelectionStratums( + surveyId, + putSurveyData.site_selection.stratums + ) + ); + } + + // Handle blocks if (putSurveyData?.blocks) { promises.push(this.upsertBlocks(surveyId, putSurveyData.blocks)); } diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 11cc7a80fa..b3b386e59c 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -42,12 +42,14 @@ import PurposeAndMethodologyForm, { PurposeAndMethodologyInitialValues, PurposeAndMethodologyYupSchema } from './components/PurposeAndMethodologyForm'; +import SamplingMethodsForm from './components/SamplingMethodsForm'; import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from './components/StudyAreaForm'; -import SurveyBlockSection, { SurveyBlockInitialValues } from './components/SurveyBlockSection'; +import { SurveyBlockInitialValues } from './components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, SurveyFundingSourceFormYupSchema } from './components/SurveyFundingSourceForm'; +import { SurveySiteSelectionInitialValues, SurveySiteSelectionYupSchema } from './components/SurveySiteSelectionForm'; import SurveyUserForm, { SurveyUserJobFormInitialValues, SurveyUserJobYupSchema } from './components/SurveyUserForm'; const useStyles = makeStyles((theme: Theme) => ({ @@ -108,7 +110,7 @@ const CreateSurveyPage = () => { const [formikRef] = useState(useRef>(null)); // Ability to bypass showing the 'Are you sure you want to cancel' dialog - const [enableCancelCheck, setEnableCancelCheck] = useState(true); + const [enableCancelCheck, setEnableCancelCheck] = useState(true); const dialogContext = useContext(DialogContext); @@ -137,6 +139,7 @@ const CreateSurveyPage = () => { ...SurveyPartnershipsFormInitialValues, ...ProprietaryDataInitialValues, ...AgreementsInitialValues, + ...SurveySiteSelectionInitialValues, ...SurveyUserJobFormInitialValues, ...SurveyBlockInitialValues }); @@ -182,6 +185,7 @@ const CreateSurveyPage = () => { .concat(SurveyFundingSourceFormYupSchema) .concat(AgreementsYupSchema) .concat(SurveyUserJobYupSchema) + .concat(SurveySiteSelectionYupSchema) .concat(SurveyPartnershipsFormYupSchema); const handleCancel = () => { @@ -382,8 +386,9 @@ const CreateSurveyPage = () => { } + component={} /> + { + const [showStratumForm, setShowStratumForm] = useState(false); + + return ( + <> + + Site Selection Strategy + + + + + + + Define Stratums + + Enter a name and description for each stratum used in this survey. + + + + + + + + + + + ); +}; + +export default SamplingMethodsForm; diff --git a/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx b/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx new file mode 100644 index 0000000000..a9f45776f7 --- /dev/null +++ b/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx @@ -0,0 +1,90 @@ +import { useMediaQuery, useTheme } from '@mui/material'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import CustomTextField from 'components/fields/CustomTextField'; +import { Formik, FormikProps } from 'formik'; +import { useRef } from 'react'; +import { IStratumForm, StratumFormYupSchema } from './SurveyStratumForm'; + +interface IStratumDialogProps { + open: boolean; + stratumFormInitialValues: IStratumForm; + onCancel: () => void; + onSave: (formikProps: FormikProps | null) => void; +} + +const StratumCreateOrEditDialog = (props: IStratumDialogProps) => { + const formikRef = useRef>(null); + + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleCancel = () => { + props.onCancel(); + }; + + const editing = props.stratumFormInitialValues.index !== null; + + return ( + + initialValues={props.stratumFormInitialValues} + innerRef={formikRef} + enableReinitialize={true} + validationSchema={StratumFormYupSchema} + validateOnBlur={true} + validateOnChange={false} + onSubmit={(_values) => { + props.onSave(formikRef.current); + }}> + {(formikProps) => { + return ( + + {editing ? 'Edit Stratum Details' : 'Add Stratum'} + + <> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam at porttitor sem. Aliquam erat + volutpat. Donec placerat nisl magna, et faucibus arcu condimentum sed. + + + + + + + + + + + + + ); + }} + + ); +}; + +export default StratumCreateOrEditDialog; diff --git a/app/src/features/surveys/components/SurveyBlockSection.tsx b/app/src/features/surveys/components/SurveyBlockSection.tsx index 01bf12b6d2..bbc53fa2ab 100644 --- a/app/src/features/surveys/components/SurveyBlockSection.tsx +++ b/app/src/features/surveys/components/SurveyBlockSection.tsx @@ -94,9 +94,9 @@ const SurveyBlockSection: React.FC = () => { sx={{ marginBottom: '14px' }}> - Define Blocks{' '} + Define Blocks  - (optional) + (Optional) { + return stratums.length > 0 + ? schema.test( + 'allowsStratums', + 'You must include the Stratified site selection strategy in order to add Stratums.', + (strategies: string[]) => strategies.includes('Stratified') + ) + : schema; + }), + stratums: yup.array().of( + yup.object({ + survey_stratum_id: yup.number().optional(), + name: yup.string().required('Must provide a name for stratum'), + description: yup.string().optional() + }) + ) + /* + // TODO assure that duplicate stratums cannot be created + .test('duplicateStratums', 'Stratums must have unique names.', (stratums) => { + const entries = (stratums || []).map((stratum) => new String(stratum.name).trim()); + return new Set(entries).size === stratums?.length; + }) + */ + }) +}); + +interface ISurveySiteSelectionFormProps { + onChangeStratumEntryVisibility: (isVisible: boolean) => void; +} + +/** + * Create/edit survey - Funding section + * + * @return {*} + */ +const SurveySiteSelectionForm = (props: ISurveySiteSelectionFormProps) => { + const [showStratumDeleteConfirmModal, setShowStratumDeleteConfirmModal] = useState(false); + + const formikProps = useFormikContext(); + const { values, setFieldValue } = formikProps; + + const codesContext = useContext(CodesContext); + assert(codesContext.codesDataLoader.data); + + const siteStrategies = codesContext.codesDataLoader.data.site_selection_strategies.map((code) => { + return { label: code.name, value: code.name }; + }); + + const handleConfirmDeleteAllStratums = () => { + // Delete all Stratums and hide the Stratums form + setFieldValue('site_selection.stratums', []); + props.onChangeStratumEntryVisibility(false); + setShowStratumDeleteConfirmModal(false); + }; + + const handleCancelDeleteAllStratums = () => { + setShowStratumDeleteConfirmModal(false); + setFieldValue('site_selection.strategies', [...values.site_selection.strategies, 'Stratified']); + }; + + useEffect(() => { + if (values.site_selection.strategies.includes('Stratified')) { + props.onChangeStratumEntryVisibility(true); + } else if (values.site_selection.stratums.length > 0) { + // Prompt to confirm removing all stratums + setShowStratumDeleteConfirmModal(true); + } else { + props.onChangeStratumEntryVisibility(false); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.site_selection.strategies]); + + return ( + <> + + + + ); +}; + +export default SurveySiteSelectionForm; diff --git a/app/src/features/surveys/components/SurveyStratumForm.tsx b/app/src/features/surveys/components/SurveyStratumForm.tsx new file mode 100644 index 0000000000..0e38d46991 --- /dev/null +++ b/app/src/features/surveys/components/SurveyStratumForm.tsx @@ -0,0 +1,204 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Box, ListItemIcon, Menu, MenuItem, MenuProps } from '@mui/material'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { FormikProps, useFormikContext } from 'formik'; +import { IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import { useState } from 'react'; +import yup from 'utils/YupSchema'; +import StratumCreateOrEditDialog from './StratumCreateOrEditDialog'; +import { IStratum } from './SurveySiteSelectionForm'; + +export interface IStratumForm { + index: number | null; + stratum: IStratum; +} + +export const StratumFormInitialValues: IStratumForm = { + index: null, + stratum: { + name: '', + description: '' + } +}; + +export const StratumFormYupSchema = yup.object().shape({ + index: yup.number().nullable(true), + stratum: yup.object().shape({ + survey_stratum_id: yup.number(), + name: yup.string().required('Must provide a Stratum name').max(300, 'Name cannot exceed 300 characters'), + description: yup + .string() + .required('Must provide a Stratum description') + .max(3000, 'Description cannot exceed 3000 characters') + }) +}); + +/** + * Create/edit survey - Funding section + * + * @return {*} + */ +const SurveyStratumForm = () => { + const [currentStratumForm, setCurrentStratumForm] = useState(StratumFormInitialValues); + const [dialogOpen, setDialogOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const formikProps = useFormikContext(); + const { values, handleSubmit, setFieldValue } = formikProps; + + const handleSave = (formikProps: FormikProps | null) => { + if (!formikProps) { + return; + } + + const stratumForm = formikProps.values; + + if (stratumForm.index === null) { + // Create new stratum + setFieldValue('site_selection.stratums', [...values.site_selection.stratums, stratumForm.stratum]); + } else { + // Edit existing stratum + setFieldValue(`site_selection.stratums[${stratumForm.index}`, stratumForm.stratum); + } + + setDialogOpen(false); + + /** + * Set the current stratum form to be the newly created stratum. This is so that + * Formik will recognize an initial values change upon creating a subsequent stratum. + */ + setCurrentStratumForm(stratumForm); + }; + + const handleCreateNewStratum = () => { + setCurrentStratumForm(StratumFormInitialValues); + setDialogOpen(true); + }; + + const handleClickContextMenu = (event: React.MouseEvent, index: number) => { + setAnchorEl(event.currentTarget); + setCurrentStratumForm({ + index, + stratum: values.site_selection.stratums[index] + }); + }; + + const handleDelete = () => { + setAnchorEl(null); + setFieldValue( + 'site_selection.stratums', + values.site_selection.stratums.filter((_stratum, index) => index !== currentStratumForm.index) + ); + }; + + const handleEdit = () => { + setDialogOpen(true); + setAnchorEl(null); + }; + + return ( + <> + setDialogOpen(false)} + stratumFormInitialValues={currentStratumForm} + onSave={handleSave} + /> + setAnchorEl(null)} + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + handleEdit()}> + + + + Edit Details + + handleDelete()}> + + + + Remove Stratum + + +
+ + {values.site_selection.stratums.map((stratum: IStratum, index: number) => { + const key = `${stratum.name}-${index}`; + + return ( + + + + + + {stratum.name} + + + {stratum.description} + + + + handleClickContextMenu(event, index)}> + + + + + + + ); + })} + + + + + +
+ + ); +}; + +export default SurveyStratumForm; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index 540f4732b9..160418f9be 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -27,12 +27,14 @@ import GeneralInformationForm, { } from '../components/GeneralInformationForm'; import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/ProprietaryDataForm'; import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from '../components/PurposeAndMethodologyForm'; +import SamplingMethodsForm from '../components/SamplingMethodsForm'; import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from '../components/StudyAreaForm'; -import SurveyBlockSection, { SurveyBlockInitialValues } from '../components/SurveyBlockSection'; +import { SurveyBlockInitialValues } from '../components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, SurveyFundingSourceFormYupSchema } from '../components/SurveyFundingSourceForm'; +import { SurveySiteSelectionInitialValues, SurveySiteSelectionYupSchema } from '../components/SurveySiteSelectionForm'; import SurveyUserForm, { SurveyUserJobFormInitialValues, SurveyUserJobYupSchema } from '../components/SurveyUserForm'; const useStyles = makeStyles((theme: Theme) => ({ @@ -81,6 +83,7 @@ const EditSurveyForm: React.FC = (props) => { ...StudyAreaInitialValues, ...SurveyFundingSourceFormInitialValues, ...SurveyPartnershipsFormInitialValues, + ...SurveySiteSelectionInitialValues, ...{ proprietor: { survey_data_proprietary: '' as unknown as StringBoolean, @@ -143,6 +146,7 @@ const EditSurveyForm: React.FC = (props) => { .concat(SurveyFundingSourceFormYupSchema) .concat(AgreementsYupSchema) .concat(SurveyUserJobYupSchema) + .concat(SurveySiteSelectionYupSchema) .concat(SurveyPartnershipsFormYupSchema); return ( @@ -238,8 +242,9 @@ const EditSurveyForm: React.FC = (props) => { } + component={} /> + { setOpenDialog((d) => !d); }; - const pluralize = (str: string, count: number) => - count > 1 || count === 0 ? `${count} ${str}'s` : `${count} ${str}`; - const AnimalFormValues: IAnimal = { general: { taxon_id: '', taxon_name: '', animal_id: '' }, captures: [], @@ -69,7 +67,7 @@ const SurveyAnimals: React.FC = () => { {`${ animalCount - ? `${pluralize('Animal', animalCount)} reported in this survey` + ? `${animalCount} ${pluralize(animalCount, 'Animal')} reported in this survey` : `No individual animals were captured or reported in this survey` }`} diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 37d612c0c4..67399714c3 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -39,4 +39,5 @@ export interface IGetAllCodeSetsResponse { ecological_seasons: CodeSet<{ id: number; name: string; description: string }>; vantage_codes: CodeSet; survey_jobs: CodeSet; + site_selection_strategies: CodeSet; } diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index 9751aa1078..e65690e332 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -5,6 +5,7 @@ import { IProprietaryDataForm } from 'features/surveys/components/ProprietaryDat import { IPurposeAndMethodologyForm } from 'features/surveys/components/PurposeAndMethodologyForm'; import { IStudyAreaForm } from 'features/surveys/components/StudyAreaForm'; import { ISurveyFundingSource, ISurveyFundingSourceForm } from 'features/surveys/components/SurveyFundingSourceForm'; +import { ISurveySiteSelectionForm } from 'features/surveys/components/SurveySiteSelectionForm'; import { Feature } from 'geojson'; import { StringBoolean } from 'types/misc'; @@ -107,6 +108,7 @@ export interface SurveyViewObject { permit: ISurveyPermits; purpose_and_methodology: IGetSurveyForViewResponsePurposeAndMethodology; funding_sources: ISurveyFundingSource[]; + site_selection: ISurveySiteSelectionForm['site_selection']; proprietor: IGetSurveyForViewResponseProprietor | null; participants: IGetSurveyParticipant[]; partnerships: IGetSurveyForViewResponsePartnerships; @@ -334,5 +336,5 @@ export type IEditSurveyRequest = IGeneralInformationForm & ISurveyFundingSourceForm & IStudyAreaForm & IProprietaryDataForm & - IUpdateAgreementsForm & + IUpdateAgreementsForm & { partnerships: IGetSurveyForViewResponsePartnerships } & ISurveySiteSelectionForm & IParticipantsJobForm; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 4b71cb9b83..3ffc2616ef 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -55,5 +55,9 @@ export const codes: IGetAllCodeSetsResponse = { survey_jobs: [ { id: 1, name: 'Survey Job 1' }, { id: 2, name: 'Survey Job 2' } + ], + site_selection_strategies: [ + { id: 1, name: 'Strategy 1' }, + { id: 2, name: 'Strategy 2' } ] }; diff --git a/app/src/test-helpers/survey-helpers.ts b/app/src/test-helpers/survey-helpers.ts index e74e5db37b..a9087bbfa2 100644 --- a/app/src/test-helpers/survey-helpers.ts +++ b/app/src/test-helpers/survey-helpers.ts @@ -69,6 +69,10 @@ export const surveyObject: SurveyViewObject = { ancillary_species: [2], ancillary_species_names: ['ancillary species 2'] }, + site_selection: { + strategies: [], + stratums: [] + }, participants: [ { system_user_id: 1, diff --git a/app/src/utils/Utils.test.ts b/app/src/utils/Utils.test.ts index aa4d524427..42124cf050 100644 --- a/app/src/utils/Utils.test.ts +++ b/app/src/utils/Utils.test.ts @@ -11,7 +11,8 @@ import { getFormattedIdentitySource, getKeyByValue, getLogOutUrl, - getTitle + getTitle, + pluralize } from './Utils'; describe('ensureProtocol', () => { @@ -364,3 +365,30 @@ describe('getKeyByValue', () => { expect(response).toEqual('2'); }); }); + +describe('pluralize', () => { + it('pluralizes a word', () => { + const response = pluralize(2, 'apple'); + expect(response).toEqual('apples'); + }); + + it('pluralizes a word with undefined quantity', () => { + const response = pluralize(null as unknown as number, 'orange'); + expect(response).toEqual('oranges'); + }); + + it('does not pluralize a single item', () => { + const response = pluralize(1, 'banana'); + expect(response).toEqual('banana'); + }); + + it('pluralizes a word with a custom suffix', () => { + const response = pluralize(10, 'berr', 'y', 'ies'); + expect(response).toEqual('berries'); + }); + + it('does not pluralize a word with a custom suffix and single quantity', () => { + const response = pluralize(1, 'berr', 'y', 'ies'); + expect(response).toEqual('berry'); + }); +}); diff --git a/app/src/utils/Utils.ts b/app/src/utils/Utils.ts index 2954e484c0..3d3016c378 100644 --- a/app/src/utils/Utils.ts +++ b/app/src/utils/Utils.ts @@ -309,3 +309,21 @@ export const formatLabel = (str: string): string => { .map((a) => a.charAt(0).toUpperCase() + a.slice(1)) .join(' '); }; + +/** + * Pluralizes a word. + * + * @example p(2, 'apple'); // => 'apples' + * @example p(null, 'orange'); // => 'oranges' + * @example p(1, 'banana'); // => 'banana' + * @example p(10, 'berr', 'y', 'ies'); // => 'berries' + * + * @param quantity The quantity used to infer plural or singular + * @param word The word to pluralize + * @param {[string]} singularSuffix The suffix used for a singular item + * @param {[string]} pluralSuffix The suffix used for plural items + * @returns + */ +export const pluralize = (quantity: number, word: string, singularSuffix = '', pluralSuffix = 's') => { + return `${word}${quantity === 1 ? singularSuffix : pluralSuffix}`; +}; From 1fa6146eef62997d2b5c283ebad27ec2694603a5 Mon Sep 17 00:00:00 2001 From: JeremyQuartech <123425360+JeremyQuartech@users.noreply.github.com> Date: Wed, 13 Sep 2023 08:57:46 -0700 Subject: [PATCH 5/9] SIMSBIOHUB-207/216: Survey Animals Table and Telemetry Device Deployment (#1087) * Adds 'Individual Animals' table to the survey page. * Adds options to Deploy Device or Remove an Animal from the survey. * Adds new '/filter' endpoint to critter-data api and associated CritterbaseService method. * Adds various new critter and deployment related endpoints to the project/survey api. * Adds '/code' endpoint to telemetry api and associated BctwService method. * Adds new 'survey-critter-repository' for survey-critter specific functionality. * Adds new 'survey-critter-service'. --------- Co-authored-by: Mac Deluca Co-authored-by: Graham Stewart Co-authored-by: Alfred Rosenthal Co-authored-by: Curtis Upshall --- api/src/openapi/schemas/critter.ts | 72 ++++++ .../critter-data/critters/filter.test.ts | 42 ++++ api/src/paths/critter-data/critters/filter.ts | 129 ++++++++++ api/src/paths/critter-data/critters/index.ts | 72 +----- .../paths/critter-data/family/{familyId}.ts | 1 - .../survey/{surveyId}/critters/index.test.ts | 117 +++++++++ .../survey/{surveyId}/critters/index.ts | 237 ++++++++++++++++++ .../{surveyId}/critters/{critterId}.test.ts | 45 ++++ .../survey/{surveyId}/critters/{critterId}.ts | 103 ++++++++ .../critters/{critterId}/deployments.test.ts | 52 ++++ .../critters/{critterId}/deployments.ts | 154 ++++++++++++ .../survey/{surveyId}/deployments.test.ts | 74 ++++++ .../survey/{surveyId}/deployments.ts | 108 ++++++++ api/src/paths/telemetry/code.test.ts | 50 ++++ api/src/paths/telemetry/code.ts | 86 +++++++ .../paths/telemetry/device/{deviceId}.test.ts | 25 ++ api/src/paths/telemetry/device/{deviceId}.ts | 80 ++++++ .../survey-critter-repository.test.ts | 67 +++++ .../repositories/survey-critter-repository.ts | 77 ++++++ api/src/services/bctw-service.test.ts | 99 ++++---- api/src/services/bctw-service.ts | 53 +++- api/src/services/critterbase-service.test.ts | 183 ++++++++------ api/src/services/critterbase-service.ts | 19 ++ .../services/survey-critter-service.test.ts | 78 ++++++ api/src/services/survey-critter-service.ts | 54 ++++ app/src/components/dialog/EditDialog.tsx | 1 + .../fields/TelemetrySelectField.tsx | 55 ++++ app/src/components/tables/CustomDataGrid.tsx | 41 +++ .../list/FundingSourcesTable.tsx | 49 +--- .../features/surveys/view/SurveyAnimals.tsx | 115 ++++++++- .../survey-animals/SurveyAnimalsTable.tsx | 121 +++++++++ .../SurveyAnimalsTableActions.tsx | 114 +++++++++ .../survey-animals/TelemetryDeviceForm.tsx | 101 ++++++++ .../surveys/view/survey-animals/animal.ts | 32 ++- app/src/hooks/api/useSurveyApi.test.ts | 100 ++++++++ app/src/hooks/api/useSurveyApi.ts | 111 +++++++- app/src/hooks/telemetry/useDeviceApi.test.tsx | 43 ++++ app/src/hooks/telemetry/useDeviceApi.tsx | 84 +++++++ app/src/hooks/useTelemetryApi.ts | 15 ++ app/src/interfaces/useCritterApi.interface.ts | 128 ++++++++++ app/src/interfaces/useSurveyApi.interface.ts | 5 + 41 files changed, 2926 insertions(+), 266 deletions(-) create mode 100644 api/src/openapi/schemas/critter.ts create mode 100644 api/src/paths/critter-data/critters/filter.test.ts create mode 100644 api/src/paths/critter-data/critters/filter.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts create mode 100644 api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts create mode 100644 api/src/paths/telemetry/code.test.ts create mode 100644 api/src/paths/telemetry/code.ts create mode 100644 api/src/paths/telemetry/device/{deviceId}.test.ts create mode 100644 api/src/paths/telemetry/device/{deviceId}.ts create mode 100644 api/src/repositories/survey-critter-repository.test.ts create mode 100644 api/src/repositories/survey-critter-repository.ts create mode 100644 api/src/services/survey-critter-service.test.ts create mode 100644 api/src/services/survey-critter-service.ts create mode 100644 app/src/components/fields/TelemetrySelectField.tsx create mode 100644 app/src/components/tables/CustomDataGrid.tsx create mode 100644 app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx create mode 100644 app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx create mode 100644 app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx create mode 100644 app/src/hooks/api/useSurveyApi.test.ts create mode 100644 app/src/hooks/telemetry/useDeviceApi.test.tsx create mode 100644 app/src/hooks/telemetry/useDeviceApi.tsx create mode 100644 app/src/hooks/useTelemetryApi.ts create mode 100644 app/src/interfaces/useCritterApi.interface.ts diff --git a/api/src/openapi/schemas/critter.ts b/api/src/openapi/schemas/critter.ts new file mode 100644 index 0000000000..77b4ce5cfd --- /dev/null +++ b/api/src/openapi/schemas/critter.ts @@ -0,0 +1,72 @@ +import { OpenAPIV3 } from 'openapi-types'; + +export const critterCreateRequestObject: OpenAPIV3.SchemaObject = { + title: 'Bulk post request object', + type: 'object', + properties: { + critters: { + title: 'critters', + type: 'array', + items: { + title: 'critter', + type: 'object' + } + }, + captures: { + title: 'captures', + type: 'array', + items: { + title: 'capture', + type: 'object' + } + }, + collections: { + title: 'collection units', + type: 'array', + items: { + title: 'collection unit', + type: 'object' + } + }, + markings: { + title: 'markings', + type: 'array', + items: { + title: 'marking', + type: 'object' + } + }, + locations: { + title: 'locations', + type: 'array', + items: { + title: 'location', + type: 'object' + } + }, + mortalities: { + title: 'locations', + type: 'array', + items: { + title: 'location', + type: 'object' + } + }, + qualitative_measurements: { + title: 'qualitative measurements', + type: 'array', + items: { + title: 'qualitative measurement', + type: 'object' + } + }, + quantitative_measurements: { + title: 'quantitative measurements', + type: 'array', + items: { + title: 'quantitative measurement', + type: 'object' + } + } + } +}; diff --git a/api/src/paths/critter-data/critters/filter.test.ts b/api/src/paths/critter-data/critters/filter.test.ts new file mode 100644 index 0000000000..55df85e325 --- /dev/null +++ b/api/src/paths/critter-data/critters/filter.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { filterCritters } from './filter'; + +describe('filterCritters', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns a list of critters', async () => { + const mockCritters = ['critter1', 'critter2']; + const mockFilterCritters = sinon.stub(CritterbaseService.prototype, 'filterCritters').resolves(mockCritters); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = filterCritters(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith(mockCritters); + expect(mockFilterCritters).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockError = new Error('a test error'); + + const mockFilterCritters = sinon.stub(CritterbaseService.prototype, 'filterCritters').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = filterCritters(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockFilterCritters).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/critter-data/critters/filter.ts b/api/src/paths/critter-data/critters/filter.ts new file mode 100644 index 0000000000..8ade7a3f6e --- /dev/null +++ b/api/src/paths/critter-data/critters/filter.ts @@ -0,0 +1,129 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../constants/roles'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/critter-data/critters/filter'); +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + filterCritters() +]; + +POST.apiDoc = { + description: 'Retrieves critters by filtering the global list with the criteria specified in the request body.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Filtering request object', + content: { + 'application/json': { + schema: { + title: 'Bulk post request object', + type: 'object', + properties: { + critters: { + type: 'array', + items: { + type: 'string', + format: 'uuid' + } + }, + animal_ids: { + type: 'array', + items: { + type: 'string' + } + }, + wlh_ids: { + type: 'array', + items: { + type: 'string' + } + }, + collection_units: { + type: 'array', + items: { + type: 'string' + } + }, + taxon_name_commons: { + type: 'array', + items: { + type: 'string' + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Responds with an array of critters.', + content: { + 'application/json': { + schema: { + title: 'List of filtered critters', + type: 'array', + items: { + type: 'object' + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function filterCritters(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + + const cb = new CritterbaseService(user); + try { + const result = await cb.filterCritters(req.body); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'filterCritters', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/critters/index.ts b/api/src/paths/critter-data/critters/index.ts index 944f50e4c2..10244e10f8 100644 --- a/api/src/paths/critter-data/critters/index.ts +++ b/api/src/paths/critter-data/critters/index.ts @@ -1,6 +1,7 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../constants/roles'; +import { critterCreateRequestObject } from '../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; import { CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service'; import { getLogger } from '../../../utils/logger'; @@ -38,76 +39,7 @@ POST.apiDoc = { description: 'Critterbase bulk creation request object', content: { 'application/json': { - schema: { - title: 'Bulk post request object', - type: 'object', - properties: { - critters: { - title: 'critters', - type: 'array', - items: { - title: 'critter', - type: 'object' - } - }, - captures: { - title: 'captures', - type: 'array', - items: { - title: 'capture', - type: 'object' - } - }, - collections: { - title: 'collection units', - type: 'array', - items: { - title: 'collection unit', - type: 'object' - } - }, - markings: { - title: 'markings', - type: 'array', - items: { - title: 'marking', - type: 'object' - } - }, - locations: { - title: 'locations', - type: 'array', - items: { - title: 'location', - type: 'object' - } - }, - mortalities: { - title: 'locations', - type: 'array', - items: { - title: 'location', - type: 'object' - } - }, - qualitative_measurements: { - title: 'qualitative measurements', - type: 'array', - items: { - title: 'qualitative measurement', - type: 'object' - } - }, - quantitative_measurements: { - title: 'quantitative measurements', - type: 'array', - items: { - title: 'quantitative measurement', - type: 'object' - } - } - } - } + schema: critterCreateRequestObject } } }, diff --git a/api/src/paths/critter-data/family/{familyId}.ts b/api/src/paths/critter-data/family/{familyId}.ts index a5756b5970..46dda1ad29 100644 --- a/api/src/paths/critter-data/family/{familyId}.ts +++ b/api/src/paths/critter-data/family/{familyId}.ts @@ -5,7 +5,6 @@ import { authorizeRequestHandler } from '../../../request-handlers/security/auth import { CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service'; import { getLogger } from '../../../utils/logger'; -// TODO: Put this all into an existing endpoint const defaultLog = getLogger('paths/critter-data/family'); export const GET: Operation = [ diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts new file mode 100644 index 0000000000..ebe30bd8e4 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.test.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { addCritterToSurvey, getCrittersFromSurvey } from '.'; +import * as db from '../../../../../../database/db'; +import { CritterbaseService } from '../../../../../../services/critterbase-service'; +import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; + +describe('getCrittersFromSurvey', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const mockSurveyCritter = { critter_id: 123, survey_id: 123, critterbase_critter_id: 'critterbase1' }; + const mockCBCritter = { critter_id: 'critterbase1' }; + + it('returns critters from survey', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon + .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') + .resolves([mockSurveyCritter]); + const mockFilterCritters = sinon.stub(CritterbaseService.prototype, 'filterCritters').resolves([mockCBCritter]); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = getCrittersFromSurvey(); + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(mockFilterCritters.calledOnce).to.be.true; + expect(mockRes.json).to.have.been.calledWith([ + { ...mockCBCritter, survey_critter_id: mockSurveyCritter.critter_id } + ]); + }); + + it('returns empty array if no critters in survey', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon.stub(SurveyCritterService.prototype, 'getCrittersInSurvey').resolves([]); + const mockFilterCritters = sinon.stub(CritterbaseService.prototype, 'filterCritters').resolves([]); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = getCrittersFromSurvey(); + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(mockFilterCritters.calledOnce).to.be.false; + expect(mockRes.json).to.have.been.calledWith([]); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon + .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') + .rejects(mockError); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCrittersFromSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockDBConnection.release).to.have.been.called; + } + }); +}); + +describe('addCritterToSurvey', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const mockSurveyCritter = 123; + const mockCBCritter = { critter_id: 'critterbase1' }; + + it('returns critters from survey', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddCritterToSurvey = sinon + .stub(SurveyCritterService.prototype, 'addCritterToSurvey') + .resolves(mockSurveyCritter); + const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves(mockCBCritter); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = addCritterToSurvey(); + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddCritterToSurvey.calledOnce).to.be.true; + expect(mockCreateCritter.calledOnce).to.be.true; + expect(mockRes.status).to.have.been.calledWith(201); + expect(mockRes.json).to.have.been.calledWith(mockCBCritter); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddCritterToSurvey = sinon.stub(SurveyCritterService.prototype, 'addCritterToSurvey').rejects(mockError); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = addCritterToSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockAddCritterToSurvey.calledOnce).to.be.true; + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockDBConnection.release).to.have.been.called; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts new file mode 100644 index 0000000000..67a6621741 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts @@ -0,0 +1,237 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { critterCreateRequestObject } from '../../../../../../openapi/schemas/critter'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { CritterbaseService, ICritterbaseUser } from '../../../../../../services/critterbase-service'; +import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters'); +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + addCritterToSurvey() +]; + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getCrittersFromSurvey() +]; + +GET.apiDoc = { + description: 'Get all critters under this survey.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer' + }, + required: true + }, + { + in: 'query', + name: 'format', + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'Responds with critters under this survey.', + content: { + 'application/json': { + schema: { + title: 'Bulk creation response object', + type: 'array', + items: { + title: 'Default critter response', + type: 'object' + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +POST.apiDoc = { + description: + 'Creates a new critter in critterbase, and if successful, adds the a link to the critter_id under this survey.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + } + ], + requestBody: { + description: 'Critterbase bulk creation request object', + content: { + 'application/json': { + schema: critterCreateRequestObject + } + } + }, + responses: { + 201: { + description: 'Responds with counts of objects created in critterbase.', + content: { + 'application/json': { + schema: { + title: 'Bulk creation response object', + type: 'object' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getCrittersFromSurvey(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req['keycloak_token']); + + const surveyService = new SurveyCritterService(connection); + const critterbaseService = new CritterbaseService(user); + try { + await connection.open(); + const surveyCritters = await surveyService.getCrittersInSurvey(surveyId); + + // Exit early if surveyCritters list is empty + if (!surveyCritters.length) { + return res.status(200).json([]); + } + + const critterIds = surveyCritters.map((critter) => String(critter.critterbase_critter_id)); + const result = await critterbaseService.filterCritters( + { critter_ids: { body: critterIds, negate: false } }, + 'detailed' + ); + + const critterMap = new Map(); + for (const item of result) { + critterMap.set(item.critter_id, item); + } + + for (const surveyCritter of surveyCritters) { + if (critterMap.has(surveyCritter.critterbase_critter_id)) { + critterMap.get(surveyCritter.critterbase_critter_id).survey_critter_id = surveyCritter.critter_id; + } + } + return res.status(200).json([...critterMap.values()]); + } catch (error) { + defaultLog.error({ label: 'createCritter', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export function addCritterToSurvey(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req['keycloak_token']); + const surveyService = new SurveyCritterService(connection); + const cb = new CritterbaseService(user); + try { + await connection.open(); + await surveyService.addCritterToSurvey(surveyId, req.body.critter_id); + const result = await cb.createCritter(req.body); + await connection.commit(); + return res.status(201).json(result); + } catch (error) { + defaultLog.error({ label: 'createCritter', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts new file mode 100644 index 0000000000..3c1bc4fb3b --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as db from '../../../../../../database/db'; +import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import { removeCritterFromSurvey } from './{critterId}'; + +describe('removeCritterFromSurvey', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const mockSurveyCritter = 123; + + it('removes critter from survey', async () => { + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(SurveyCritterService.prototype, 'removeCritterFromSurvey').resolves(mockSurveyCritter); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = removeCritterFromSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.jsonValue).to.equal(mockSurveyCritter); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(SurveyCritterService.prototype, 'removeCritterFromSurvey').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = removeCritterFromSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts new file mode 100644 index 0000000000..1624d43eeb --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts @@ -0,0 +1,103 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { SurveyCritterService } from '../../../../../../services/survey-critter-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}'); +export const DELETE: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + removeCritterFromSurvey() +]; + +DELETE.apiDoc = { + description: 'Removes association of this critter to this survey.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'string' + }, + required: true + } + ], + responses: { + 200: { + description: 'Responds with affected number of rows.', + content: { + 'application/json': { + schema: { + title: 'Affected rows', + type: 'number' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function removeCritterFromSurvey(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const critterId = Number(req.params.critterId); + const connection = getDBConnection(req['keycloak_token']); + const surveyService = new SurveyCritterService(connection); + try { + await connection.open(); + const result = await surveyService.removeCritterFromSurvey(surveyId, critterId); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'removeCritterFromSurvey', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts new file mode 100644 index 0000000000..047b1556b2 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as db from '../../../../../../../database/db'; +import { BctwService } from '../../../../../../../services/bctw-service'; +import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; +import { deployDevice } from './deployments'; + +describe('deployDevice', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const mockSurveyEntry = 123; + + it('deploys a new telemetry device', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'addDeployment').resolves(mockSurveyEntry); + const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = deployDevice(); + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddDeployment.calledOnce).to.be.true; + expect(mockBctwService.calledOnce).to.be.true; + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith(mockSurveyEntry); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'addDeployment').rejects(mockError); + const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = deployDevice(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddDeployment.calledOnce).to.be.true; + expect(mockBctwService.notCalled).to.be.true; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts new file mode 100644 index 0000000000..1858dad297 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts @@ -0,0 +1,154 @@ +import { AxiosError } from 'axios'; +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { v4 } from 'uuid'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { BctwService } from '../../../../../../../services/bctw-service'; +import { ICritterbaseUser } from '../../../../../../../services/critterbase-service'; +import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters'); +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deployDevice() +]; + +POST.apiDoc = { + description: + 'Creates a new critter in critterbase, and if successful, adds the a link to the critter_id under this survey.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'number' + } + } + ], + requestBody: { + description: 'Critterbase bulk creation request object', + content: { + 'application/json': { + schema: { + title: 'Deploy device request object', + type: 'object', + properties: { + critter_id: { + type: 'string', + format: 'uuid' + }, + attachment_start: { + type: 'string' + }, + attachment_end: { + type: 'string' + }, + device_id: { + type: 'integer' + }, + frequency: { + type: 'number' + }, + frequency_unit: { + type: 'string' + }, + device_make: { + type: 'string' + }, + device_model: { + type: 'string' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Responds with count of rows created in SIMS DB Deployments.', + content: { + 'application/json': { + schema: { + title: 'Number of rows affected', + type: 'number' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function deployDevice(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const surveyId = Number(req.params.critterId); + const connection = getDBConnection(req['keycloak_token']); + const surveyCritterService = new SurveyCritterService(connection); + const bctw = new BctwService(user); + try { + await connection.open(); + const override_deployment_id = v4(); + req.body.deployment_id = override_deployment_id; + const surveyEntry = await surveyCritterService.addDeployment(surveyId, req.body.deployment_id); + await bctw.deployDevice(req.body); + await connection.commit(); + return res.status(200).json(surveyEntry); + } catch (error) { + defaultLog.error({ label: 'addDeployment', message: 'error', error }); + console.log(JSON.stringify((error as Error).message)); + await connection.rollback(); + return res.status(500).json((error as AxiosError).response); + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts new file mode 100644 index 0000000000..9932141f89 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.test.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as db from '../../../../../database/db'; +import { BctwService, IDeploymentRecord } from '../../../../../services/bctw-service'; +import { SurveyCritterService } from '../../../../../services/survey-critter-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../__mocks__/db'; +import { getDeploymentsInSurvey } from './deployments'; + +describe('getDeploymentsInSurvey', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); + const mockDeployments: IDeploymentRecord[] = [ + { + critter_id: 'critterbase1', + assignment_id: 'assignment1', + collar_id: 'collar1', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + deployment_id: 'deployment1', + device_id: 123, + created_at: '2020-01-01', + created_by_user_id: 'user1', + updated_at: '2020-01-01', + updated_by_user_id: 'user1', + valid_from: '2020-01-01', + valid_to: '2020-01-02' + } + ]; + const mockCritters = [{ critter_id: 123, survey_id: 123, critterbase_critter_id: 'critterbase1' }]; + + it('gets deployments in survey', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon + .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') + .resolves(mockCritters); + const getDeploymentsByCritterId = sinon + .stub(BctwService.prototype, 'getDeploymentsByCritterId') + .resolves(mockDeployments); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getDeploymentsInSurvey(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(getDeploymentsByCritterId.calledOnce).to.be.true; + expect(mockRes.json.calledWith(mockDeployments)).to.be.true; + expect(mockRes.status.calledWith(200)).to.be.true; + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockGetCrittersInSurvey = sinon + .stub(SurveyCritterService.prototype, 'getCrittersInSurvey') + .rejects(mockError); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getDeploymentsInSurvey(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetCrittersInSurvey.calledOnce).to.be.true; + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockDBConnection.release).to.have.been.called; + } + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts new file mode 100644 index 0000000000..96652edf61 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments.ts @@ -0,0 +1,108 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../constants/roles'; +import { getDBConnection } from '../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../request-handlers/security/authorization'; +import { BctwService } from '../../../../../services/bctw-service'; +import { ICritterbaseUser } from '../../../../../services/critterbase-service'; +import { SurveyCritterService } from '../../../../../services/survey-critter-service'; +import { getLogger } from '../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters'); +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getDeploymentsInSurvey() +]; + +GET.apiDoc = { + description: + 'Fetches a list of all the deployments under this survey. This is determined by the critters under this survey.', + tags: ['bctw'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + } + ], + responses: { + 200: { + description: 'Responds with all deployments under this survey, determined by critters under the survey.', + content: { + 'application/json': { + schema: { + title: 'Deployments', + type: 'array', + items: { + type: 'object' + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getDeploymentsInSurvey(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req['keycloak_token']); + const surveyCritterService = new SurveyCritterService(connection); + const bctw = new BctwService(user); + try { + await connection.open(); + const critter_ids = (await surveyCritterService.getCrittersInSurvey(surveyId)).map( + (a) => a.critterbase_critter_id + ); + const results = critter_ids.length ? await bctw.getDeploymentsByCritterId(critter_ids) : []; + return res.status(200).json(results); + } catch (error) { + defaultLog.error({ label: 'getDeploymentsInSurvey', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/telemetry/code.test.ts b/api/src/paths/telemetry/code.test.ts new file mode 100644 index 0000000000..1de6816519 --- /dev/null +++ b/api/src/paths/telemetry/code.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService } from '../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../__mocks__/db'; +import { getCodeValues } from './code'; + +describe('getCodeValues', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns a list of Bctw code objects', async () => { + const mockCodeValues = [ + { + code_header_title: 'title', + code_header_name: 'name', + id: 123, + code: 'code', + description: 'description', + long_description: 'long_description' + } + ]; + const mockGetCode = sinon.stub(BctwService.prototype, 'getCode').resolves(mockCodeValues); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCodeValues(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockCodeValues); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetCode).to.have.been.calledOnce; + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('mock error'); + const mockGetCode = sinon.stub(BctwService.prototype, 'getCode').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCodeValues(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetCode).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/code.ts b/api/src/paths/telemetry/code.ts new file mode 100644 index 0000000000..a439adc077 --- /dev/null +++ b/api/src/paths/telemetry/code.ts @@ -0,0 +1,86 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { BctwService, IBctwUser } from '../../services/bctw-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/telemetry/code'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getCodeValues() +]; + +GET.apiDoc = { + description: 'Get a list of "code" values from the exterior telemetry system.', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Generic telemetry code response.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + code: { type: 'string' }, + code_header_title: { type: 'string' }, + code_header_name: { type: 'string' }, + description: { type: 'string' }, + long_description: { type: 'string' } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getCodeValues(): RequestHandler { + return async (req, res) => { + const user: IBctwUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const bctwService = new BctwService(user); + const codeHeader = String(req.query.codeHeader); + try { + const result = await bctwService.getCode(codeHeader); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getCodeValues', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/device/{deviceId}.test.ts b/api/src/paths/telemetry/device/{deviceId}.test.ts new file mode 100644 index 0000000000..ead4851fe5 --- /dev/null +++ b/api/src/paths/telemetry/device/{deviceId}.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService } from '../../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getDeviceDetails } from './{deviceId}'; + +describe('getDeviceDetails', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets device details', async () => { + const mockGetDeviceDetails = sinon.stub(BctwService.prototype, 'getDeviceDetails').resolves([]); + const mockGetDeployments = sinon.stub(BctwService.prototype, 'getDeviceDeployments').resolves([]); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getDeviceDetails(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(200); + expect(mockGetDeviceDetails).to.have.been.calledOnce; + expect(mockGetDeployments).to.have.been.calledOnce; + }); +}); diff --git a/api/src/paths/telemetry/device/{deviceId}.ts b/api/src/paths/telemetry/device/{deviceId}.ts new file mode 100644 index 0000000000..32d45e0aba --- /dev/null +++ b/api/src/paths/telemetry/device/{deviceId}.ts @@ -0,0 +1,80 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { BctwService, IBctwUser } from '../../../services/bctw-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/telemetry/device/{deviceId}'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getDeviceDetails() +]; + +GET.apiDoc = { + description: 'Get a list of metadata changes to a device from the exterior telemetry system.', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Device change history response', + content: { + 'application/json': { + schema: { + type: 'object' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getDeviceDetails(): RequestHandler { + return async (req, res) => { + const user: IBctwUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const bctwService = new BctwService(user); + const deviceId = Number(req.params.deviceId); + try { + const results = await bctwService.getDeviceDetails(deviceId); + const deployments = await bctwService.getDeviceDeployments(deviceId); + const retObj = { + device: results?.[0], + deployments: deployments + }; + return res.status(200).json(retObj); + } catch (error) { + defaultLog.error({ label: 'getDeviceDetails', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/repositories/survey-critter-repository.test.ts b/api/src/repositories/survey-critter-repository.test.ts new file mode 100644 index 0000000000..826bacbc41 --- /dev/null +++ b/api/src/repositories/survey-critter-repository.test.ts @@ -0,0 +1,67 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SurveyCritterRepository } from './survey-critter-repository'; + +chai.use(sinonChai); + +describe('SurveyRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getCrittersInSurvey', () => { + it('should return result', async () => { + const mockSurveyCritter = { critter_id: 1, survey_id: 1, critterbase_critter_id: 1 }; + const mockResponse = ({ rows: [mockSurveyCritter], rowCount: 1 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new SurveyCritterRepository(dbConnection); + + const response = await repository.getCrittersInSurvey(1); + + expect(response).to.eql([mockSurveyCritter]); + }); + }); + + describe('addCritterToSurvey', () => { + it('should return result', async () => { + const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new SurveyCritterRepository(dbConnection); + + const response = await repository.addCritterToSurvey(1, 'critter_id'); + + expect(response).to.eql(1); + }); + }); + + describe('removeCritterFromSurvey', () => { + it('should return result', async () => { + const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new SurveyCritterRepository(dbConnection); + + const response = await repository.removeCritterFromSurvey(1, 1); + + expect(response).to.eql(1); + }); + }); + + describe('addDeployment', () => { + it('should return result', async () => { + const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new SurveyCritterRepository(dbConnection); + + const response = await repository.addDeployment(1, 'deployment_id'); + + expect(response).to.eql(1); + }); + }); +}); diff --git a/api/src/repositories/survey-critter-repository.ts b/api/src/repositories/survey-critter-repository.ts new file mode 100644 index 0000000000..5f839493b9 --- /dev/null +++ b/api/src/repositories/survey-critter-repository.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { getLogger } from '../utils/logger'; +import { BaseRepository } from './base-repository'; + +export const SurveyCritterRecord = z.object({ + critter_id: z.number(), + survey_id: z.number(), + critterbase_critter_id: z.string().uuid() +}); + +export type SurveyCritterRecord = z.infer; + +const defaultLog = getLogger('repositories/survey-repository'); + +export class SurveyCritterRepository extends BaseRepository { + /** + * Get critters in this survey + * + * @param {number} surveyId + * @returns {*} + * @member SurveyRepository + */ + async getCrittersInSurvey(surveyId: number): Promise { + defaultLog.debug({ label: 'getcrittersInSurvey', surveyId }); + const queryBuilder = getKnex().table('critter').select().where('survey_id', surveyId); + const response = await this.connection.knex(queryBuilder); + return response.rows; + } + + /** + * Add critter to survey + * + * @param {number} surveyId + * @param {string} critterId + * @returns {*} + * @member SurveyRepository + */ + async addCritterToSurvey(surveyId: number, critterId: string): Promise { + defaultLog.debug({ label: 'addCritterToSurvey', surveyId }); + const queryBuilder = getKnex().table('critter').insert({ survey_id: surveyId, critterbase_critter_id: critterId }); + const response = await this.connection.knex(queryBuilder); + return response.rowCount; + } + + /** + * Removes a critter from the survey. + * + * @param surveyId + * @param critterId + * @returns {*} + * @member SurveyRepository + */ + async removeCritterFromSurvey(surveyId: number, critterId: number): Promise { + defaultLog.debug({ label: 'removeCritterFromSurvey', surveyId }); + const queryBuilder = getKnex().table('critter').delete().where({ survey_id: surveyId, critter_id: critterId }); + const response = await this.connection.knex(queryBuilder); + return response.rowCount; + } + + /** + * Add a deployment to the critter. + * + * @param {number} critterId + * @param {string} deplyomentId + * @return {*} {Promise} + * @memberof SurveyCritterRepository + */ + async addDeployment(critterId: number, deplyomentId: string): Promise { + defaultLog.debug({ label: 'addDeployment', deplyomentId }); + const queryBuilder = getKnex() + .table('deployment') + .insert({ critter_id: critterId, bctw_deployment_id: deplyomentId }); + const response = await this.connection.knex(queryBuilder); + return response.rowCount; + } +} diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts index b4efad9ffa..295367da9e 100755 --- a/api/src/services/bctw-service.test.ts +++ b/api/src/services/bctw-service.test.ts @@ -7,8 +7,12 @@ import { BctwService, BCTW_API_HOST, DEPLOY_DEVICE_ENDPOINT, + GET_CODE_ENDPOINT, GET_COLLAR_VENDORS_ENDPOINT, + GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT, + GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, GET_DEPLOYMENTS_ENDPOINT, + GET_DEVICE_DETAILS, HEALTH_ENDPOINT, IDeployDevice, IDeploymentUpdate, @@ -56,38 +60,6 @@ describe('BctwService', () => { }); }); - // describe('handleRequestError', () => { - // afterEach(() => { - // sinon.restore(); - // }); - - // it('should throw an error if the status is not 200', async () => { - // const bctwService = new BctwService(mockUser); - // const response = { data: 'data', status: 400 } as AxiosResponse; - // const endpoint = '/endpoint'; - // try { - // await bctwService.handleRequestError(response, endpoint); - // } catch (error) { - // expect((error as Error).message).to.equal( - // `API request to ${endpoint} failed with status code ${response.status}` - // ); - // } - // }); - - // it('should throw an error if the response has no data', async () => { - // const bctwService = new BctwService(mockUser); - // const response = { data: null, status: 200 } as AxiosResponse; - // const endpoint = '/endpoint'; - // try { - // await bctwService.handleRequestError(response, endpoint); - // } catch (error) { - // expect((error as Error).message).to.equal( - // `API request to ${endpoint} failed with status code ${response.status}` - // ); - // } - // }); - // }); - describe('_makeGetRequest', () => { afterEach(() => { sinon.restore(); @@ -121,25 +93,6 @@ describe('BctwService', () => { }); }); - // describe('makePostPatchRequest', () => { - // afterEach(() => { - // sinon.restore(); - // }); - - // it('should make an axios post/patch request', async () => { - // const bctwService = new BctwService(mockUser); - // const endpoint = '/endpoint'; - // const mockResponse = { data: 'data' } as AxiosResponse; - - // const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves(mockResponse); - - // const result = await bctwService.makePostPatchRequest('post', endpoint, { foo: 'bar' }); - - // expect(result).to.equal(mockResponse.data); - // expect(mockAxios).to.have.been.calledOnce; - // }); - // }); - describe('BctwService public methods', () => { afterEach(() => { sinon.restore(); @@ -210,5 +163,49 @@ describe('BctwService', () => { expect(mockGetRequest).to.have.been.calledOnceWith(HEALTH_ENDPOINT); }); }); + + describe('getCode', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getCode('codeHeader'); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_CODE_ENDPOINT, { codeHeader: 'codeHeader' }); + }); + }); + + describe('getDeploymentsByCritterId', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getDeploymentsByCritterId(['abc123']); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT, { + critter_ids: ['abc123'] + }); + }); + }); + + describe('getDeviceDetails', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getDeviceDetails(123); + + expect(mockGetRequest).to.have.been.calledOnceWith(`${GET_DEVICE_DETAILS}${123}`); + }); + }); + + describe('getDeviceDeployments', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getDeviceDeployments(123); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, { + device_id: '123' + }); + }); + }); }); }); diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts index cb65b86e0a..e482f83c05 100644 --- a/api/src/services/bctw-service.ts +++ b/api/src/services/bctw-service.ts @@ -36,7 +36,8 @@ export const IDeploymentRecord = z.object({ valid_to: z.string(), attachment_start: z.string(), attachment_end: z.string(), - deployment_id: z.number() + deployment_id: z.string(), + device_id: z.number() }); export type IDeploymentRecord = z.infer; @@ -46,14 +47,27 @@ export const IBctwUser = z.object({ username: z.string() }); +interface ICodeResponse { + code_header_title: string; + code_header_name: string; + id: number; + code: string; + description: string; + long_description: string; +} + export type IBctwUser = z.infer; export const BCTW_API_HOST = process.env.BCTW_API_HOST || ''; export const DEPLOY_DEVICE_ENDPOINT = '/deploy-device'; export const GET_DEPLOYMENTS_ENDPOINT = '/get-deployments'; +export const GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT = '/get-deployments-by-critter-id'; +export const GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT = '/get-deployments-by-device-id'; export const UPDATE_DEPLOYMENT_ENDPOINT = '/update-deployment'; export const GET_COLLAR_VENDORS_ENDPOINT = '/get-collar-vendors'; export const HEALTH_ENDPOINT = '/health'; +export const GET_CODE_ENDPOINT = '/get-code'; +export const GET_DEVICE_DETAILS = '/get-collar-history-by-device/'; export class BctwService { user: IBctwUser; @@ -76,7 +90,9 @@ export class BctwService { }, (error: AxiosError) => { return Promise.reject( - new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`) + new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`, [ + error?.response?.data + ]) ); } ); @@ -124,7 +140,7 @@ export class BctwService { * @return {*} * @memberof BctwService */ - async _makeGetRequest(endpoint: string, queryParams?: Record) { + async _makeGetRequest(endpoint: string, queryParams?: Record) { let url = endpoint; if (queryParams) { const params = new URLSearchParams(queryParams); @@ -145,6 +161,14 @@ export class BctwService { return await this.axiosInstance.post(DEPLOY_DEVICE_ENDPOINT, device); } + async getDeviceDetails(deviceId: number): Promise[]> { + return await this._makeGetRequest(`${GET_DEVICE_DETAILS}${deviceId}`); + } + + async getDeviceDeployments(deviceId: number): Promise { + return await this._makeGetRequest(GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, { device_id: String(deviceId) }); + } + /** * Get all existing deployments. * @@ -155,6 +179,18 @@ export class BctwService { return this._makeGetRequest(GET_DEPLOYMENTS_ENDPOINT); } + /** + * Get all existing deployments for a list of critter IDs. + * + * @param {string[]} critter_ids + * @return {*} {Promise} + * @memberof BctwService + */ + async getDeploymentsByCritterId(critter_ids: string[]): Promise { + const query = { critter_ids: critter_ids }; + return this._makeGetRequest(GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT, query); + } + /** * Update the start and end dates of an existing deployment. * @@ -185,4 +221,15 @@ export class BctwService { async getHealth(): Promise { return this._makeGetRequest(HEALTH_ENDPOINT); } + + /** + * Get a list of all BCTW codes with a given header name. + * + * @param {string} codeHeaderName + * @return {*} {Promise} + * @memberof BctwService + */ + async getCode(codeHeaderName: string): Promise { + return this._makeGetRequest(GET_CODE_ENDPOINT, { codeHeader: codeHeaderName }); + } } diff --git a/api/src/services/critterbase-service.test.ts b/api/src/services/critterbase-service.test.ts index 85ad7e83e2..4a6ae98bfc 100644 --- a/api/src/services/critterbase-service.test.ts +++ b/api/src/services/critterbase-service.test.ts @@ -2,7 +2,7 @@ import { AxiosResponse } from 'axios'; import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { CritterbaseService, CRITTERBASE_API_HOST, IBulkCreate } from './critterbase-service'; +import { CritterbaseService, CRITTERBASE_API_HOST } from './critterbase-service'; import { KeycloakService } from './keycloak-service'; chai.use(sinonChai); @@ -71,25 +71,6 @@ describe('CritterbaseService', () => { }); }); - describe('makePostPatchRequest', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should make an axios post/patch request', async () => { - const cb = new CritterbaseService(mockUser); - const endpoint = '/endpoint'; - const mockResponse = { data: 'data' } as AxiosResponse; - - const mockAxios = sinon.stub(cb.axiosInstance, 'post').resolves(mockResponse); - - const result = await cb.axiosInstance.post(endpoint, { foo: 'bar' }); - - expect(result).to.equal(mockResponse); - expect(mockAxios).to.have.been.calledOnce; - }); - }); - describe('Critterbase service public methods', () => { afterEach(() => { sinon.restore(); @@ -97,64 +78,110 @@ describe('CritterbaseService', () => { const cb = new CritterbaseService(mockUser); - describe('getLookupValues', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - await cb.getLookupValues('colours', []); - expect(mockGetRequest).to.have.been.calledOnceWith('lookups/colours', [{ key: 'format', value: 'asSelect ' }]); - }); - describe('getTaxonMeasurements', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - await cb.getTaxonMeasurements('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('xref/taxon-measurements', [ - { key: 'taxon_id', value: 'asdf ' } - ]); - }); - describe('getTaxonBodyLocations', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - await cb.getTaxonBodyLocations('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('xref/taxon-marking-body-locations', [ - { key: 'taxon_id', value: 'asdf' }, - { key: 'format', value: 'asSelect' } - ]); - }); - describe('getQualitativeOptions', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - await cb.getQualitativeOptions('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('xref/taxon-qualitative-measurement-options', [ - { key: 'taxon_measurement_id', value: 'asdf' }, - { key: 'format', value: 'asSelect' } - ]); - }); - describe('getFamilies', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - await cb.getFamilies(); - expect(mockGetRequest).to.have.been.calledOnceWith('family', []); - }); - describe('getCritter', async () => { - const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); - await cb.getCritter('asdf'); - expect(mockGetRequest).to.have.been.calledOnceWith('critters/' + 'asdf', [{ key: 'format', value: 'detail' }]); - }); - describe('createCritter', async () => { - const mockPostPatchRequest = sinon.stub(cb.axiosInstance, 'post'); - const data: IBulkCreate = { - locations: [{ latitude: 2, longitude: 2 }], - critters: [], - captures: [], - mortalities: [], - markings: [], - qualitative_measurements: [], - quantitative_measurements: [], - families: [], - collections: [] - }; - await cb.createCritter(data); - expect(mockPostPatchRequest).to.have.been.calledOnceWith('post', 'critters', data); - }); - describe('signUp', async () => { - const mockPostPatchRequest = sinon.stub(cb.axiosInstance, 'post'); - await cb.signUp(); - expect(mockPostPatchRequest).to.have.been.calledOnceWith('post', 'signup'); + describe('getLookupValues', () => { + it('should retrieve matching lookup values', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + const mockParams = [{ key: 'format', value: 'asSelect ' }]; + await cb.getLookupValues('colours', mockParams); + expect(mockGetRequest).to.have.been.calledOnceWith('/lookups/colours', mockParams); + }); + }); + + describe('getTaxonMeasurements', () => { + it('should retrieve taxon measurements', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getTaxonMeasurements('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('/xref/taxon-measurements', [ + { key: 'taxon_id', value: 'asdf' } + ]); + }); + }); + + describe('getTaxonBodyLocations', () => { + it('should retrieve taxon body locations', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getTaxonBodyLocations('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('/xref/taxon-marking-body-locations', [ + { key: 'taxon_id', value: 'asdf' }, + { key: 'format', value: 'asSelect' } + ]); + }); + }); + + describe('getQualitativeOptions', () => { + it('should retrieve qualitative options', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getQualitativeOptions('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('/xref/taxon-qualitative-measurement-options', [ + { key: 'taxon_measurement_id', value: 'asdf' }, + { key: 'format', value: 'asSelect' } + ]); + }); + }); + + describe('getFamilies', () => { + it('should retrieve families', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getFamilies(); + expect(mockGetRequest).to.have.been.calledOnceWith('/family', []); + }); + }); + + describe('getFamilyById', () => { + it('should retrieve a family', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getFamilyById('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('/family/' + 'asdf', []); + }); + }); + + describe('getCritter', () => { + it('should fetch a critter', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getCritter('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('/critters/' + 'asdf', [{ key: 'format', value: 'detail' }]); + }); + }); + + describe('createCritter', () => { + it('should create a critter', async () => { + const data = { + locations: [{ latitude: 2, longitude: 2 }], + critters: [], + captures: [], + mortalities: [], + markings: [], + qualitative_measurements: [], + quantitative_measurements: [], + families: [], + collections: [] + }; + const axiosStub = sinon.stub(cb.axiosInstance, 'post').resolves({ data: [] }); + + await cb.createCritter(data); + expect(axiosStub).to.have.been.calledOnceWith('/bulk', data); + }); + }); + + describe('signUp', () => { + it('should sign up a user', async () => { + const axiosStub = sinon.stub(cb.axiosInstance, 'post').resolves({ data: [] }); + await cb.signUp(); + expect(axiosStub).to.have.been.calledOnceWith('/signup'); + }); + }); + + describe('filterCritters', () => { + it('should filter critters', async () => { + const axiosStub = sinon.stub(cb.axiosInstance, 'post').resolves({ data: [] }); + const mockFilterObj = { body: ['mock_id'], negate: false }; + const mockFilterCritters = { + critter_ids: mockFilterObj, + animal_ids: mockFilterObj + }; + await cb.filterCritters(mockFilterCritters); + expect(axiosStub).to.have.been.calledOnceWith('/critters/filter?format=default', mockFilterCritters); + }); }); }); }); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts index f8c5c7ce32..417702338f 100644 --- a/api/src/services/critterbase-service.ts +++ b/api/src/services/critterbase-service.ts @@ -117,6 +117,19 @@ export interface IBulkCreate { families: IFamilyPayload[]; } +interface IFilterObj { + body: string[]; + negate: boolean; +} + +export interface IFilterCritters { + critter_ids?: IFilterObj; + animal_ids?: IFilterObj; + wlh_ids?: IFilterObj; + collection_units?: IFilterObj; + taxon_name_commons?: IFilterObj; +} + export interface ICbSelectRows { key: string; id: string; @@ -164,6 +177,7 @@ export type CbRouteKey = keyof typeof CbRoutes; export const CRITTERBASE_API_HOST = process.env.CB_API_HOST || ``; const CRITTER_ENDPOINT = '/critters'; +const FILTER_ENDPOINT = `${CRITTER_ENDPOINT}/filter`; const BULK_ENDPOINT = '/bulk'; const SIGNUP_ENDPOINT = '/signup'; const FAMILY_ENDPOINT = '/family'; @@ -272,6 +286,11 @@ export class CritterbaseService { return response.data; } + async filterCritters(data: IFilterCritters, format: 'default' | 'detailed' = 'default') { + const response = await this.axiosInstance.post(`${FILTER_ENDPOINT}?format=${format}`, data); + return response.data; + } + async signUp() { const response = await this.axiosInstance.post(SIGNUP_ENDPOINT); return response.data; diff --git a/api/src/services/survey-critter-service.test.ts b/api/src/services/survey-critter-service.test.ts new file mode 100644 index 0000000000..2afbf41b11 --- /dev/null +++ b/api/src/services/survey-critter-service.test.ts @@ -0,0 +1,78 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SurveyCritterRepository } from '../repositories/survey-critter-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SurveyCritterService } from './survey-critter-service'; + +chai.use(sinonChai); + +describe('SurveyService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getCrittersInSurvey', () => { + it('returns the first row on success', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyCritterService(dbConnection); + + const data = [ + { + survey_id: 1, + critter_id: 1, + critterbase_critter_id: 'critter_id' + } + ]; + + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'getCrittersInSurvey').resolves(data); + + const response = await service.getCrittersInSurvey(1); + + expect(repoStub).to.be.calledOnce; + expect(response).to.eql(data); + }); + }); + + describe('addCritterToSurvey', () => { + it('returns the first row on success', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyCritterService(dbConnection); + + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'addCritterToSurvey').resolves(1); + + const response = await service.addCritterToSurvey(1, 'critter_id'); + + expect(repoStub).to.be.calledOnce; + expect(response).to.eql(1); + }); + }); + + describe('removeCritterFromSurvey', () => { + it('returns the first row on success', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyCritterService(dbConnection); + + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'removeCritterFromSurvey').resolves(1); + + const response = await service.removeCritterFromSurvey(1, 1); + + expect(repoStub).to.be.calledOnce; + expect(response).to.eql(1); + }); + }); + + describe('addDeployment', () => { + it('returns the first row on success', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyCritterService(dbConnection); + + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'addDeployment').resolves(1); + + const response = await service.addDeployment(1, 'deployment_id'); + + expect(repoStub).to.be.calledOnce; + expect(response).to.eql(1); + }); + }); +}); diff --git a/api/src/services/survey-critter-service.ts b/api/src/services/survey-critter-service.ts new file mode 100644 index 0000000000..4b2e5f6a31 --- /dev/null +++ b/api/src/services/survey-critter-service.ts @@ -0,0 +1,54 @@ +import { IDBConnection } from '../database/db'; +import { SurveyCritterRepository } from '../repositories/survey-critter-repository'; +import { DBService } from './db-service'; + +export class SurveyCritterService extends DBService { + critterRepository: SurveyCritterRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.critterRepository = new SurveyCritterRepository(connection); + } + /** + * Get all critter associations for the given survey. This only gets you critter ids, which can be used to fetch details from the external system. + * @param {number} surveyId + * @returns {*} + */ + async getCrittersInSurvey(surveyId: number) { + return this.critterRepository.getCrittersInSurvey(surveyId); + } + + /** + * Add a critter as part of this survey. Does not create anything in the external system. + * + * @param {number} surveyId + * @param {string} critterId + * @returns {*} + */ + async addCritterToSurvey(surveyId: number, critterBaseCritterId: string) { + return this.critterRepository.addCritterToSurvey(surveyId, critterBaseCritterId); + } + + /** + * Removes a critter from the survey. Does not affect the critter in the external system. + * @param {number} surveyId + * @param {string} critterId + * @returns {*} + */ + async removeCritterFromSurvey(surveyId: number, critterId: number) { + return this.critterRepository.removeCritterFromSurvey(surveyId, critterId); + } + + /** + * Adds a deployment to a critter. Does not affect the critter or device in the external system. + * + * @param {number} critterId + * @param {string} deplyomentId + * @return {*} + * @memberof SurveyCritterService + */ + async addDeployment(critterId: number, deplyomentId: string) { + return this.critterRepository.addDeployment(critterId, deplyomentId); + } +} diff --git a/app/src/components/dialog/EditDialog.tsx b/app/src/components/dialog/EditDialog.tsx index 5de8671fa1..dff6c1c8ba 100644 --- a/app/src/components/dialog/EditDialog.tsx +++ b/app/src/components/dialog/EditDialog.tsx @@ -123,6 +123,7 @@ export const EditDialog = (props: PropsWithChildren Promise<(string | number)[]>; + controlProps?: FormControlProps; +} + +interface ISelectOption { + value: string | number; + label: string; +} + +const TelemetrySelectField: React.FC = (props) => { + const bctwLookupLoader = useDataLoader(() => props.fetchData()); + const { values, touched, errors, handleChange, handleBlur } = useFormikContext(); + + const err = get(touched, props.name) && get(errors, props.name); + + if (!bctwLookupLoader.data) { + bctwLookupLoader.load(); + } + + return ( + + {props.label} + + {err} + + ); +}; + +export default TelemetrySelectField; diff --git a/app/src/components/tables/CustomDataGrid.tsx b/app/src/components/tables/CustomDataGrid.tsx new file mode 100644 index 0000000000..0036f56b6d --- /dev/null +++ b/app/src/components/tables/CustomDataGrid.tsx @@ -0,0 +1,41 @@ +import { grey } from '@mui/material/colors'; +import { makeStyles } from '@mui/styles'; +import { DataGrid, DataGridProps } from '@mui/x-data-grid'; +import NoRowsOverlay from 'features/funding-sources/list/FundingSourcesTableNoRowsOverlay'; +import { useCallback } from 'react'; +const useStyles = makeStyles(() => ({ + projectsTable: { + tableLayout: 'fixed' + }, + linkButton: { + textAlign: 'left', + fontWeight: 700 + }, + noDataText: { + fontFamily: 'inherit !important', + fontSize: '0.875rem', + fontWeight: 700 + }, + dataGrid: { + border: 'none !important', + fontFamily: 'inherit !important', + '& .MuiDataGrid-columnHeaderTitle': { + textTransform: 'uppercase', + fontSize: '0.875rem', + fontWeight: 700, + color: grey[600] + }, + '& .MuiDataGrid-cell:focus-within, & .MuiDataGrid-cellCheckbox:focus-within, & .MuiDataGrid-columnHeader:focus-within': + { + outline: 'none !important' + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent !important' + } + } +})); +export const CustomDataGrid = (props: DataGridProps) => { + const classes = useStyles(); + const NoRowsOverlayStyled = useCallback(() => , [classes.noDataText]); + return ; +}; diff --git a/app/src/features/funding-sources/list/FundingSourcesTable.tsx b/app/src/features/funding-sources/list/FundingSourcesTable.tsx index b975eca48f..a3b2c4ee31 100644 --- a/app/src/features/funding-sources/list/FundingSourcesTable.tsx +++ b/app/src/features/funding-sources/list/FundingSourcesTable.tsx @@ -1,43 +1,8 @@ -import { grey } from '@mui/material/colors'; -import { makeStyles } from '@mui/styles'; -import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { GridColDef } from '@mui/x-data-grid'; +import { CustomDataGrid } from 'components/tables/CustomDataGrid'; import { IGetFundingSourcesResponse } from 'interfaces/useFundingSourceApi.interface'; -import { useCallback } from 'react'; import { getFormattedAmount } from 'utils/Utils'; import TableActionsMenu from './FundingSourcesTableActionsMenu'; -import NoRowsOverlay from './FundingSourcesTableNoRowsOverlay'; - -const useStyles = makeStyles(() => ({ - projectsTable: { - tableLayout: 'fixed' - }, - linkButton: { - textAlign: 'left', - fontWeight: 700 - }, - noDataText: { - fontFamily: 'inherit !important', - fontSize: '0.875rem', - fontWeight: 700 - }, - dataGrid: { - border: 'none !important', - fontFamily: 'inherit !important', - '& .MuiDataGrid-columnHeaderTitle': { - textTransform: 'uppercase', - fontSize: '0.875rem', - fontWeight: 700, - color: grey[600] - }, - '& .MuiDataGrid-cell:focus-within, & .MuiDataGrid-cellCheckbox:focus-within, & .MuiDataGrid-columnHeader:focus-within': - { - outline: 'none !important' - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: 'transparent !important' - } - } -})); export interface IFundingSourcesTableTableProps { fundingSources: IGetFundingSourcesResponse[]; @@ -54,8 +19,6 @@ export interface IFundingSourcesTableEntry { } const FundingSourcesTable = (props: IFundingSourcesTableTableProps) => { - const classes = useStyles(); - const columns: GridColDef[] = [ { field: 'name', @@ -98,11 +61,8 @@ const FundingSourcesTable = (props: IFundingSourcesTableTableProps) => { } ]; - const NoRowsOverlayStyled = useCallback(() => , [classes.noDataText]); - return ( - `funding-source-${row.funding_source_id}`} @@ -116,9 +76,6 @@ const FundingSourcesTable = (props: IFundingSourcesTableTableProps) => { disableColumnFilter disableColumnMenu sortingOrder={['asc', 'desc']} - slots={{ - noRowsOverlay: NoRowsOverlayStyled - }} data-testid="funding-source-table" /> ); diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index a001fcbc62..4a50d6dfec 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -6,22 +6,56 @@ import EditDialog from 'components/dialog/EditDialog'; import { H2ButtonToolbar } from 'components/toolbar/ActionToolbars'; import { SurveyAnimalsI18N } from 'constants/i18n'; import { DialogContext } from 'contexts/dialogContext'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import { SurveyContext } from 'contexts/surveyContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; import React, { useContext, useState } from 'react'; import { pluralize } from 'utils/Utils'; import NoSurveySectionData from '../components/NoSurveySectionData'; -import { AnimalSchema, Critter, IAnimal } from './survey-animals/animal'; +import { + AnimalSchema, + AnimalTelemetryDeviceSchema, + Critter, + IAnimal, + IAnimalTelemetryDevice +} from './survey-animals/animal'; import IndividualAnimalForm from './survey-animals/IndividualAnimalForm'; +import { SurveyAnimalsTable } from './survey-animals/SurveyAnimalsTable'; +import TelemetryDeviceForm from './survey-animals/TelemetryDeviceForm'; const SurveyAnimals: React.FC = () => { - const cbApi = useCritterbaseApi(); + const bhApi = useBiohubApi(); const dialogContext = useContext(DialogContext); + const surveyContext = useContext(SurveyContext); - const [openDialog, setOpenDialog] = useState(false); + const [openAddCritterDialog, setOpenAddCritterDialog] = useState(false); + const [openAddDeviceDialog, setOpenAddDeviceDialog] = useState(false); const [animalCount, setAnimalCount] = useState(0); + const [selectedCritterId, setSelectedCritterId] = useState(null); + + const { projectId, surveyId } = surveyContext; + const { + refresh: refreshCritters, + load: loadCritters, + data: critterData + } = useDataLoader(() => bhApi.survey.getSurveyCritters(projectId, surveyId)); + + const { + refresh: refreshDeployments, + load: loadDeployments, + data: deploymentData + } = useDataLoader(() => bhApi.survey.getDeploymentsInSurvey(projectId, surveyId)); + + if (!critterData) { + loadCritters(); + } + + if (!deploymentData) { + loadDeployments(); + } const toggleDialog = () => { - setOpenDialog((d) => !d); + setOpenAddCritterDialog((d) => !d); }; const AnimalFormValues: IAnimal = { @@ -35,10 +69,21 @@ const SurveyAnimals: React.FC = () => { device: undefined }; - const handleOnSave = async (animal: IAnimal) => { + const DeviceFormValues: IAnimalTelemetryDevice = { + device_id: 0, + device_make: '', + frequency: 0, + frequency_unit: '', + device_model: '', + attachment_start: '', + attachment_end: undefined + }; + + const handleCritterSave = async (animal: IAnimal) => { const critter = new Critter(animal); const postCritterPayload = async () => { - await cbApi.critters.createCritter(critter); + await bhApi.survey.createCritterAndAddToSurvey(projectId, surveyId, critter); + refreshCritters(); dialogContext.setSnackbar({ open: true, snackbarMessage: ( @@ -56,6 +101,26 @@ const SurveyAnimals: React.FC = () => { } }; + const handleTelemetrySave = async (survey_critter_id: number, data: IAnimalTelemetryDevice) => { + const critter = critterData?.find((a) => a.survey_critter_id === survey_critter_id); + const critterTelemetryDevice = { ...data, critter_id: critter?.critter_id ?? '' }; + try { + await bhApi.survey.addDeployment(projectId, surveyId, survey_critter_id, critterTelemetryDevice); + } catch (e) { + dialogContext.setSnackbar({ + open: true, + snackbarMessage: ( + + {`Could not add deployment.`} + + ) + }); + } finally { + setOpenAddDeviceDialog(false); + refreshDeployments(); + } + }; + return ( {
} - open={openDialog} + open={openAddCritterDialog} onSave={(values) => { - handleOnSave(values); + handleCritterSave(values); }} onCancel={toggleDialog} component={{ @@ -84,6 +149,21 @@ const SurveyAnimals: React.FC = () => { validationSchema: AnimalSchema }} /> + , + initialValues: DeviceFormValues, + validationSchema: AnimalTelemetryDeviceSchema + }} + onCancel={() => setOpenAddDeviceDialog(false)} + onSave={(values) => { + if (selectedCritterId) { + handleTelemetrySave(selectedCritterId, values); + } + }} + /> { /> - + {critterData?.length ? ( + { + bhApi.survey.removeCritterFromSurvey(projectId, surveyId, critter_id); + refreshCritters(); + }} + onAddDevice={(critter_id) => { + setSelectedCritterId(critter_id); + setOpenAddDeviceDialog(true); + }} + /> + ) : ( + + )} ); diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx new file mode 100644 index 0000000000..2a01532b4a --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx @@ -0,0 +1,121 @@ +import { Typography } from '@mui/material'; +import { GridColDef } from '@mui/x-data-grid'; +import { CustomDataGrid } from 'components/tables/CustomDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { getFormattedDate } from 'utils/Utils'; +import { IAnimalDeployment } from './animal'; +import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; + +interface ISurveyAnimalsTableEntry { + survey_critter_id: number; + critter_id: string; + animal_id: string | null; + taxon: string; + telemetry_device?: IAnimalDeployment[]; +} + +interface ISurveyAnimalsTableProps { + animalData: IDetailedCritterWithInternalId[]; + deviceData?: IAnimalDeployment[]; + onRemoveCritter: (critter_id: number) => void; + onAddDevice: (critter_id: number) => void; +} + +const noOpPlaceHolder = (critter_id: number) => { + // This function intentionally left blank - used as placeholder. +}; + +export const SurveyAnimalsTable = ({ + animalData, + deviceData, + onRemoveCritter, + onAddDevice +}: ISurveyAnimalsTableProps): JSX.Element => { + const columns: GridColDef[] = [ + { + field: 'critter_id', + headerName: 'Critter ID', + flex: 1, + minWidth: 300 + }, + { + field: 'animal_id', + headerName: 'Animal ID', + flex: 1 + }, + { + field: 'taxon', + headerName: 'Taxon', + flex: 1 + }, + { + field: 'create_timestamp', + headerName: 'Created On', + flex: 1, + renderCell: (params) => ( + {getFormattedDate(DATE_FORMAT.ShortDateFormatMonthFirst, params.value)} + ) + }, + { + field: 'telemetry_device', + headerName: 'Device ID', + flex: 1, + renderCell: (params) => ( + + {params.value?.length + ? params.value?.map((device: IAnimalDeployment) => device.device_id).join(', ') + : 'No Device'} + + ) + }, + { + field: 'actions', + type: 'actions', + sortable: false, + flex: 1, + align: 'right', + maxWidth: 50, + renderCell: (params) => ( + + ) + } + ]; + + const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData + ? animalData.map((animal) => { + const devices = deviceData.filter((device) => device.critter_id === animal.critter_id); + return { + ...animal, + telemetry_device: devices + }; + }) + : animalData; + + return ( + row.critter_id} + columns={columns} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + hideFooter + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + sortingOrder={['asc', 'desc']} + data-testid="survey-animal-table" + /> + ); +}; diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx new file mode 100644 index 0000000000..1ffb67f441 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx @@ -0,0 +1,114 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { ListItemText } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import React, { useState } from 'react'; +import { IAnimalDeployment } from './animal'; + +export interface ITableActionsMenuProps { + critter_id: number; + devices?: IAnimalDeployment[]; + onAddDevice: (critter_id: number) => void; + onRemoveDevice: (critter_id: number) => void; + onEditDevice: (critter_id: number) => void; + onEditCritter: (critter_id: number) => void; + onRemoveCritter: (critter_id: number) => void; +} + +const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + { + handleClose(); + props.onAddDevice(props.critter_id); + }} + data-testid="animal-table-row-add-device"> + + + + Add Telemetry Device + + { + handleClose(); + props.onRemoveDevice(props.critter_id); + }} + data-testid="animal-table-row-remove-device"> + + + + Remove Telemetry Device + + { + handleClose(); + props.onEditDevice(props.critter_id); + }} + data-testid="animal-table-row-edit-timespan"> + + + + Edit Deployment Timespan + + { + handleClose(); + props.onEditCritter(props.critter_id); + }} + data-testid="animal-table-row-edit-critter"> + + + + Edit Critter Details + + { + handleClose(); + props.onRemoveCritter(props.critter_id); + }} + data-testid="animal-table-row-remove-critter"> + + + + Remove Critter From Survey + + + + ); +}; + +export default SurveyAnimalsTableActions; diff --git a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx new file mode 100644 index 0000000000..e4a2789143 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx @@ -0,0 +1,101 @@ +import { FormHelperText } from '@mui/material'; +import Grid from '@mui/material/Grid'; +import CustomTextField from 'components/fields/CustomTextField'; +import SingleDateField from 'components/fields/SingleDateField'; +import TelemetrySelectField from 'components/fields/TelemetrySelectField'; +import { Form, useFormikContext } from 'formik'; +import useDataLoader from 'hooks/useDataLoader'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import moment from 'moment'; +import { useEffect, useState } from 'react'; +import { IAnimalTelemetryDevice } from './animal'; + +const TelemetryDeviceForm = () => { + const { values, setStatus } = useFormikContext(); + const [bctwErrors, setBctwErrors] = useState>({}); + const api = useTelemetryApi(); + const { data: deviceData, refresh: refreshDevice } = useDataLoader(() => + api.devices.getDeviceDetails(values.device_id) + ); + + useEffect(() => { + refreshDevice(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.device_id]); + + useEffect(() => { + const errors: { device_make?: string; attachment_start?: string } = {}; + if (deviceData?.device && deviceData.device?.device_make !== values.device_make) { + errors.device_make = `Submitting this form would change the registered device make of device ${values.device_id}, which is disallowed.`; + } + + const existingDeployment = deviceData?.deployments?.find( + (a) => + (moment(values.attachment_start).isSameOrAfter(moment(a.attachment_start)) && + moment(values.attachment_start).isSameOrBefore(moment(a.attachment_end))) || + a.attachment_end == null + ); + if (existingDeployment) { + errors.attachment_start = `Cannot make a deployment starting on this date, as it will conflict with deployment ${ + existingDeployment.deployment_id + } + running from ${existingDeployment.attachment_start} until ${existingDeployment.attachment_end ?? 'indefinite'}.`; + } + setBctwErrors(errors); + setStatus({ forceDisable: Object.entries(errors).length > 0 }); + }, [deviceData, setStatus, values]); + + return ( +
+ + + + + + + + + { + const codeVals = await api.devices.getCodeValues('frequency_unit'); + return codeVals.map((a) => a.description); + }} + controlProps={{ size: 'small' }} + /> + + + + + + + + + + + {Object.entries(bctwErrors).length > 0 && ( + + {Object.values(bctwErrors).map((bctwError) => ( + {bctwError} + ))} + + )} + +
+ ); +}; + +export default TelemetryDeviceForm; diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index b848386dd4..348bb463ea 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -148,12 +148,30 @@ export const AnimalRelationshipSchema = yup.object({}).shape({ relationship: yup.mixed().oneOf(['parent', 'child', 'sibling']).required(req) }); -const AnimalTelemetryDeviceSchema = yup.object({}).shape({ - device_id: yup.string().required(req), - manufacturer: yup.string().required(req), - //I think this needs an additional field for hz - device_frequency: numSchema.required(req), - model: yup.string().required(req) +export const AnimalTelemetryDeviceSchema = yup.object({}).shape({ + device_id: numSchema.required(req), + device_make: yup.string().required(req), + frequency: numSchema.required(req), + frequency_unit: yup.string().required(req), + device_model: yup.string().required(req), + attachment_start: yup.string().required(req), + attachment_end: yup.string() +}); + +export const AnimalDeploymentSchema = yup.object({}).shape({ + assignment_id: yup.string(), + collar_id: yup.string(), + critter_id: yup.string(), + created_at: yup.string(), + created_by_user_id: yup.string(), + updated_at: yup.string(), + updated_by_user_id: yup.string(), + valid_from: yup.string(), + valid_to: yup.string(), + attachment_start: yup.string(), + attachment_end: yup.string(), + deployment_id: yup.string(), + device_id: yup.number() }); const AnimalImageSchema = yup.object({}).shape({}); @@ -199,6 +217,8 @@ export type IAnimal = InferType; export type IAnimalKey = keyof IAnimal; +export type IAnimalDeployment = InferType; + //Critterbase related types type ICritterID = { critter_id: string }; diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts new file mode 100644 index 0000000000..a5fbbbb203 --- /dev/null +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -0,0 +1,100 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Critter, IAnimal, IAnimalDeployment } from 'features/surveys/view/survey-animals/animal'; +import { v4 } from 'uuid'; +import useSurveyApi from './useSurveyApi'; + +describe('useSurveyApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const projectId = 1; + const surveyId = 1; + const critterId = 1; + + describe('createCritterAndAddToSurvey', () => { + it('creates a critter successfully', async () => { + const animal: IAnimal = { + general: { animal_id: '1', taxon_id: v4(), taxon_name: '1' }, + captures: [], + markings: [], + measurements: [], + mortality: [], + family: [], + images: [], + device: undefined + }; + const critter = new Critter(animal); + + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/critters`).reply(201, { create: { critters: 1 } }); + + const result = await useSurveyApi(axios).createCritterAndAddToSurvey(projectId, surveyId, critter); + + expect(result.create.critters).toBe(1); + }); + }); + + describe('removeCritterFromSurvey', () => { + it('should remove a critter from survey', async () => { + mock.onDelete(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}`).reply(200, 1); + + const result = await useSurveyApi(axios).removeCritterFromSurvey(projectId, surveyId, critterId); + + expect(result).toBe(1); + }); + }); + + describe('addDeployment', () => { + it('should add deployment to survey critter', async () => { + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`).reply(201, 1); + + const result = await useSurveyApi(axios).addDeployment(projectId, surveyId, critterId, { + device_id: 1, + device_make: 'ATS', + device_model: 'E', + frequency: 1, + frequency_unit: 'Hz', + attachment_start: '2023-01-01', + attachment_end: undefined, + critter_id: v4() + }); + + expect(result).toBe(1); + }); + }); + + describe('getDeploymentsInSurvey', () => { + it('should get one deployment', async () => { + const response: IAnimalDeployment = { + assignment_id: v4(), + collar_id: v4(), + critter_id: v4(), + created_at: '2023-01-01', + created_by_user_id: v4(), + updated_at: undefined, + updated_by_user_id: undefined, + valid_from: undefined, + valid_to: undefined, + attachment_start: '2023-01-01', + attachment_end: '2023-01-01', + deployment_id: v4(), + device_id: 123 + }; + + mock.onGet(`/api/project/${projectId}/survey/${surveyId}/deployments`).reply(200, [response]); + + const result = await useSurveyApi(axios).getDeploymentsInSurvey(projectId, surveyId); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].device_id).toBe(123); + }); + }); +}); diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index c26e4535a9..abf34b278a 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -1,6 +1,7 @@ import { AxiosInstance, CancelTokenSource } from 'axios'; import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; +import { Critter, IAnimalDeployment, IAnimalTelemetryDevice } from 'features/surveys/view/survey-animals/animal'; import { IGetAttachmentDetails, IGetReportDetails, @@ -10,6 +11,7 @@ import { IGetSummaryResultsResponse, IUploadSummaryResultsResponse } from 'inter import { ICreateSurveyRequest, ICreateSurveyResponse, + IDetailedCritterWithInternalId, IGetSurveyAttachmentsResponse, IGetSurveyForListResponse, IGetSurveyForUpdateResponse, @@ -405,6 +407,108 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; + /** + * Retrieve a list of critters associated with the given survey with details taken from critterbase. + * + * @param {number} projectId + * @param {number} surveyId + * @returns {ICritterDetailedResponse[]} + */ + const getSurveyCritters = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/critters`); + return data; + }; + + type CritterBulkCreationResponse = { + create: { + critters: number; + collections: number; + markings: number; + locations: number; + captures: number; + mortalities: number; + qualitative_measurements: number; + quantitative_measurements: number; + families: number; + family_children: number; + family_parents: number; + }; + }; + + /** + * Create a critter and add it to the list of critters associated with this survey. This will create a new critter in Critterbase. + * + * @param {number} projectId + * @param {number} surveyId + * @param {Critter} critter Critter payload type + * @returns Count of affected rows + */ + const createCritterAndAddToSurvey = async ( + projectId: number, + surveyId: number, + critter: Critter + ): Promise => { + const payload = { + critters: [ + { critter_id: critter.critter_id, animal_id: critter.animal_id, sex: 'Unknown', taxon_id: critter.taxon_id } + ], + qualitative_measurements: critter.measurements.qualitative, + quantitative_measurements: critter.measurements.quantitative, + ...critter + }; + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/critters`, payload); + return data; + }; + + /** + * Remove critter from survey. This will remove the critter from the list of critters associated with this survey. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} critterId + * @return {*} {Promise} + */ + const removeCritterFromSurvey = async (projectId: number, surveyId: number, critterId: number): Promise => { + const { data } = await axios.delete(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}`); + return data; + }; + + /** + * Deploy a device to a critter. This will add a device to the list of devices associated with a critter in BCTW. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} critterId + * @param {(IAnimalTelemetryDevice & { critter_id: string })} body + * @return {*} {Promise} + */ + const addDeployment = async ( + projectId: number, + surveyId: number, + critterId: number, + body: IAnimalTelemetryDevice & { critter_id: string } + ): Promise => { + body.device_id = Number(body.device_id); //Turn this into validation class soon + body.frequency = Number(body.frequency); + const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, + body + ); + return data; + }; + + /** + * Retrieve a list of deployments associated with the given survey. + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} + */ + const getDeploymentsInSurvey = async (projectId: number, surveyId: number): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments`); + return data; + }; + return { createSurvey, getSurveyForView, @@ -423,7 +527,12 @@ const useSurveyApi = (axios: AxiosInstance) => { getSurveyAttachmentSignedURL, deleteSurvey, getSummarySubmissionSignedURL, - deleteSummarySubmission + deleteSummarySubmission, + getSurveyCritters, + createCritterAndAddToSurvey, + removeCritterFromSurvey, + addDeployment, + getDeploymentsInSurvey }; }; diff --git a/app/src/hooks/telemetry/useDeviceApi.test.tsx b/app/src/hooks/telemetry/useDeviceApi.test.tsx new file mode 100644 index 0000000000..b03a90a9c0 --- /dev/null +++ b/app/src/hooks/telemetry/useDeviceApi.test.tsx @@ -0,0 +1,43 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { useDeviceApi } from './useDeviceApi'; + +describe('useDeviceApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockVendors = ['vendor1', 'vendor2']; + + const mockCodeValues = { + code_header_title: 'code_header_title', + code_header_name: 'code_header_name', + id: 123, + description: 'description', + long_description: 'long_description' + }; + + it('should return a list of vendors', async () => { + mock.onGet('/api/telemetry/vendors').reply(200, mockVendors); + const result = await useDeviceApi(axios).getCollarVendors(); + expect(result).toEqual(mockVendors); + }); + + it('should return a list of code values', async () => { + mock.onGet('/api/telemetry/code?codeHeader=code_header_name').reply(200, [mockCodeValues]); + const result = await useDeviceApi(axios).getCodeValues('code_header_name'); + expect(result).toEqual([mockCodeValues]); + }); + + it('should return device deployment details', async () => { + mock.onGet(`/api/telemetry/device/${123}`).reply(200, { device: undefined, deployments: [] }); + const result = await useDeviceApi(axios).getDeviceDetails(123); + expect(result.deployments.length).toBe(0); + }); +}); diff --git a/app/src/hooks/telemetry/useDeviceApi.tsx b/app/src/hooks/telemetry/useDeviceApi.tsx new file mode 100644 index 0000000000..d1186c5a57 --- /dev/null +++ b/app/src/hooks/telemetry/useDeviceApi.tsx @@ -0,0 +1,84 @@ +import { AxiosInstance } from 'axios'; +import { IAnimalDeployment } from 'features/surveys/view/survey-animals/animal'; + +interface ICodeResponse { + code_header_title: string; + code_header_name: string; + id: number; + code: string; + description: string; + long_description: string; +} +/** + * Returns a set of functions for making device-related API calls. + * + * @param {AxiosInstance} axios + * @return {*} + */ +const useDeviceApi = (axios: AxiosInstance) => { + /** + * Returns a list of supported collar vendors. + * + * @return {*} {Promise} + */ + const getCollarVendors = async (): Promise => { + try { + const { data } = await axios.get('/api/telemetry/vendors'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return []; + }; + + /** + * Returns a list of code values for a given code header. + * + * @param {string} codeHeader + * @return {*} {Promise} + */ + const getCodeValues = async (codeHeader: string): Promise => { + try { + const { data } = await axios.get(`/api/telemetry/code?codeHeader=${codeHeader}`); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return []; + }; + + interface IGetDeviceDetailsResponse { + device: Record | undefined; + deployments: Omit[]; + } + + /** + * Returns details for a given device. + * + * @param {number} deviceId + * @return {*} {Promise} + */ + const getDeviceDetails = async (deviceId: number): Promise => { + try { + const { data } = await axios.get(`/api/telemetry/device/${deviceId}`); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return { device: undefined, deployments: [] }; + }; + + return { + getDeviceDetails, + getCollarVendors, + getCodeValues + }; +}; + +export { useDeviceApi }; diff --git a/app/src/hooks/useTelemetryApi.ts b/app/src/hooks/useTelemetryApi.ts new file mode 100644 index 0000000000..19e9b78a24 --- /dev/null +++ b/app/src/hooks/useTelemetryApi.ts @@ -0,0 +1,15 @@ +import { ConfigContext } from 'contexts/configContext'; +import { useContext } from 'react'; +import useAxios from './api/useAxios'; +import { useDeviceApi } from './telemetry/useDeviceApi'; + +export const useTelemetryApi = () => { + const config = useContext(ConfigContext); + const apiAxios = useAxios(config?.API_HOST); + const devices = useDeviceApi(apiAxios); + return { devices }; +}; + +type TelemetryApiReturnType = ReturnType; + +export type TelemetryApiLookupFunctions = keyof TelemetryApiReturnType['devices']; // Add more options as needed. diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts new file mode 100644 index 0000000000..7b7030da32 --- /dev/null +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -0,0 +1,128 @@ +type ICollectionUnitResponse = { + critter_collection_unit_id: string; + category_name: string; + unit_name: string; + collection_unit_id: string; + collection_category_id: string; +}; + +type ILocationResponse = { + latitude: number; + longitude: number; + coordinate_uncertainty: number | null; + temperature: number | null; + location_comment: string | null; + region_env_id: string | null; + region_nr_id: string | null; + wmu_id: string | null; + region_env_name: string | null; + region_nr_name: string | null; + wmu_name: string | null; +}; + +type ICaptureResponse = { + capture_id: string; + capture_location_id: string | null; + release_location_id: string | null; + capture_timestamp: string; + release_timestamp: string | null; + capture_comment: string | null; + release_comment: string | null; + capture_location: ILocationResponse | null; + release_location: ILocationResponse | null; +}; + +type IMarkingResponse = { + marking_id: string; + capture_id: string; + mortality_id: string | null; + taxon_marking_body_location_id: string; + marking_type_id: string; + marking_material_id: string; + identifier: string; + frequency: string | null; + frequency_unit: string | null; + order: string | null; + comment: string; + attached_timestamp: string; + removed_timestamp: string | null; + body_location: string; + marking_type: string; + marking_material: string; + primary_colour: string | null; + secondary_colour: string | null; + text_colour: string | null; +}; + +type IQualitativeMeasurementResponse = { + measurement_qualitative_id: string; + taxon_measurement_id: string; + capture_id: string | null; + mortality_id: string | null; + qualitative_option_id: string; + measurement_comment: string | null; + measured_timestamp: string | null; + measurement_name: string; + option_label: string; + option_value: number; +}; + +type IQuantitativeMeasurementResponse = { + measurement_quantitative_id: string; + taxon_measurement_id: string; + capture_id: string | null; + mortality_id: string | null; + value: number; + measurement_comment: string | null; + measured_timestamp: string | null; + measurement_name: string; +}; + +type IMortalityResponse = { + mortality_id: string; + critter_id: string; + location_id: string | null; + mortality_timestamp: string; + proximate_cause_of_death_id: string | null; + proximate_cause_of_death_confidence: string; + proximate_predated_by_taxon_id: string | null; + ultimate_cause_of_death_id: string | null; + ultimate_cause_of_death_confidence: string; + ultimate_predated_by_taxon_id: string | null; + mortality_comment: string | null; +}; + +export type ICritterDetailedResponse = { + critter_id: string; + taxon_id: string; + wlh_id: string | null; + animal_id: string | null; + sex: string; + responsible_region_nr_id: string; + create_user: string; + update_user: string; + create_timestamp: string; + update_timestamp: string; + critter_comment: string; + taxon: string; + responsible_region: string; + mortality_timestamp: string | null; + collection_units: ICollectionUnitResponse[]; + mortality: IMortalityResponse[]; + capture: ICaptureResponse[]; + marking: IMarkingResponse[]; + measurement: { + qualitative: IQualitativeMeasurementResponse[]; + quantitative: IQuantitativeMeasurementResponse[]; + }; +}; + +export interface ICritterSimpleResponse { + critter_id: string; + wlh_id: string; + animal_id: string; + sex: string; + taxon: string; + collection_units: ICollectionUnitResponse[]; + mortality_timestamp?: string; +} diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index e65690e332..f339b444f8 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -8,6 +8,7 @@ import { ISurveyFundingSource, ISurveyFundingSourceForm } from 'features/surveys import { ISurveySiteSelectionForm } from 'features/surveys/components/SurveySiteSelectionForm'; import { Feature } from 'geojson'; import { StringBoolean } from 'types/misc'; +import { ICritterDetailedResponse } from './useCritterApi.interface'; /** * Create survey post object. @@ -331,6 +332,10 @@ export interface IGetSurveyForUpdateResponse { surveyData: SurveyUpdateObject; } +export interface IDetailedCritterWithInternalId extends ICritterDetailedResponse { + survey_critter_id: number; //The internal critter_id in the SIMS DB. Called this to distinguish against the critterbase UUID of the same name. +} + export type IEditSurveyRequest = IGeneralInformationForm & IPurposeAndMethodologyForm & ISurveyFundingSourceForm & From 11acde53a45b4782d976051635143f18b064fbd5 Mon Sep 17 00:00:00 2001 From: jeznorth Date: Wed, 13 Sep 2023 16:37:10 -0700 Subject: [PATCH 6/9] Updates to Stratum, Blocks and Participants Components (#1091) * Standardizing patterns for stratum and blocks components * Updating Participants component to conform with patter. --- app/src/components/user/UserRoleSelector.tsx | 5 +- .../components/SamplingMethodsForm.tsx | 33 ++++-- .../components/StratumCreateOrEditDialog.tsx | 44 +++----- .../surveys/components/SurveyBlockSection.tsx | 33 +++--- .../components/SurveySiteSelectionForm.tsx | 12 ++- .../surveys/components/SurveyStratumForm.tsx | 102 ++++++++---------- .../surveys/components/SurveyUserForm.tsx | 2 +- app/src/styles.scss | 4 + 8 files changed, 116 insertions(+), 119 deletions(-) diff --git a/app/src/components/user/UserRoleSelector.tsx b/app/src/components/user/UserRoleSelector.tsx index eb5600e720..8d78030485 100644 --- a/app/src/components/user/UserRoleSelector.tsx +++ b/app/src/components/user/UserRoleSelector.tsx @@ -1,6 +1,7 @@ import { mdiClose } from '@mdi/js'; import Icon from '@mdi/react'; import { Box, IconButton, MenuItem, Paper, Select } from '@mui/material'; +import { grey } from '@mui/material/colors'; import { ICode } from 'interfaces/useCodesApi.interface'; import { IGetProjectParticipant } from 'interfaces/useProjectApi.interface'; import { IGetSurveyParticipant } from 'interfaces/useSurveyApi.interface'; @@ -27,9 +28,7 @@ const UserRoleSelector: React.FC = (props) => { variant="outlined" className={error ? 'userRoleItemError' : 'userRoleItem'} sx={{ - '&.userRoleItem': { - borderColor: 'grey.400' - }, + background: grey[100], '&.userRoleItemError': { borderColor: 'error.main', '& .MuiOutlinedInput-notchedOutline': { diff --git a/app/src/features/surveys/components/SamplingMethodsForm.tsx b/app/src/features/surveys/components/SamplingMethodsForm.tsx index 600066e5a7..31e4a6e69b 100644 --- a/app/src/features/surveys/components/SamplingMethodsForm.tsx +++ b/app/src/features/surveys/components/SamplingMethodsForm.tsx @@ -12,28 +12,41 @@ const SamplingMethodsForm = () => { return ( <> - Site Selection Strategy + Site Selection Strategies - Define Stratums + Add Stratum - Enter a name and description for each stratum used in this survey. + mb: 2 + }} + variant="body1" + color="textSecondary"> + Specify each stratum used when selecting sampling sites. - - - + + + Add Blocks (Optional) + + + If required, specify each block included in this survey. + diff --git a/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx b/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx index a9f45776f7..95842ea96d 100644 --- a/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx +++ b/app/src/features/surveys/components/StratumCreateOrEditDialog.tsx @@ -1,10 +1,8 @@ import { useMediaQuery, useTheme } from '@mui/material'; -import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import CustomTextField from 'components/fields/CustomTextField'; import { Formik, FormikProps } from 'formik'; @@ -46,31 +44,23 @@ const StratumCreateOrEditDialog = (props: IStratumDialogProps) => { {editing ? 'Edit Stratum Details' : 'Add Stratum'} - <> - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam at porttitor sem. Aliquam erat - volutpat. Donec placerat nisl magna, et faucibus arcu condimentum sed. - - - - - - + + - - + + ); diff --git a/app/src/features/surveys/components/SurveyUserForm.tsx b/app/src/features/surveys/components/SurveyUserForm.tsx index dad7fc5d29..ee5d685f6a 100644 --- a/app/src/features/surveys/components/SurveyUserForm.tsx +++ b/app/src/features/surveys/components/SurveyUserForm.tsx @@ -142,7 +142,7 @@ const SurveyUserForm: React.FC = (props) => { sx={{ maxWidth: '72ch' }}> - Add people and their associated job for this survey. + Add particpants to this survey and assign each a role. {errors?.['participants'] && selectedUsers.length > 0 && ( diff --git a/app/src/styles.scss b/app/src/styles.scss index 49b6b45941..44e034536e 100644 --- a/app/src/styles.scss +++ b/app/src/styles.scss @@ -16,6 +16,10 @@ legend.MuiTypography-root { padding: 0; font-size: 16px; font-weight: 700; + + & + p { + margin-top: -8px; + } } .sectionHeaderButton { From 5289900c8069d06a8b881a0e2215700cda012912 Mon Sep 17 00:00:00 2001 From: GrahamS-Quartech <112989452+GrahamS-Quartech@users.noreply.github.com> Date: Thu, 14 Sep 2023 15:51:43 -0700 Subject: [PATCH 7/9] SIMSBIOHUB-216 Patch (#1093) * Removed small prop from fields in device form, removed unimplemented buttons, removed delete critter button when devices attached, fixed delete statement not commiting in delete endpoint --- .../survey/{surveyId}/critters/{critterId}.ts | 1 + app/package-lock.json | 216 +++++++++--------- .../surveys/view/SurveyAnimals.test.tsx | 47 ++++ .../features/surveys/view/SurveyAnimals.tsx | 5 +- .../survey-animals/SurveyAnimalsTable.tsx | 1 - .../SurveyAnimalsTableActions.test.tsx | 35 +++ .../SurveyAnimalsTableActions.tsx | 45 ++-- .../survey-animals/TelemetryDeviceForm.tsx | 15 +- 8 files changed, 217 insertions(+), 148 deletions(-) create mode 100644 app/src/features/surveys/view/SurveyAnimals.test.tsx create mode 100644 app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts index 1624d43eeb..9cd3d6c510 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts @@ -91,6 +91,7 @@ export function removeCritterFromSurvey(): RequestHandler { try { await connection.open(); const result = await surveyService.removeCritterFromSurvey(surveyId, critterId); + await connection.commit(); return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'removeCritterFromSurvey', message: 'error', error }); diff --git a/app/package-lock.json b/app/package-lock.json index e9f6dad851..cce60258f3 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -4478,7 +4478,7 @@ "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" + "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==" }, "ansi-escapes": { "version": "4.3.2", @@ -4580,12 +4580,12 @@ "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==" }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "array-includes": { "version": "3.1.6", @@ -4699,7 +4699,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" }, "ast-types-flow": { "version": "0.0.7", @@ -4716,12 +4716,12 @@ "async-foreach": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", - "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" + "integrity": "sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==" }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "at-least-node": { "version": "1.0.0", @@ -4757,7 +4757,7 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" }, "aws4": { "version": "1.12.0", @@ -5061,7 +5061,7 @@ "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "requires": { "tweetnacl": "^0.14.3" } @@ -5093,7 +5093,7 @@ "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", - "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "integrity": "sha512-OorbnJVPII4DuUKbjARAe8u8EfqOmkEEaSFIyoQ7OjTHn6kafxWl0wLgoZ2rXaYd7MyLcDaU4TmhfxtwgcccMQ==", "requires": { "inherits": "~2.0.0" } @@ -5259,7 +5259,7 @@ "camelcase": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" + "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==" }, "camelcase-css": { "version": "2.0.1", @@ -5270,7 +5270,7 @@ "camelcase-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", - "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==", "requires": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" @@ -5302,7 +5302,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, "chalk": { "version": "2.4.2", @@ -5442,7 +5442,7 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==" }, "collect-v8-coverage": { "version": "1.0.2", @@ -5461,7 +5461,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "colord": { "version": "2.9.3", @@ -5563,7 +5563,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "concaveman": { "version": "1.2.1", @@ -5591,7 +5591,7 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" }, "content-disposition": { "version": "0.5.4", @@ -5619,7 +5619,7 @@ "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "core-js": { "version": "3.31.1", @@ -6036,7 +6036,7 @@ "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", "requires": { "array-find-index": "^1.0.1" } @@ -6050,7 +6050,7 @@ "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "requires": { "assert-plus": "^1.0.0" } @@ -6077,7 +6077,7 @@ "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" }, "decimal.js": { "version": "10.4.3", @@ -6142,17 +6142,17 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" }, "dequal": { "version": "2.0.3", @@ -6163,7 +6163,7 @@ "destroy": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==" }, "detect-newline": { "version": "3.1.0", @@ -6378,7 +6378,7 @@ "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -6387,7 +6387,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "ejs": { "version": "3.1.9", @@ -6423,7 +6423,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, "enhanced-resolve": { "version": "5.15.0", @@ -6587,12 +6587,12 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "escodegen": { "version": "2.1.0", @@ -7278,7 +7278,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, "eventemitter3": { "version": "4.0.7", @@ -7411,7 +7411,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" }, "fast-deep-equal": { "version": "3.1.3", @@ -7594,7 +7594,7 @@ "find-up": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", "requires": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" @@ -7644,7 +7644,7 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" }, "fork-ts-checker-webpack-plugin": { "version": "6.5.3", @@ -7835,7 +7835,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, "fs-extra": { "version": "10.1.0", @@ -7857,7 +7857,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "fsevents": { "version": "2.3.2", @@ -7902,7 +7902,7 @@ "gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", - "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", "requires": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -7930,7 +7930,7 @@ "geojson-equality": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", - "integrity": "sha1-oXE3TvBD5dR5eZWEC65GSOB1LXI=", + "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", "requires": { "deep-equal": "^1.0.0" } @@ -7966,7 +7966,7 @@ "get-stdin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" + "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==" }, "get-stream": { "version": "6.0.1", @@ -7987,7 +7987,7 @@ "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "requires": { "assert-plus": "^1.0.0" } @@ -8141,7 +8141,7 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==" }, "har-validator": { "version": "5.1.5", @@ -8169,7 +8169,7 @@ "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", "requires": { "ansi-regex": "^2.0.0" } @@ -8183,7 +8183,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "has-property-descriptors": { "version": "1.0.0", @@ -8214,7 +8214,7 @@ "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, "he": { "version": "1.2.0", @@ -8427,7 +8427,7 @@ "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -8437,7 +8437,7 @@ "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", - "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", "dev": true }, "https-proxy-agent": { @@ -8504,7 +8504,7 @@ "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, "immer": { "version": "9.0.21", @@ -8553,7 +8553,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -8609,7 +8609,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "is-bigint": { "version": "1.0.4", @@ -8687,7 +8687,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", "requires": { "number-is-nan": "^1.0.0" } @@ -8864,12 +8864,12 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" }, "is-utf8": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" }, "is-weakmap": { "version": "2.0.1", @@ -8908,17 +8908,17 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "istanbul-lib-coverage": { "version": "3.2.0", @@ -11457,7 +11457,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" }, "jsdom": { "version": "16.7.0", @@ -11554,7 +11554,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, "json5": { "version": "2.2.3", @@ -11751,7 +11751,7 @@ "leaflet-fullscreen": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/leaflet-fullscreen/-/leaflet-fullscreen-1.0.2.tgz", - "integrity": "sha1-CcYcS6xF9jsu4Sav2H5c2XZQ/Bs=" + "integrity": "sha512-1Yxm8RZg6KlKX25+hbP2H/wnOAphH7hFcvuADJFb4QZTN7uOSN9Hsci5EZpow8vtNej9OGzu59Jxmn+0qKOO9Q==" }, "leaflet.locatecontrol": { "version": "0.76.1", @@ -11849,7 +11849,7 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" } } }, @@ -11904,7 +11904,7 @@ "loud-rejection": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", - "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", "requires": { "currently-unhandled": "^0.4.1", "signal-exit": "^3.0.0" @@ -11963,7 +11963,7 @@ "map-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==" }, "mdn-data": { "version": "2.0.4", @@ -11974,7 +11974,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, "memfs": { "version": "3.5.3", @@ -11993,7 +11993,7 @@ "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", - "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==", "requires": { "camelcase-keys": "^2.0.0", "decamelize": "^1.1.2", @@ -12010,7 +12010,7 @@ "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, "merge-stream": { "version": "2.0.0", @@ -12027,7 +12027,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "mgrs": { "version": "1.0.0", @@ -12301,12 +12301,12 @@ "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==" }, "chalk": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", "requires": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -12318,14 +12318,14 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==" } } }, "nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", "requires": { "abbrev": "1" } @@ -12398,7 +12398,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==" }, "nwsapi": { "version": "2.2.7", @@ -12414,7 +12414,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-hash": { "version": "3.0.0", @@ -12519,7 +12519,7 @@ "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "requires": { "ee-first": "1.1.1" } @@ -12533,7 +12533,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "requires": { "wrappy": "1" } @@ -12575,12 +12575,12 @@ "os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==" }, "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" }, "osenv": { "version": "0.1.5", @@ -12689,7 +12689,7 @@ "path-exists": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", "requires": { "pinkie-promise": "^2.0.0" } @@ -12697,7 +12697,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, "path-key": { "version": "3.1.1", @@ -12713,7 +12713,7 @@ "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, "path-type": { "version": "4.0.0", @@ -12723,7 +12723,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "picocolors": { "version": "1.0.0", @@ -12739,17 +12739,17 @@ "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" }, "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==" }, "pinkie-promise": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", "requires": { "pinkie": "^2.0.0" } @@ -13756,7 +13756,7 @@ "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" }, "psl": { "version": "1.9.0", @@ -15214,7 +15214,7 @@ "read-pkg-up": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", "requires": { "find-up": "^1.0.0", "read-pkg": "^1.0.0" @@ -15439,7 +15439,7 @@ "repeating": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", - "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", "requires": { "is-finite": "^1.0.0" } @@ -15486,7 +15486,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, "require-from-string": { "version": "2.0.2", @@ -15788,7 +15788,7 @@ "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", - "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "integrity": "sha512-dYE8LhncfBUar6POCxMTm0Ln+erjeczqEvCJib5/7XNkdw1FkUGgwMPY360FY0FgPWQxHWCx29Jl3oejyGLM9Q==", "requires": { "js-base64": "^2.1.8", "source-map": "^0.4.2" @@ -15797,7 +15797,7 @@ "source-map": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==", "requires": { "amdefine": ">=0.0.4" } @@ -15945,7 +15945,7 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, "setprototypeof": { "version": "1.2.0", @@ -15988,7 +15988,7 @@ "lru-cache": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", - "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=" + "integrity": "sha512-WpibWJ60c3AgAz8a2iYErDrcT2C7OmKnsWhIcHOjkUHFjkXncJhtLxNSqUmxRxRunpb5I8Vprd7aNSd2NtksJQ==" } } }, @@ -16040,7 +16040,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" }, "source-map-js": { "version": "1.0.2", @@ -16216,7 +16216,7 @@ "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" }, "stdout-stream": { "version": "1.4.1", @@ -16271,7 +16271,7 @@ "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -16356,7 +16356,7 @@ "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "requires": { "ansi-regex": "^2.0.0" } @@ -16364,7 +16364,7 @@ "strip-bom": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", "requires": { "is-utf8": "^0.2.0" } @@ -16793,7 +16793,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" }, "to-regex-range": { "version": "5.0.1", @@ -16812,7 +16812,7 @@ "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" }, "tough-cookie": { "version": "2.5.0", @@ -16835,7 +16835,7 @@ "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", - "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=" + "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==" }, "true-case-path": { "version": "1.0.3", @@ -16911,7 +16911,7 @@ "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "requires": { "safe-buffer": "^5.0.1" } @@ -16919,7 +16919,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" }, "type-check": { "version": "0.4.0", @@ -17070,7 +17070,7 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, "unquote": { "version": "1.1.1", @@ -17127,7 +17127,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "util.promisify": { "version": "1.0.1", @@ -17150,7 +17150,7 @@ "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, "uuid": { "version": "8.3.2", @@ -17185,12 +17185,12 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -17894,7 +17894,7 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" }, "string-width": { "version": "3.1.0", @@ -17919,7 +17919,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "write-file-atomic": { "version": "4.0.2", @@ -17940,7 +17940,7 @@ "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", - "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "dev": true }, "xml-name-validator": { @@ -18003,7 +18003,7 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" }, "string-width": { "version": "3.1.0", diff --git a/app/src/features/surveys/view/SurveyAnimals.test.tsx b/app/src/features/surveys/view/SurveyAnimals.test.tsx new file mode 100644 index 0000000000..1181ad7767 --- /dev/null +++ b/app/src/features/surveys/view/SurveyAnimals.test.tsx @@ -0,0 +1,47 @@ +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { cleanup, render, waitFor } from 'test-helpers/test-utils'; +import SurveyAnimals from './SurveyAnimals'; + +jest.mock('../../../hooks/useBioHubApi'); +const mockBiohubApi = useBiohubApi as jest.Mock; + +const mockUseApi = { + survey: { + getSurveyCritters: jest.fn(), + getDeploymentsInSurvey: jest.fn(), + createCritterAndAddToSurvey: jest.fn(), + addDeployment: jest.fn() + } +}; + +describe('SurveyAnimals', () => { + beforeEach(() => { + mockBiohubApi.mockImplementation(() => mockUseApi); + mockUseApi.survey.getDeploymentsInSurvey.mockClear(); + mockUseApi.survey.getSurveyCritters.mockClear(); + mockUseApi.survey.createCritterAndAddToSurvey.mockClear(); + mockUseApi.survey.addDeployment.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders correctly with no animals', async () => { + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('No Individual Animals')).toBeInTheDocument(); + }); + }); + + it('renders correctly with animals', async () => { + mockUseApi.survey.getSurveyCritters.mockResolvedValueOnce([{ critter_id: 'abc', survey_critter_id: 1 }]); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('abc')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index 4a50d6dfec..1ff0dc4dd6 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -152,6 +152,7 @@ const SurveyAnimals: React.FC = () => { , initialValues: DeviceFormValues, @@ -178,8 +179,8 @@ const SurveyAnimals: React.FC = () => { { - bhApi.survey.removeCritterFromSurvey(projectId, surveyId, critter_id); + onRemoveCritter={async (critter_id) => { + await bhApi.survey.removeCritterFromSurvey(projectId, surveyId, critter_id); refreshCritters(); }} onAddDevice={(critter_id) => { diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx index 2a01532b4a..f30d5d961a 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx @@ -81,7 +81,6 @@ export const SurveyAnimalsTable = ({ critter_id={params.row.survey_critter_id} devices={params.row?.telemetry_device} onAddDevice={onAddDevice} - onRemoveDevice={noOpPlaceHolder} onEditCritter={noOpPlaceHolder} onEditDevice={noOpPlaceHolder} onRemoveCritter={onRemoveCritter} diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx new file mode 100644 index 0000000000..ed27e59aa9 --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, waitFor } from 'test-helpers/test-utils'; +import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; + +describe('SurveyAnimalsTableActions', () => { + const onAddDevice = jest.fn(); + const onRemoveCritter = jest.fn(); + + it('all buttons should be clickable', async () => { + const { getByTestId } = render( + <> + {}} + onEditCritter={() => {}} + onRemoveCritter={onRemoveCritter} + /> + + ); + + fireEvent.click(getByTestId('animal actions')); + + await waitFor(() => { + expect(getByTestId('animal-table-row-add-device')).toBeInTheDocument(); + expect(getByTestId('animal-table-row-remove-critter')).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId('animal-table-row-add-device')); + expect(onAddDevice.mock.calls.length).toBe(1); + + fireEvent.click(getByTestId('animal-table-row-remove-critter')); + expect(onRemoveCritter.mock.calls.length).toBe(1); + }); +}); diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx index 1ffb67f441..5763483ded 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx @@ -1,4 +1,4 @@ -import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import { ListItemText } from '@mui/material'; import IconButton from '@mui/material/IconButton'; @@ -12,7 +12,6 @@ export interface ITableActionsMenuProps { critter_id: number; devices?: IAnimalDeployment[]; onAddDevice: (critter_id: number) => void; - onRemoveDevice: (critter_id: number) => void; onEditDevice: (critter_id: number) => void; onEditCritter: (critter_id: number) => void; onRemoveCritter: (critter_id: number) => void; @@ -62,18 +61,9 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Add Telemetry Device - { - handleClose(); - props.onRemoveDevice(props.critter_id); - }} - data-testid="animal-table-row-remove-device"> - - - - Remove Telemetry Device - - { handleClose(); props.onEditDevice(props.critter_id); @@ -94,18 +84,21 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { Edit Critter Details - - { - handleClose(); - props.onRemoveCritter(props.critter_id); - }} - data-testid="animal-table-row-remove-critter"> - - - - Remove Critter From Survey - + */ + } + {!props.devices?.length && ( + { + handleClose(); + props.onRemoveCritter(props.critter_id); + }} + data-testid="animal-table-row-remove-critter"> + + + + Remove Critter From Survey + + )} ); diff --git a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx index e4a2789143..0365abb1fe 100644 --- a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx +++ b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx @@ -49,10 +49,10 @@ const TelemetryDeviceForm = () => {
- + - + { const codeVals = await api.devices.getCodeValues('frequency_unit'); return codeVals.map((a) => a.description); }} - controlProps={{ size: 'small' }} /> @@ -72,19 +71,13 @@ const TelemetryDeviceForm = () => { name={'device_make'} id="manufacturer" fetchData={api.devices.getCollarVendors} - controlProps={{ size: 'small' }} /> - + - + {Object.entries(bctwErrors).length > 0 && ( From 429212f0a9bf48f73736940aadb7d87f39396852 Mon Sep 17 00:00:00 2001 From: GrahamS-Quartech <112989452+GrahamS-Quartech@users.noreply.github.com> Date: Tue, 19 Sep 2023 11:36:37 -0700 Subject: [PATCH 8/9] SIMSBIOHUB 211 - Edit Telemetry Form (#1094) * Restructured the deployment form to better handle the case where multiple devices with potentially multiple deployments per device will need to be displayed. Hitting edit deployment on a critter with multiple devices assigned will demonstrate this case. It will only update information that has been modified since opening the form. * Added hooks and routes for upserting collars in BCTW, getting deployments per device id, getting device metadata change history, updating deployment rows for both SIMS and BCTW. --- .../survey/{surveyId}/critters/{critterId}.ts | 3 +- .../critters/{critterId}/deployments.test.ts | 107 +++++++++--- .../critters/{critterId}/deployments.ts | 140 ++++++++++++++- api/src/paths/telemetry/device/index.test.ts | 47 +++++ api/src/paths/telemetry/device/index.ts | 104 +++++++++++ .../paths/telemetry/device/{deviceId}.test.ts | 11 +- .../survey-critter-repository.test.ts | 8 +- .../repositories/survey-critter-repository.ts | 18 +- api/src/services/bctw-service.test.ts | 32 +++- api/src/services/bctw-service.ts | 45 ++++- .../services/survey-critter-service.test.ts | 6 +- api/src/services/survey-critter-service.ts | 24 ++- .../surveys/view/SurveyAnimals.test.tsx | 106 ++++++++++-- .../features/surveys/view/SurveyAnimals.tsx | 163 +++++++++++++----- .../survey-animals/SurveyAnimalsTable.tsx | 39 +++-- .../SurveyAnimalsTableActions.test.tsx | 20 +-- .../SurveyAnimalsTableActions.tsx | 41 +++-- .../survey-animals/TelemetryDeviceForm.tsx | 152 ++++++++++++---- .../surveys/view/survey-animals/animal.ts | 31 +--- .../surveys/view/survey-animals/device.ts | 58 +++++++ app/src/hooks/api/useSurveyApi.test.ts | 64 +++++-- app/src/hooks/api/useSurveyApi.ts | 52 +++--- app/src/hooks/telemetry/useDeviceApi.test.tsx | 8 + app/src/hooks/telemetry/useDeviceApi.tsx | 22 ++- app/src/utils/Utils.ts | 8 + 25 files changed, 1033 insertions(+), 276 deletions(-) create mode 100644 api/src/paths/telemetry/device/index.test.ts create mode 100644 api/src/paths/telemetry/device/index.ts create mode 100644 app/src/features/surveys/view/survey-animals/device.ts diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts index 9cd3d6c510..c2e8c0fdd4 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}.ts @@ -84,13 +84,12 @@ DELETE.apiDoc = { export function removeCritterFromSurvey(): RequestHandler { return async (req, res) => { - const surveyId = Number(req.params.surveyId); const critterId = Number(req.params.critterId); const connection = getDBConnection(req['keycloak_token']); const surveyService = new SurveyCritterService(connection); try { await connection.open(); - const result = await surveyService.removeCritterFromSurvey(surveyId, critterId); + const result = await surveyService.removeCritterFromSurvey(critterId); await connection.commit(); return res.status(200).json(result); } catch (error) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts index 047b1556b2..d62dfd02b5 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.test.ts @@ -1,12 +1,13 @@ +import Ajv from 'ajv'; import { expect } from 'chai'; import sinon from 'sinon'; import * as db from '../../../../../../../database/db'; import { BctwService } from '../../../../../../../services/bctw-service'; import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; -import { deployDevice } from './deployments'; +import { deployDevice, PATCH, POST, updateDeployment } from './deployments'; -describe('deployDevice', () => { +describe('critter deployments', () => { afterEach(() => { sinon.restore(); }); @@ -14,39 +15,89 @@ describe('deployDevice', () => { const mockDBConnection = getMockDBConnection({ release: sinon.stub() }); const mockSurveyEntry = 123; - it('deploys a new telemetry device', async () => { - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'addDeployment').resolves(mockSurveyEntry); - const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + describe('openapi schema', () => { + const ajv = new Ajv(); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - const requestHandler = deployDevice(); - await requestHandler(mockReq, mockRes, mockNext); - - expect(mockGetDBConnection.calledOnce).to.be.true; - expect(mockAddDeployment.calledOnce).to.be.true; - expect(mockBctwService.calledOnce).to.be.true; - expect(mockRes.status).to.have.been.calledWith(200); - expect(mockRes.json).to.have.been.calledWith(mockSurveyEntry); + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + expect(ajv.validateSchema((PATCH.apiDoc as unknown) as object)).to.be.true; + }); }); - it('catches and re-throws errors', async () => { - const mockError = new Error('a test error'); - const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'addDeployment').rejects(mockError); - const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + describe('upsertDeployment', () => { + it('updates an existing deployment', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').resolves(1); + const mockBctwService = sinon.stub(BctwService.prototype, 'updateDeployment'); - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - const requestHandler = deployDevice(); - try { + const requestHandler = updateDeployment(); await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { + expect(mockGetDBConnection.calledOnce).to.be.true; expect(mockAddDeployment.calledOnce).to.be.true; - expect(mockBctwService.notCalled).to.be.true; - } + expect(mockBctwService.calledOnce).to.be.true; + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith(1); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').rejects(mockError); + const mockBctwService = sinon.stub(BctwService.prototype, 'updateDeployment'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = updateDeployment(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddDeployment.calledOnce).to.be.true; + expect(mockBctwService.notCalled).to.be.true; + } + }); + describe('deployDevice', () => { + it('deploys a new telemetry device', async () => { + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddDeployment = sinon + .stub(SurveyCritterService.prototype, 'upsertDeployment') + .resolves(mockSurveyEntry); + const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = deployDevice(); + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddDeployment.calledOnce).to.be.true; + expect(mockBctwService.calledOnce).to.be.true; + expect(mockRes.status).to.have.been.calledWith(201); + expect(mockRes.json).to.have.been.calledWith(mockSurveyEntry); + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockGetDBConnection = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockAddDeployment = sinon.stub(SurveyCritterService.prototype, 'upsertDeployment').rejects(mockError); + const mockBctwService = sinon.stub(BctwService.prototype, 'deployDevice'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = deployDevice(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockGetDBConnection.calledOnce).to.be.true; + expect(mockAddDeployment.calledOnce).to.be.true; + expect(mockBctwService.notCalled).to.be.true; + } + }); + }); }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts index 1858dad297..1ac709da11 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts @@ -10,7 +10,7 @@ import { ICritterbaseUser } from '../../../../../../../services/critterbase-serv import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; import { getLogger } from '../../../../../../../utils/logger'; -const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters'); +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments'); export const POST: Operation = [ authorizeRequestHandler((req) => { return { @@ -32,7 +32,7 @@ export const POST: Operation = [ POST.apiDoc = { description: - 'Creates a new critter in critterbase, and if successful, adds the a link to the critter_id under this survey.', + 'Deploys a device, creating a record of the insertion in the SIMS deployment table. Will also upsert a collar in BCTW as well as insert a new deployment under the resultant collar_id.', tags: ['critterbase'], security: [ { @@ -57,7 +57,7 @@ POST.apiDoc = { } ], requestBody: { - description: 'Critterbase bulk creation request object', + description: 'Specifies a critter, device, and timespan to complete deployment.', content: { 'application/json': { schema: { @@ -95,7 +95,7 @@ POST.apiDoc = { } }, responses: { - 200: { + 201: { description: 'Responds with count of rows created in SIMS DB Deployments.', content: { 'application/json': { @@ -124,13 +124,112 @@ POST.apiDoc = { } }; +export const PATCH: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateDeployment() +]; + +PATCH.apiDoc = { + description: + 'Allows you to update the deployment timespan for a device. Effectively ends a deployment if the attachment end is filled in, but should not delete anything.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number' + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'number' + } + } + ], + requestBody: { + description: 'Specifies a deployment id and the new timerange to update it with.', + content: { + 'application/json': { + schema: { + title: 'Deploy device request object', + type: 'object', + properties: { + deployment_id: { + type: 'string', + format: 'uuid' + }, + attachment_start: { + type: 'string' + }, + attachment_end: { + type: 'string', + nullable: true + } + } + } + } + } + }, + responses: { + 200: { + description: 'Responds with count of rows created or updated in SIMS DB Deployments.', + content: { + 'application/json': { + schema: { + title: 'Number of rows affected', + type: 'number' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + export function deployDevice(): RequestHandler { return async (req, res) => { const user: ICritterbaseUser = { keycloak_guid: req['system_user']?.user_guid, username: req['system_user']?.user_identifier }; - const surveyId = Number(req.params.critterId); + const critterId = Number(req.params.critterId); const connection = getDBConnection(req['keycloak_token']); const surveyCritterService = new SurveyCritterService(connection); const bctw = new BctwService(user); @@ -138,10 +237,10 @@ export function deployDevice(): RequestHandler { await connection.open(); const override_deployment_id = v4(); req.body.deployment_id = override_deployment_id; - const surveyEntry = await surveyCritterService.addDeployment(surveyId, req.body.deployment_id); + const surveyEntry = await surveyCritterService.upsertDeployment(critterId, req.body.deployment_id); await bctw.deployDevice(req.body); await connection.commit(); - return res.status(200).json(surveyEntry); + return res.status(201).json(surveyEntry); } catch (error) { defaultLog.error({ label: 'addDeployment', message: 'error', error }); console.log(JSON.stringify((error as Error).message)); @@ -152,3 +251,30 @@ export function deployDevice(): RequestHandler { } }; } + +export function updateDeployment(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const critterId = Number(req.params.critterId); + const connection = getDBConnection(req['keycloak_token']); + const surveyCritterService = new SurveyCritterService(connection); + const bctw = new BctwService(user); + try { + await connection.open(); + const surveyEntry = await surveyCritterService.upsertDeployment(critterId, req.body.deployment_id); + await bctw.updateDeployment(req.body); + await connection.commit(); + return res.status(200).json(surveyEntry); + } catch (error) { + defaultLog.error({ label: 'updateDeployment', message: 'error', error }); + console.log(JSON.stringify((error as Error).message)); + await connection.rollback(); + return res.status(500).json((error as AxiosError).response); + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/telemetry/device/index.test.ts b/api/src/paths/telemetry/device/index.test.ts new file mode 100644 index 0000000000..8264415566 --- /dev/null +++ b/api/src/paths/telemetry/device/index.test.ts @@ -0,0 +1,47 @@ +import Ajv from 'ajv'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService } from '../../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { POST, upsertDevice } from './index'; + +describe('upsertDevice', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('openapi schema', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((POST.apiDoc as unknown) as object)).to.be.true; + }); + }); + + it('upsert device details', async () => { + const mockUpsertDevice = sinon.stub(BctwService.prototype, 'updateDevice'); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = upsertDevice(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.statusValue).to.equal(200); + expect(mockUpsertDevice).to.have.been.calledOnce; + }); + + it('catches and re-throws errors', async () => { + const mockError = new Error('a test error'); + const mockBctwService = sinon.stub(BctwService.prototype, 'updateDevice').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + const requestHandler = upsertDevice(); + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockBctwService.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/telemetry/device/index.ts b/api/src/paths/telemetry/device/index.ts new file mode 100644 index 0000000000..0a1a8a8162 --- /dev/null +++ b/api/src/paths/telemetry/device/index.ts @@ -0,0 +1,104 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { BctwService, IBctwUser } from '../../../services/bctw-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/telemetry/device/{deviceId}'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + upsertDevice() +]; + +POST.apiDoc = { + description: 'Upsert device metadata inside BCTW.', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Device body', + content: { + 'application/json': { + schema: { + properties: { + collar_id: { + type: 'string', + format: 'uuid' + }, + device_id: { + type: 'integer' + }, + device_make: { + type: 'string' + }, + device_model: { + type: 'string' + }, + frequency: { + type: 'number' + }, + frequency_unit: { + type: 'string' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Resultant object of upsert.', + content: { + 'application/json': { + schema: { + type: 'object' + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function upsertDevice(): RequestHandler { + return async (req, res) => { + const user: IBctwUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const bctwService = new BctwService(user); + try { + const results = await bctwService.updateDevice(req.body); + return res.status(200).json(results); + } catch (error) { + defaultLog.error({ label: 'upsertDevice', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/device/{deviceId}.test.ts b/api/src/paths/telemetry/device/{deviceId}.test.ts index ead4851fe5..69ce92c1c7 100644 --- a/api/src/paths/telemetry/device/{deviceId}.test.ts +++ b/api/src/paths/telemetry/device/{deviceId}.test.ts @@ -1,14 +1,23 @@ +import Ajv from 'ajv'; import { expect } from 'chai'; import sinon from 'sinon'; import { BctwService } from '../../../services/bctw-service'; import { getRequestHandlerMocks } from '../../../__mocks__/db'; -import { getDeviceDetails } from './{deviceId}'; +import { GET, getDeviceDetails } from './{deviceId}'; describe('getDeviceDetails', () => { afterEach(() => { sinon.restore(); }); + describe('openapi schema', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((GET.apiDoc as unknown) as object)).to.be.true; + }); + }); + it('gets device details', async () => { const mockGetDeviceDetails = sinon.stub(BctwService.prototype, 'getDeviceDetails').resolves([]); const mockGetDeployments = sinon.stub(BctwService.prototype, 'getDeviceDeployments').resolves([]); diff --git a/api/src/repositories/survey-critter-repository.test.ts b/api/src/repositories/survey-critter-repository.test.ts index 826bacbc41..cc84ba8cc5 100644 --- a/api/src/repositories/survey-critter-repository.test.ts +++ b/api/src/repositories/survey-critter-repository.test.ts @@ -46,20 +46,20 @@ describe('SurveyRepository', () => { const repository = new SurveyCritterRepository(dbConnection); - const response = await repository.removeCritterFromSurvey(1, 1); + const response = await repository.removeCritterFromSurvey(1); expect(response).to.eql(1); }); }); - describe('addDeployment', () => { - it('should return result', async () => { + describe('upsertDeployment', () => { + it('should update existing row', async () => { const mockResponse = ({ rows: [{ submissionId: 1 }], rowCount: 1 } as any) as Promise>; const dbConnection = getMockDBConnection({ knex: () => mockResponse }); const repository = new SurveyCritterRepository(dbConnection); - const response = await repository.addDeployment(1, 'deployment_id'); + const response = await repository.upsertDeployment(1, 'deployment_id'); expect(response).to.eql(1); }); diff --git a/api/src/repositories/survey-critter-repository.ts b/api/src/repositories/survey-critter-repository.ts index 5f839493b9..491342fe06 100644 --- a/api/src/repositories/survey-critter-repository.ts +++ b/api/src/repositories/survey-critter-repository.ts @@ -51,27 +51,31 @@ export class SurveyCritterRepository extends BaseRepository { * @returns {*} * @member SurveyRepository */ - async removeCritterFromSurvey(surveyId: number, critterId: number): Promise { - defaultLog.debug({ label: 'removeCritterFromSurvey', surveyId }); - const queryBuilder = getKnex().table('critter').delete().where({ survey_id: surveyId, critter_id: critterId }); + async removeCritterFromSurvey(critterId: number): Promise { + defaultLog.debug({ label: 'removeCritterFromSurvey', critterId }); + const queryBuilder = getKnex().table('critter').delete().where({ critter_id: critterId }); const response = await this.connection.knex(queryBuilder); return response.rowCount; } /** - * Add a deployment to the critter. + * Will insert a new critter - deployment uuid association, or update if it already exists. * * @param {number} critterId * @param {string} deplyomentId - * @return {*} {Promise} + * @returns {*} * @memberof SurveyCritterRepository */ - async addDeployment(critterId: number, deplyomentId: string): Promise { + async upsertDeployment(critterId: number, deplyomentId: string): Promise { defaultLog.debug({ label: 'addDeployment', deplyomentId }); const queryBuilder = getKnex() .table('deployment') - .insert({ critter_id: critterId, bctw_deployment_id: deplyomentId }); + .insert({ critter_id: critterId, bctw_deployment_id: deplyomentId }) + .onConflict(['critter_id', 'bctw_deployment_id']) + .merge(['critter_id', 'bctw_deployment_id']); + const response = await this.connection.knex(queryBuilder); + return response.rowCount; } } diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts index 295367da9e..ce169a5df6 100755 --- a/api/src/services/bctw-service.test.ts +++ b/api/src/services/bctw-service.test.ts @@ -16,7 +16,8 @@ import { HEALTH_ENDPOINT, IDeployDevice, IDeploymentUpdate, - UPDATE_DEPLOYMENT_ENDPOINT + UPDATE_DEPLOYMENT_ENDPOINT, + UPSERT_DEVICE_ENDPOINT } from './bctw-service'; import { KeycloakService } from './keycloak-service'; @@ -102,8 +103,8 @@ describe('BctwService', () => { const mockDevice: IDeployDevice = { device_id: 1, frequency: 100, - manufacturer: 'Lotek', - model: 'model', + device_make: 'Lotek', + device_model: 'model', attachment_start: '2020-01-01', attachment_end: '2020-01-02', critter_id: 'abc123' @@ -207,5 +208,30 @@ describe('BctwService', () => { }); }); }); + + describe('updateDevice', () => { + it('should send a post request', async () => { + const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves({ data: { results: [], errors: [] } }); + + const body = { + device_id: 1, + collar_id: '' + }; + await bctwService.updateDevice(body); + + expect(mockAxios).to.have.been.calledOnceWith(UPSERT_DEVICE_ENDPOINT, body); + }); + it('should send a post request and get some errors back', async () => { + sinon + .stub(bctwService.axiosInstance, 'post') + .resolves({ data: { results: [], errors: [{ device_id: 'error' }] } }); + + const body = { + device_id: 1, + collar_id: '' + }; + await bctwService.updateDevice(body).catch((e) => expect(e.message).to.equal('[{"device_id":"error"}]')); + }); + }); }); }); diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts index e482f83c05..d123c86b22 100644 --- a/api/src/services/bctw-service.ts +++ b/api/src/services/bctw-service.ts @@ -6,16 +6,19 @@ import { KeycloakService } from './keycloak-service'; export const IDeployDevice = z.object({ device_id: z.number(), - frequency: z.number(), - manufacturer: z.string(), - model: z.string(), + frequency: z.number().optional(), + frequency_unit: z.string().optional(), + device_make: z.string().optional(), + device_model: z.string().optional(), attachment_start: z.string(), - attachment_end: z.string(), + attachment_end: z.string().nullable(), critter_id: z.string() }); export type IDeployDevice = z.infer; +export type IDevice = Omit & { collar_id: string }; + export const IDeploymentUpdate = z.object({ deployment_id: z.string(), attachment_start: z.string(), @@ -60,6 +63,7 @@ export type IBctwUser = z.infer; export const BCTW_API_HOST = process.env.BCTW_API_HOST || ''; export const DEPLOY_DEVICE_ENDPOINT = '/deploy-device'; +export const UPSERT_DEVICE_ENDPOINT = '/upsert-collar'; export const GET_DEPLOYMENTS_ENDPOINT = '/get-deployments'; export const GET_DEPLOYMENTS_BY_CRITTER_ENDPOINT = '/get-deployments-by-critter-id'; export const GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT = '/get-deployments-by-device-id'; @@ -161,10 +165,39 @@ export class BctwService { return await this.axiosInstance.post(DEPLOY_DEVICE_ENDPOINT, device); } - async getDeviceDetails(deviceId: number): Promise[]> { - return await this._makeGetRequest(`${GET_DEVICE_DETAILS}${deviceId}`); + /** + * Update device hardware details in BCTW. + * + * @param {IDevice} device + * @returns {*} {IDevice} + * @memberof BctwService + */ + async updateDevice(device: IDevice): Promise { + const { data } = await this.axiosInstance.post(UPSERT_DEVICE_ENDPOINT, device); + if (data.errors.length) { + throw Error(JSON.stringify(data.errors)); + } + return data; } + /** + * Get device hardware details by device id. + * + * @param deviceId + * @returns {*} {Promise} + * @memberof BctwService + */ + async getDeviceDetails(deviceId: number): Promise { + return this._makeGetRequest(`${GET_DEVICE_DETAILS}${deviceId}`); + } + + /** + * Get deployments by device id, may return results for multiple critters. + * + * @param {number} deviceId + * @returns {*} {Promise} + * @memberof BctwService + */ async getDeviceDeployments(deviceId: number): Promise { return await this._makeGetRequest(GET_DEPLOYMENTS_BY_DEVICE_ENDPOINT, { device_id: String(deviceId) }); } diff --git a/api/src/services/survey-critter-service.test.ts b/api/src/services/survey-critter-service.test.ts index 2afbf41b11..78f55bb05e 100644 --- a/api/src/services/survey-critter-service.test.ts +++ b/api/src/services/survey-critter-service.test.ts @@ -55,7 +55,7 @@ describe('SurveyService', () => { const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'removeCritterFromSurvey').resolves(1); - const response = await service.removeCritterFromSurvey(1, 1); + const response = await service.removeCritterFromSurvey(1); expect(repoStub).to.be.calledOnce; expect(response).to.eql(1); @@ -67,9 +67,9 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyCritterService(dbConnection); - const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'addDeployment').resolves(1); + const repoStub = sinon.stub(SurveyCritterRepository.prototype, 'upsertDeployment').resolves(1); - const response = await service.addDeployment(1, 'deployment_id'); + const response = await service.upsertDeployment(1, 'deployment_id'); expect(repoStub).to.be.calledOnce; expect(response).to.eql(1); diff --git a/api/src/services/survey-critter-service.ts b/api/src/services/survey-critter-service.ts index 4b2e5f6a31..eb367b09a9 100644 --- a/api/src/services/survey-critter-service.ts +++ b/api/src/services/survey-critter-service.ts @@ -1,5 +1,5 @@ import { IDBConnection } from '../database/db'; -import { SurveyCritterRepository } from '../repositories/survey-critter-repository'; +import { SurveyCritterRecord, SurveyCritterRepository } from '../repositories/survey-critter-repository'; import { DBService } from './db-service'; export class SurveyCritterService extends DBService { @@ -15,7 +15,7 @@ export class SurveyCritterService extends DBService { * @param {number} surveyId * @returns {*} */ - async getCrittersInSurvey(surveyId: number) { + async getCrittersInSurvey(surveyId: number): Promise { return this.critterRepository.getCrittersInSurvey(surveyId); } @@ -26,29 +26,27 @@ export class SurveyCritterService extends DBService { * @param {string} critterId * @returns {*} */ - async addCritterToSurvey(surveyId: number, critterBaseCritterId: string) { + async addCritterToSurvey(surveyId: number, critterBaseCritterId: string): Promise { return this.critterRepository.addCritterToSurvey(surveyId, critterBaseCritterId); } /** * Removes a critter from the survey. Does not affect the critter in the external system. - * @param {number} surveyId * @param {string} critterId * @returns {*} */ - async removeCritterFromSurvey(surveyId: number, critterId: number) { - return this.critterRepository.removeCritterFromSurvey(surveyId, critterId); + async removeCritterFromSurvey(critterId: number): Promise { + return this.critterRepository.removeCritterFromSurvey(critterId); } /** - * Adds a deployment to a critter. Does not affect the critter or device in the external system. + * Upsert a deployment row into SIMS. * - * @param {number} critterId - * @param {string} deplyomentId - * @return {*} - * @memberof SurveyCritterService + * @param {id} critterId + * @param {id} deplyomentId + * @returns {*} */ - async addDeployment(critterId: number, deplyomentId: string) { - return this.critterRepository.addDeployment(critterId, deplyomentId); + async upsertDeployment(critterId: number, deplyomentId: string): Promise { + return this.critterRepository.upsertDeployment(critterId, deplyomentId); } } diff --git a/app/src/features/surveys/view/SurveyAnimals.test.tsx b/app/src/features/surveys/view/SurveyAnimals.test.tsx index 1181ad7767..9b7b233750 100644 --- a/app/src/features/surveys/view/SurveyAnimals.test.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.test.tsx @@ -1,11 +1,20 @@ +import { AuthStateContext } from 'contexts/authStateContext'; +import { IProjectAuthStateContext, ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; +import { IProjectContext, ProjectContext } from 'contexts/projectContext'; +import { ISurveyContext, SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { cleanup, render, waitFor } from 'test-helpers/test-utils'; +import { DataLoader } from 'hooks/useDataLoader'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; +import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; import SurveyAnimals from './SurveyAnimals'; jest.mock('../../../hooks/useBioHubApi'); +jest.mock('../../../hooks/useTelemetryApi'); const mockBiohubApi = useBiohubApi as jest.Mock; +const mockTelemetryApi = useTelemetryApi as jest.Mock; -const mockUseApi = { +const mockUseBiohub = { survey: { getSurveyCritters: jest.fn(), getDeploymentsInSurvey: jest.fn(), @@ -14,13 +23,58 @@ const mockUseApi = { } }; +const mockUseTelemetry = { + devices: { + getDeviceDetails: jest.fn() + } +}; + describe('SurveyAnimals', () => { + const mockSurveyContext: ISurveyContext = { + artifactDataLoader: { + data: null, + load: jest.fn() + } as unknown as DataLoader, + surveyId: 1, + projectId: 1, + surveyDataLoader: { + data: { surveyData: { survey_details: { survey_name: 'name' } } }, + load: jest.fn() + } as unknown as DataLoader + } as unknown as ISurveyContext; + + const mockProjectAuthStateContext: IProjectAuthStateContext = { + getProjectParticipant: () => null, + hasProjectRole: () => true, + hasProjectPermission: () => true, + hasSystemRole: () => true, + getProjectId: () => 1, + hasLoadedParticipantInfo: true + }; + + const mockProjectContext: IProjectContext = { + artifactDataLoader: { + data: null, + load: jest.fn() + } as unknown as DataLoader, + projectId: 1, + projectDataLoader: { + data: { projectData: { project: { project_name: 'name' } } }, + load: jest.fn() + } as unknown as DataLoader + } as unknown as IProjectContext; + + const authState = getMockAuthState({ base: SystemAdminAuthState }); + beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.survey.getDeploymentsInSurvey.mockClear(); - mockUseApi.survey.getSurveyCritters.mockClear(); - mockUseApi.survey.createCritterAndAddToSurvey.mockClear(); - mockUseApi.survey.addDeployment.mockClear(); + mockBiohubApi.mockImplementation(() => mockUseBiohub); + mockUseBiohub.survey.getDeploymentsInSurvey.mockClear(); + mockUseBiohub.survey.getSurveyCritters.mockClear(); + mockUseBiohub.survey.createCritterAndAddToSurvey.mockClear(); + mockUseBiohub.survey.addDeployment.mockClear(); + + mockTelemetryApi.mockImplementation(() => mockUseTelemetry); + mockUseTelemetry.devices.getDeviceDetails.mockClear(); }); afterEach(() => { @@ -28,7 +82,17 @@ describe('SurveyAnimals', () => { }); it('renders correctly with no animals', async () => { - const { getByText } = render(); + const { getByText } = render( + + + + + + + + + + ); await waitFor(() => { expect(getByText('No Individual Animals')).toBeInTheDocument(); @@ -36,12 +100,32 @@ describe('SurveyAnimals', () => { }); it('renders correctly with animals', async () => { - mockUseApi.survey.getSurveyCritters.mockResolvedValueOnce([{ critter_id: 'abc', survey_critter_id: 1 }]); + mockUseBiohub.survey.getSurveyCritters.mockResolvedValueOnce([ + { critter_id: 'critter_uuid', survey_critter_id: 1, animal_id: 'a', taxon: 'a', created_at: 'a' } + ]); - const { getByText } = render(); + mockUseBiohub.survey.getDeploymentsInSurvey.mockResolvedValue([{ critter_id: 'critter_uuid', device_id: 123 }]); + mockUseBiohub.survey.createCritterAndAddToSurvey.mockResolvedValue({}); + const { getByText, getByTestId } = render( + + + + + + + + + + ); await waitFor(() => { - expect(getByText('abc')).toBeInTheDocument(); + expect(getByText('critter_uuid')).toBeInTheDocument(); + expect(getByTestId('survey-animal-table')).toBeInTheDocument(); + fireEvent.click(getByTestId('animal actions')); + fireEvent.click(getByTestId('animal-table-row-edit-timespan')); + fireEvent.click(getByText('Save Changes')); + //fireEvent.click(getByText('Import Animals')); + //fireEvent.click(getByText('Save Changes')); }); }); }); diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index 1ff0dc4dd6..b205d2034b 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -9,29 +9,31 @@ import { DialogContext } from 'contexts/dialogContext'; import { SurveyContext } from 'contexts/surveyContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; +import { useTelemetryApi } from 'hooks/useTelemetryApi'; +import { isEqual as _deepEquals } from 'lodash-es'; import React, { useContext, useState } from 'react'; -import { pluralize } from 'utils/Utils'; +import { datesSameNullable, pluralize } from 'utils/Utils'; +import yup from 'utils/YupSchema'; import NoSurveySectionData from '../components/NoSurveySectionData'; -import { - AnimalSchema, - AnimalTelemetryDeviceSchema, - Critter, - IAnimal, - IAnimalTelemetryDevice -} from './survey-animals/animal'; +import { AnimalSchema, Critter, IAnimal } from './survey-animals/animal'; +import { AnimalTelemetryDeviceSchema, Device, IAnimalTelemetryDevice } from './survey-animals/device'; import IndividualAnimalForm from './survey-animals/IndividualAnimalForm'; import { SurveyAnimalsTable } from './survey-animals/SurveyAnimalsTable'; -import TelemetryDeviceForm from './survey-animals/TelemetryDeviceForm'; +import TelemetryDeviceForm, { TELEMETRY_DEVICE_FORM_MODE } from './survey-animals/TelemetryDeviceForm'; const SurveyAnimals: React.FC = () => { const bhApi = useBiohubApi(); + const telemetryApi = useTelemetryApi(); const dialogContext = useContext(DialogContext); const surveyContext = useContext(SurveyContext); const [openAddCritterDialog, setOpenAddCritterDialog] = useState(false); - const [openAddDeviceDialog, setOpenAddDeviceDialog] = useState(false); + const [openDeviceDialog, setOpenDeviceDialog] = useState(false); const [animalCount, setAnimalCount] = useState(0); const [selectedCritterId, setSelectedCritterId] = useState(null); + const [telemetryFormMode, setTelemetryFormMode] = useState( + TELEMETRY_DEVICE_FORM_MODE.ADD + ); const { projectId, surveyId } = surveyContext; const { @@ -50,6 +52,8 @@ const SurveyAnimals: React.FC = () => { loadCritters(); } + const currentCritterbaseCritterId = critterData?.find((a) => a.survey_critter_id === selectedCritterId)?.critter_id; + if (!deploymentData) { loadDeployments(); } @@ -58,6 +62,17 @@ const SurveyAnimals: React.FC = () => { setOpenAddCritterDialog((d) => !d); }; + const setPopup = (message: string) => { + dialogContext.setSnackbar({ + open: true, + snackbarMessage: ( + + {message} + + ) + }); + }; + const AnimalFormValues: IAnimal = { general: { taxon_id: '', taxon_name: '', animal_id: '' }, captures: [], @@ -70,13 +85,47 @@ const SurveyAnimals: React.FC = () => { }; const DeviceFormValues: IAnimalTelemetryDevice = { - device_id: 0, + device_id: '' as unknown as number, device_make: '', - frequency: 0, + frequency: '' as unknown as number, frequency_unit: '', device_model: '', - attachment_start: '', - attachment_end: undefined + deployments: [ + { + deployment_id: '', + attachment_start: '', + attachment_end: undefined + } + ] + }; + + const obtainDeviceFormInitialValues = (mode: TELEMETRY_DEVICE_FORM_MODE) => { + switch (mode) { + case TELEMETRY_DEVICE_FORM_MODE.ADD: + return [DeviceFormValues]; + case TELEMETRY_DEVICE_FORM_MODE.EDIT: { + const deployments = deploymentData?.filter((a) => a.critter_id === currentCritterbaseCritterId); + if (deployments) { + //Any suggestions on something better than this reduce is welcome. + //Idea is to transform flat rows of {device_id, ..., deployment_id, attachment_end, attachment_start} + //to {device_id, ..., deployments: [{deployment_id, attachment_start, attachment_end}]} + const red = deployments.reduce((acc: IAnimalTelemetryDevice[], curr) => { + const currObj = acc.find((a: any) => a.device_id === curr.device_id); + const { attachment_end, attachment_start, deployment_id, ...rest } = curr; + const deployment = { deployment_id, attachment_start, attachment_end }; + if (!currObj) { + acc.push({ ...rest, deployments: [deployment] }); + } else { + currObj.deployments?.push(deployment); + } + return acc; + }, []); + return red; + } else { + return [DeviceFormValues]; + } + } + } }; const handleCritterSave = async (animal: IAnimal) => { @@ -88,7 +137,7 @@ const SurveyAnimals: React.FC = () => { open: true, snackbarMessage: ( - {`Animal added to Survey`} + {'Animal added to Survey'} ) }); @@ -101,24 +150,48 @@ const SurveyAnimals: React.FC = () => { } }; - const handleTelemetrySave = async (survey_critter_id: number, data: IAnimalTelemetryDevice) => { + const handleTelemetrySave = async (survey_critter_id: number, data: IAnimalTelemetryDevice[]) => { const critter = critterData?.find((a) => a.survey_critter_id === survey_critter_id); - const critterTelemetryDevice = { ...data, critter_id: critter?.critter_id ?? '' }; - try { - await bhApi.survey.addDeployment(projectId, surveyId, survey_critter_id, critterTelemetryDevice); - } catch (e) { - dialogContext.setSnackbar({ - open: true, - snackbarMessage: ( - - {`Could not add deployment.`} - - ) - }); - } finally { - setOpenAddDeviceDialog(false); - refreshDeployments(); + const critterTelemetryDevice = { ...data[0], critter_id: critter?.critter_id ?? '' }; + if (telemetryFormMode === TELEMETRY_DEVICE_FORM_MODE.ADD) { + try { + await bhApi.survey.addDeployment(projectId, surveyId, survey_critter_id, critterTelemetryDevice); + setPopup('Successfully added deployment.'); + } catch (e) { + setPopup('Failed to add deployment.'); + } + } else if (telemetryFormMode === TELEMETRY_DEVICE_FORM_MODE.EDIT) { + for (const formValues of data) { + const existingDevice = deploymentData?.find((a) => a.device_id === formValues.device_id); + const formDevice = new Device({ collar_id: existingDevice?.collar_id, ...formValues }); + if (existingDevice && !_deepEquals(new Device(existingDevice), formDevice)) { + //Verify whether the data entered in the form changed from the device metadata we already have. + try { + await telemetryApi.devices.upsertCollar(formDevice); //If it's different, upsert. Note that this alone does not touch a deployment. + } catch (e) { + setPopup(`Failed to update device ${formDevice.device_id}`); + } + } + for (const formDeployment of formValues.deployments ?? []) { + //Iterate over the deployments under this device. + const existingDeployment = deploymentData?.find((a) => a.deployment_id === formDeployment.deployment_id); //Find the deployment info we already have. + if ( + !datesSameNullable(formDeployment?.attachment_start, existingDeployment?.attachment_start) || + !datesSameNullable(formDeployment?.attachment_end, existingDeployment?.attachment_end) //Helper function necessary for this date comparison since moment(null) !== moment(null) normally. + ) { + try { + await bhApi.survey.updateDeployment(projectId, surveyId, survey_critter_id, formDeployment); + } catch (e) { + setPopup(`Failed to update deployment ${formDeployment.deployment_id}`); + } + } + } + } + setPopup('Updated deployment and device data successfully.'); } + + setOpenDeviceDialog(false); + refreshDeployments(); }; return ( @@ -150,15 +223,16 @@ const SurveyAnimals: React.FC = () => { }} /> , - initialValues: DeviceFormValues, - validationSchema: AnimalTelemetryDeviceSchema + element: , + initialValues: obtainDeviceFormInitialValues(telemetryFormMode), + validationSchema: yup.array(AnimalTelemetryDeviceSchema) }} - onCancel={() => setOpenAddDeviceDialog(false)} + onCancel={() => setOpenDeviceDialog(false)} onSave={(values) => { if (selectedCritterId) { handleTelemetrySave(selectedCritterId, values); @@ -179,13 +253,18 @@ const SurveyAnimals: React.FC = () => { { - await bhApi.survey.removeCritterFromSurvey(projectId, surveyId, critter_id); + onMenuOpen={setSelectedCritterId} + onRemoveCritter={(critter_id) => { + bhApi.survey.removeCritterFromSurvey(projectId, surveyId, critter_id); refreshCritters(); }} onAddDevice={(critter_id) => { - setSelectedCritterId(critter_id); - setOpenAddDeviceDialog(true); + setTelemetryFormMode(TELEMETRY_DEVICE_FORM_MODE.ADD); + setOpenDeviceDialog(true); + }} + onEditDevice={(device_id) => { + setTelemetryFormMode(TELEMETRY_DEVICE_FORM_MODE.EDIT); + setOpenDeviceDialog(true); }} /> ) : ( diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx index f30d5d961a..71f22497b5 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTable.tsx @@ -4,7 +4,7 @@ import { CustomDataGrid } from 'components/tables/CustomDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { IDetailedCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; import { getFormattedDate } from 'utils/Utils'; -import { IAnimalDeployment } from './animal'; +import { IAnimalDeployment } from './device'; import SurveyAnimalsTableActions from './SurveyAnimalsTableActions'; interface ISurveyAnimalsTableEntry { @@ -12,14 +12,16 @@ interface ISurveyAnimalsTableEntry { critter_id: string; animal_id: string | null; taxon: string; - telemetry_device?: IAnimalDeployment[]; + deployments?: IAnimalDeployment[]; } interface ISurveyAnimalsTableProps { animalData: IDetailedCritterWithInternalId[]; deviceData?: IAnimalDeployment[]; + onMenuOpen: (critter_id: number) => void; onRemoveCritter: (critter_id: number) => void; onAddDevice: (critter_id: number) => void; + onEditDevice: (device_id: number) => void; } const noOpPlaceHolder = (critter_id: number) => { @@ -29,9 +31,21 @@ const noOpPlaceHolder = (critter_id: number) => { export const SurveyAnimalsTable = ({ animalData, deviceData, + onMenuOpen, onRemoveCritter, - onAddDevice + onAddDevice, + onEditDevice }: ISurveyAnimalsTableProps): JSX.Element => { + const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData + ? animalData.map((animal) => { + const deployments = deviceData.filter((device) => device.critter_id === animal.critter_id); + return { + ...animal, + deployments: deployments + }; + }) + : animalData; + const columns: GridColDef[] = [ { field: 'critter_id', @@ -58,7 +72,7 @@ export const SurveyAnimalsTable = ({ ) }, { - field: 'telemetry_device', + field: 'deployments', headerName: 'Device ID', flex: 1, renderCell: (params) => ( @@ -79,26 +93,18 @@ export const SurveyAnimalsTable = ({ renderCell: (params) => ( ) } ]; - const animalDeviceData: ISurveyAnimalsTableEntry[] = deviceData - ? animalData.map((animal) => { - const devices = deviceData.filter((device) => device.critter_id === animal.critter_id); - return { - ...animal, - telemetry_device: devices - }; - }) - : animalData; - return ( diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx index ed27e59aa9..1aefea4e59 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.test.tsx @@ -7,16 +7,16 @@ describe('SurveyAnimalsTableActions', () => { it('all buttons should be clickable', async () => { const { getByTestId } = render( - <> - {}} - onEditCritter={() => {}} - onRemoveCritter={onRemoveCritter} - /> - + {}} + onEditCritter={() => {}} + onRemoveCritter={onRemoveCritter} + onMenuOpen={() => {}} + onRemoveDevice={() => {}} + /> ); fireEvent.click(getByTestId('animal actions')); diff --git a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx index 5763483ded..027623a7dd 100644 --- a/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx +++ b/app/src/features/surveys/view/survey-animals/SurveyAnimalsTableActions.tsx @@ -1,17 +1,19 @@ -import { mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import { ListItemText } from '@mui/material'; import IconButton from '@mui/material/IconButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; import React, { useState } from 'react'; -import { IAnimalDeployment } from './animal'; +import { IAnimalDeployment } from './device'; export interface ITableActionsMenuProps { critter_id: number; devices?: IAnimalDeployment[]; + onMenuOpen: (critter_id: number) => void; onAddDevice: (critter_id: number) => void; + onRemoveDevice: (critter_id: number) => void; onEditDevice: (critter_id: number) => void; onEditCritter: (critter_id: number) => void; onRemoveCritter: (critter_id: number) => void; @@ -23,6 +25,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); + props.onMenuOpen(props.critter_id); }; const handleClose = () => { @@ -59,22 +62,24 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { - Add Telemetry Device + Add Telemetry Device + {props.devices?.length ? ( + { + handleClose(); + props.onEditDevice(props.critter_id); + }} + data-testid="animal-table-row-edit-timespan"> + + + + Edit Deployment Timespan + + ) : null} { - //To be implemented later. + //To be implemented in 217 - Edit Critters /* { - handleClose(); - props.onEditDevice(props.critter_id); - }} - data-testid="animal-table-row-edit-timespan"> - - - - Edit Deployment Timespan - - { handleClose(); props.onEditCritter(props.critter_id); @@ -83,7 +88,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { - Edit Critter Details + Edit Critter Details */ } {!props.devices?.length && ( @@ -96,7 +101,7 @@ const SurveyAnimalsTableActions = (props: ITableActionsMenuProps) => { - Remove Critter From Survey + Remove Critter From Survey )} diff --git a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx index 0365abb1fe..628a5b9167 100644 --- a/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx +++ b/app/src/features/surveys/view/survey-animals/TelemetryDeviceForm.tsx @@ -1,4 +1,4 @@ -import { FormHelperText } from '@mui/material'; +import { Box, Divider, FormHelperText, Paper, Typography } from '@mui/material'; import Grid from '@mui/material/Grid'; import CustomTextField from 'components/fields/CustomTextField'; import SingleDateField from 'components/fields/SingleDateField'; @@ -7,58 +7,104 @@ import { Form, useFormikContext } from 'formik'; import useDataLoader from 'hooks/useDataLoader'; import { useTelemetryApi } from 'hooks/useTelemetryApi'; import moment from 'moment'; -import { useEffect, useState } from 'react'; -import { IAnimalTelemetryDevice } from './animal'; +import { Fragment, useEffect, useState } from 'react'; +import { IAnimalTelemetryDevice, IDeploymentTimespan } from './device'; -const TelemetryDeviceForm = () => { - const { values, setStatus } = useFormikContext(); +export enum TELEMETRY_DEVICE_FORM_MODE { + ADD = 'add', + EDIT = 'edit' +} + +const DeploymentFormSection = ({ + index, + deployments +}: { + index: number; + deployments: IDeploymentTimespan[]; +}): JSX.Element => { + return ( + <> + + {deployments.map((deploy, i) => { + return ( + + + + + + + + + ); + })} + + + ); +}; + +interface IDeviceFormSectionProps { + mode: TELEMETRY_DEVICE_FORM_MODE; + values: IAnimalTelemetryDevice[]; + index: number; +} + +const DeviceFormSection = ({ values, index, mode }: IDeviceFormSectionProps): JSX.Element => { + const { setStatus } = useFormikContext<{ formValues: IAnimalTelemetryDevice[] }>(); const [bctwErrors, setBctwErrors] = useState>({}); const api = useTelemetryApi(); - const { data: deviceData, refresh: refreshDevice } = useDataLoader(() => - api.devices.getDeviceDetails(values.device_id) - ); + + const { data: bctwDeviceData, refresh } = useDataLoader(() => api.devices.getDeviceDetails(values[index].device_id)); useEffect(() => { - refreshDevice(); + refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values.device_id]); + }, [values[index].device_id]); useEffect(() => { const errors: { device_make?: string; attachment_start?: string } = {}; - if (deviceData?.device && deviceData.device?.device_make !== values.device_make) { - errors.device_make = `Submitting this form would change the registered device make of device ${values.device_id}, which is disallowed.`; + if (bctwDeviceData?.device && bctwDeviceData.device?.device_make !== values[index].device_make) { + errors.device_make = `Submitting this form would change the registered device make of device ${values[index].device_id}, which is disallowed.`; } - const existingDeployment = deviceData?.deployments?.find( - (a) => - (moment(values.attachment_start).isSameOrAfter(moment(a.attachment_start)) && - moment(values.attachment_start).isSameOrBefore(moment(a.attachment_end))) || - a.attachment_end == null - ); - if (existingDeployment) { - errors.attachment_start = `Cannot make a deployment starting on this date, as it will conflict with deployment ${ - existingDeployment.deployment_id - } - running from ${existingDeployment.attachment_start} until ${existingDeployment.attachment_end ?? 'indefinite'}.`; + for (const deployment of values[index].deployments ?? []) { + const existingDeployment = bctwDeviceData?.deployments?.find( + (a) => + deployment.deployment_id !== a.deployment_id && + moment(deployment.attachment_start).isSameOrAfter(moment(a.attachment_start)) && + (moment(deployment.attachment_start).isSameOrBefore(moment(a.attachment_end)) || a.attachment_end == null) + ); //Check if there is already a deployment that is not the same id as this one and overlaps the time we are trying to upload. + if (existingDeployment) { + errors.attachment_start = `Cannot make a deployment starting on this date, it will conflict with deployment ${ + existingDeployment.deployment_id + } + running from ${existingDeployment.attachment_start} until ${ + existingDeployment.attachment_end ?? 'indefinite' + }.`; + } } + setBctwErrors(errors); setStatus({ forceDisable: Object.entries(errors).length > 0 }); - }, [deviceData, setStatus, values]); + }, [bctwDeviceData, index, setStatus, values]); return ( - + <> - + - + { const codeVals = await api.devices.getCodeValues('frequency_unit'); return codeVals.map((a) => a.description); @@ -68,25 +114,57 @@ const TelemetryDeviceForm = () => { - - - - + + + + + Deployments + {} + {Object.entries(bctwErrors).length > 0 && ( - {Object.values(bctwErrors).map((bctwError) => ( - {bctwError} + {Object.entries(bctwErrors).map((bctwError) => ( + + {bctwError[1]} + ))} )} - + + + ); +}; + +interface ITelemetryDeviceFormProps { + mode: TELEMETRY_DEVICE_FORM_MODE; +} + +const TelemetryDeviceForm = ({ mode }: ITelemetryDeviceFormProps) => { + const { values } = useFormikContext(); + + return ( + + <> + {values.map((device, idx) => ( + + Device Metadata + + + + ))} + ); }; diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index 348bb463ea..bd30ff0ccb 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -4,6 +4,7 @@ import moment from 'moment'; import yup from 'utils/YupSchema'; import { v4 } from 'uuid'; import { AnyObjectSchema, InferType, reach } from 'yup'; +import { AnimalTelemetryDeviceSchema } from './device'; /** * Provides an acceptable amount of type security with formik field names for animal forms @@ -148,32 +149,6 @@ export const AnimalRelationshipSchema = yup.object({}).shape({ relationship: yup.mixed().oneOf(['parent', 'child', 'sibling']).required(req) }); -export const AnimalTelemetryDeviceSchema = yup.object({}).shape({ - device_id: numSchema.required(req), - device_make: yup.string().required(req), - frequency: numSchema.required(req), - frequency_unit: yup.string().required(req), - device_model: yup.string().required(req), - attachment_start: yup.string().required(req), - attachment_end: yup.string() -}); - -export const AnimalDeploymentSchema = yup.object({}).shape({ - assignment_id: yup.string(), - collar_id: yup.string(), - critter_id: yup.string(), - created_at: yup.string(), - created_by_user_id: yup.string(), - updated_at: yup.string(), - updated_by_user_id: yup.string(), - valid_from: yup.string(), - valid_to: yup.string(), - attachment_start: yup.string(), - attachment_end: yup.string(), - deployment_id: yup.string(), - device_id: yup.number() -}); - const AnimalImageSchema = yup.object({}).shape({}); export const AnimalSchema = yup.object({}).shape({ @@ -209,16 +184,12 @@ export type IAnimalMortality = InferType; export type IAnimalRelationship = InferType; -export type IAnimalTelemetryDevice = InferType; - export type IAnimalImage = InferType; export type IAnimal = InferType; export type IAnimalKey = keyof IAnimal; -export type IAnimalDeployment = InferType; - //Critterbase related types type ICritterID = { critter_id: string }; diff --git a/app/src/features/surveys/view/survey-animals/device.ts b/app/src/features/surveys/view/survey-animals/device.ts new file mode 100644 index 0000000000..14b554df1c --- /dev/null +++ b/app/src/features/surveys/view/survey-animals/device.ts @@ -0,0 +1,58 @@ +import yup from 'utils/YupSchema'; +import { InferType } from 'yup'; + +export type IAnimalDeployment = InferType; + +export type IDeploymentTimespan = InferType; + +export type IAnimalTelemetryDevice = InferType; + +const req = 'Required.'; +const mustBeNum = 'Must be a number'; +const numSchema = yup.number().typeError(mustBeNum); + +export const AnimalDeploymentTimespanSchema = yup.object({}).shape({ + deployment_id: yup.string(), + attachment_start: yup.string().required(req), + attachment_end: yup.string() +}); + +export const AnimalTelemetryDeviceSchema = yup.object({}).shape({ + device_id: numSchema.required(req), + device_make: yup.string().required(req), + frequency: numSchema, + frequency_unit: yup.string().nullable(), + device_model: yup.string(), + deployments: yup.array(AnimalDeploymentTimespanSchema) +}); + +export const AnimalDeploymentSchema = yup.object({}).shape({ + assignment_id: yup.string().required(), + collar_id: yup.string().required(), + critter_id: yup.string().required(), + attachment_start: yup.string().required(), + attachment_end: yup.string(), + deployment_id: yup.string().required(), + device_id: yup.number().required(), + device_make: yup.string().required(), + device_model: yup.string(), + frequency: numSchema, + frequency_unit: yup.string() +}); + +export class Device implements Omit { + device_id: number; + device_make: string; + device_model: string; + frequency: number; + frequency_unit: string; + collar_id: string; + constructor(obj: Record) { + this.device_id = Number(obj.device_id); + this.device_make = String(obj.device_make); + this.device_model = String(obj.device_model); + this.frequency = Number(obj.frequency); + this.frequency_unit = String(obj.frequency_unit); + this.collar_id = String(obj.collar_id); + } +} diff --git a/app/src/hooks/api/useSurveyApi.test.ts b/app/src/hooks/api/useSurveyApi.test.ts index a5fbbbb203..a6e5de3d69 100644 --- a/app/src/hooks/api/useSurveyApi.test.ts +++ b/app/src/hooks/api/useSurveyApi.test.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { Critter, IAnimal, IAnimalDeployment } from 'features/surveys/view/survey-animals/animal'; +import { Critter, IAnimal } from 'features/surveys/view/survey-animals/animal'; +import { IAnimalDeployment } from 'features/surveys/view/survey-animals/device'; import { v4 } from 'uuid'; import useSurveyApi from './useSurveyApi'; @@ -61,13 +62,45 @@ describe('useSurveyApi', () => { device_model: 'E', frequency: 1, frequency_unit: 'Hz', - attachment_start: '2023-01-01', - attachment_end: undefined, + deployments: [ + { + deployment_id: '', + attachment_start: '2023-01-01', + attachment_end: undefined + } + ], critter_id: v4() }); expect(result).toBe(1); }); + + it('should fail to add deployment to survey critter', async () => { + mock.onPost(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`).reply(201, 1); + + const result = useSurveyApi(axios).addDeployment(projectId, surveyId, critterId, { + device_id: 1, + device_make: 'ATS', + device_model: 'E', + frequency: 1, + frequency_unit: 'Hz', + deployments: [ + { + deployment_id: '', + attachment_start: '2023-01-01', + attachment_end: undefined + }, + { + deployment_id: '', + attachment_start: '2023-01-01', + attachment_end: undefined + } + ], + critter_id: v4() + }); + + await expect(result).rejects.toThrow(); + }); }); describe('getDeploymentsInSurvey', () => { @@ -76,16 +109,14 @@ describe('useSurveyApi', () => { assignment_id: v4(), collar_id: v4(), critter_id: v4(), - created_at: '2023-01-01', - created_by_user_id: v4(), - updated_at: undefined, - updated_by_user_id: undefined, - valid_from: undefined, - valid_to: undefined, attachment_start: '2023-01-01', attachment_end: '2023-01-01', deployment_id: v4(), - device_id: 123 + device_id: 123, + device_make: '', + device_model: 'a', + frequency: 1, + frequency_unit: 'Hz' }; mock.onGet(`/api/project/${projectId}/survey/${surveyId}/deployments`).reply(200, [response]); @@ -97,4 +128,17 @@ describe('useSurveyApi', () => { expect(result[0].device_id).toBe(123); }); }); + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + mock.onPatch(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`).reply(200, 1); + const result = await useSurveyApi(axios).updateDeployment(projectId, surveyId, critterId, { + attachment_end: undefined, + deployment_id: 'a', + attachment_start: 'a' + }); + + expect(result).toBe(1); + }); + }); }); diff --git a/app/src/hooks/api/useSurveyApi.ts b/app/src/hooks/api/useSurveyApi.ts index abf34b278a..4f4edd71f9 100644 --- a/app/src/hooks/api/useSurveyApi.ts +++ b/app/src/hooks/api/useSurveyApi.ts @@ -1,7 +1,12 @@ import { AxiosInstance, CancelTokenSource } from 'axios'; import { IEditReportMetaForm } from 'components/attachments/EditReportMetaForm'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; -import { Critter, IAnimalDeployment, IAnimalTelemetryDevice } from 'features/surveys/view/survey-animals/animal'; +import { Critter } from 'features/surveys/view/survey-animals/animal'; +import { + IAnimalDeployment, + IAnimalTelemetryDevice, + IDeploymentTimespan +} from 'features/surveys/view/survey-animals/device'; import { IGetAttachmentDetails, IGetReportDetails, @@ -460,28 +465,11 @@ const useSurveyApi = (axios: AxiosInstance) => { return data; }; - /** - * Remove critter from survey. This will remove the critter from the list of critters associated with this survey. - * - * @param {number} projectId - * @param {number} surveyId - * @param {number} critterId - * @return {*} {Promise} - */ const removeCritterFromSurvey = async (projectId: number, surveyId: number, critterId: number): Promise => { const { data } = await axios.delete(`/api/project/${projectId}/survey/${surveyId}/critters/${critterId}`); return data; }; - /** - * Deploy a device to a critter. This will add a device to the list of devices associated with a critter in BCTW. - * - * @param {number} projectId - * @param {number} surveyId - * @param {number} critterId - * @param {(IAnimalTelemetryDevice & { critter_id: string })} body - * @return {*} {Promise} - */ const addDeployment = async ( projectId: number, surveyId: number, @@ -490,20 +478,31 @@ const useSurveyApi = (axios: AxiosInstance) => { ): Promise => { body.device_id = Number(body.device_id); //Turn this into validation class soon body.frequency = Number(body.frequency); + body.frequency_unit = body.frequency_unit?.length ? body.frequency_unit : undefined; + if (!body.deployments || body.deployments.length !== 1) { + throw Error('Calling this with any amount other than 1 deployments currently unsupported.'); + } + const flattened = { ...body, ...body.deployments[0] }; const { data } = await axios.post( + `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, + flattened + ); + return data; + }; + + const updateDeployment = async ( + projectId: number, + surveyId: number, + critterId: number, + body: IDeploymentTimespan + ): Promise => { + const { data } = await axios.patch( `/api/project/${projectId}/survey/${surveyId}/critters/${critterId}/deployments`, body ); return data; }; - /** - * Retrieve a list of deployments associated with the given survey. - * - * @param {number} projectId - * @param {number} surveyId - * @return {*} {Promise} - */ const getDeploymentsInSurvey = async (projectId: number, surveyId: number): Promise => { const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/deployments`); return data; @@ -532,7 +531,8 @@ const useSurveyApi = (axios: AxiosInstance) => { createCritterAndAddToSurvey, removeCritterFromSurvey, addDeployment, - getDeploymentsInSurvey + getDeploymentsInSurvey, + updateDeployment }; }; diff --git a/app/src/hooks/telemetry/useDeviceApi.test.tsx b/app/src/hooks/telemetry/useDeviceApi.test.tsx index b03a90a9c0..7230a8b729 100644 --- a/app/src/hooks/telemetry/useDeviceApi.test.tsx +++ b/app/src/hooks/telemetry/useDeviceApi.test.tsx @@ -1,5 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { Device } from 'features/surveys/view/survey-animals/device'; import { useDeviceApi } from './useDeviceApi'; describe('useDeviceApi', () => { @@ -40,4 +41,11 @@ describe('useDeviceApi', () => { const result = await useDeviceApi(axios).getDeviceDetails(123); expect(result.deployments.length).toBe(0); }); + + it('should upsert a collar', async () => { + const device = new Device({ device_id: 123, collar_id: 'abc' }); + mock.onPost(`/api/telemetry/device`).reply(200, { device_id: 123, collar_id: 'abc' }); + const result = await useDeviceApi(axios).upsertCollar(device); + expect(result.device_id).toBe(123); + }); }); diff --git a/app/src/hooks/telemetry/useDeviceApi.tsx b/app/src/hooks/telemetry/useDeviceApi.tsx index d1186c5a57..45c4d44b7f 100644 --- a/app/src/hooks/telemetry/useDeviceApi.tsx +++ b/app/src/hooks/telemetry/useDeviceApi.tsx @@ -1,5 +1,5 @@ import { AxiosInstance } from 'axios'; -import { IAnimalDeployment } from 'features/surveys/view/survey-animals/animal'; +import { Device, IAnimalDeployment } from 'features/surveys/view/survey-animals/device'; interface ICodeResponse { code_header_title: string; @@ -74,10 +74,28 @@ const useDeviceApi = (axios: AxiosInstance) => { return { device: undefined, deployments: [] }; }; + /** + * Allows you to update a collar in bctw, invalidating the old record. + * @param {Device} body + * @returns {*} + */ + const upsertCollar = async (body: Device): Promise => { + try { + const { data } = await axios.post(`/api/telemetry/device`, body); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return {}; + }; + return { getDeviceDetails, getCollarVendors, - getCodeValues + getCodeValues, + upsertCollar }; }; diff --git a/app/src/utils/Utils.ts b/app/src/utils/Utils.ts index 3d3016c378..85e9840847 100644 --- a/app/src/utils/Utils.ts +++ b/app/src/utils/Utils.ts @@ -310,6 +310,14 @@ export const formatLabel = (str: string): string => { .join(' '); }; +export const datesSameNullable = (date1: string | undefined, date2: string | undefined): boolean => { + if (date1 == null && date2 == null) { + return true; + } else { + return moment(date1).isSame(moment(date2)); + } +}; + /** * Pluralizes a word. * From c62c52bea14c62817246bbc5a4d3ee3668e1a7e2 Mon Sep 17 00:00:00 2001 From: Al Rosenthal Date: Wed, 20 Sep 2023 16:56:38 -0700 Subject: [PATCH 9/9] SIMSBIOHUB-145: Survey Locations (#1092) * Migration to add new survey locations table * updated API/ Services to handle multiple 'locations' for a single survey * updated broken tests * refactored old code * Updated front end to pass an array of locations for a survey --- api/src/models/survey-create.test.ts | 38 +-- api/src/models/survey-create.ts | 14 +- api/src/models/survey-update.test.ts | 36 +-- api/src/models/survey-update.ts | 16 +- api/src/models/survey-view.test.ts | 29 ++- api/src/models/survey-view.ts | 23 +- .../project/{projectId}/survey/create.ts | 41 ++-- .../paths/project/{projectId}/survey/list.ts | 57 +++-- .../critters/{critterId}/deployments.ts | 1 - .../{projectId}/survey/{surveyId}/update.ts | 61 ++--- .../survey/{surveyId}/update/get.ts | 57 +++-- .../survey/{surveyId}/view.test.ts | 30 ++- .../{projectId}/survey/{surveyId}/view.ts | 67 ++++-- .../administrative-activity-repository.ts | 11 +- .../site-selection-strategy-repository.ts | 4 +- .../survey-location-repository.ts | 97 ++++++++ .../repositories/survey-repository.test.ts | 32 +-- api/src/repositories/survey-repository.ts | 108 +-------- api/src/services/eml-service.ts | 8 +- api/src/services/project-service.ts | 40 ++++ api/src/services/survey-location-service.ts | 61 +++++ api/src/services/survey-service.test.ts | 22 +- api/src/services/survey-service.ts | 57 +++-- api/src/zod-schema/json.ts | 1 + app/package-lock.json | 216 +++++++++--------- .../fields/MultiAutocompleteField.tsx | 89 +++++--- .../MultiAutocompleteFieldVariableSize.tsx | 5 +- app/src/components/fields/SingleDateField.tsx | 16 ++ .../components/fields/StartEndDateFields.tsx | 20 +- .../components/ProjectDetailsForm.tsx | 2 +- .../create/CreateProjectPage.test.tsx | 10 + app/src/features/surveys/CreateSurveyPage.tsx | 11 +- .../surveys/components/StudyAreaForm.test.tsx | 55 ++--- .../surveys/components/StudyAreaForm.tsx | 50 ++-- .../components/SurveySiteSelectionForm.tsx | 64 ++++-- .../surveys/components/SurveyStratumForm.tsx | 17 +- .../features/surveys/edit/EditSurveyForm.tsx | 6 +- .../view/components/SurveyStudyArea.test.tsx | 61 ++--- .../view/components/SurveyStudyArea.tsx | 52 +++-- app/src/interfaces/useSurveyApi.interface.ts | 26 ++- app/src/test-helpers/survey-helpers.ts | 13 +- .../20230802000000_new_funding_table.ts | 2 +- .../20230831111300_survey_locations.ts | 129 +++++++++++ .../seeds/03_basic_project_survey_setup.ts | 63 +++-- 44 files changed, 1175 insertions(+), 643 deletions(-) create mode 100644 api/src/repositories/survey-location-repository.ts create mode 100644 api/src/services/survey-location-service.ts create mode 100644 database/src/migrations/20230831111300_survey_locations.ts diff --git a/api/src/models/survey-create.test.ts b/api/src/models/survey-create.test.ts index a51f56c6b1..6e98c35e4d 100644 --- a/api/src/models/survey-create.test.ts +++ b/api/src/models/survey-create.test.ts @@ -40,8 +40,8 @@ describe('PostSurveyObject', () => { expect(data.purpose_and_methodology).to.equal(null); }); - it('sets location', () => { - expect(data.location).to.equal(null); + it('sets locations', () => { + expect(data.locations).to.eql([]); }); it('sets agreements', () => { @@ -62,7 +62,6 @@ describe('PostSurveyObject', () => { permit: {}, proprietor: {}, purpose_and_methodology: {}, - location: {}, agreements: {} }; @@ -90,10 +89,6 @@ describe('PostSurveyObject', () => { expect(data.purpose_and_methodology).to.instanceOf(PostPurposeAndMethodologyData); }); - it('sets location', () => { - expect(data.location).to.instanceOf(PostLocationData); - }); - it('sets agreements', () => { expect(data.agreements).to.instanceOf(PostAgreementsData); }); @@ -393,12 +388,16 @@ describe('PostLocationData', () => { data = new PostLocationData(null); }); - it('sets survey_area_name', () => { - expect(data.survey_area_name).to.equal(null); + it('sets name', () => { + expect(data.name).to.equal(null); + }); + + it('sets description', () => { + expect(data.description).to.equal(null); }); - it('sets geometry', () => { - expect(data.geometry).to.eql([]); + it('sets geojson', () => { + expect(data.geojson).to.eql([]); }); }); @@ -406,20 +405,25 @@ describe('PostLocationData', () => { let data: PostLocationData; const obj = { - survey_area_name: 'area_name', - geometry: [{}] + name: 'area name', + description: 'area description', + geojson: [{}] }; before(() => { data = new PostLocationData(obj); }); - it('sets survey_area_name', () => { - expect(data.survey_area_name).to.equal(obj.survey_area_name); + it('sets name', () => { + expect(data.name).to.equal(obj.name); + }); + + it('sets description', () => { + expect(data.description).to.equal(obj.description); }); - it('sets geometry', () => { - expect(data.geometry).to.eql(obj.geometry); + it('sets geojson', () => { + expect(data.geojson).to.eql(obj.geojson); }); }); }); diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index fbbc381c2c..fd6359911d 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -9,7 +9,7 @@ export class PostSurveyObject { funding_sources: PostFundingSourceData[]; proprietor: PostProprietorData; purpose_and_methodology: PostPurposeAndMethodologyData; - location: PostLocationData; + locations: PostLocationData[]; agreements: PostAgreementsData; participants: PostParticipationData[]; partnerships: PostPartnershipsData; @@ -25,11 +25,11 @@ export class PostSurveyObject { this.proprietor = (obj?.proprietor && new PostProprietorData(obj.proprietor)) || null; this.purpose_and_methodology = (obj?.purpose_and_methodology && new PostPurposeAndMethodologyData(obj.purpose_and_methodology)) || null; - this.location = (obj?.location && new PostLocationData(obj.location)) || null; this.agreements = (obj?.agreements && new PostAgreementsData(obj.agreements)) || null; this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PostParticipationData(p))) || []; this.partnerships = (obj?.partnerships && new PostPartnershipsData(obj.partnerships)) || null; + this.locations = (obj?.locations && obj.locations.map((p: any) => new PostLocationData(p))) || []; this.site_selection = (obj?.site_selection && new PostSiteSelectionData(obj)) || null; this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } @@ -125,12 +125,14 @@ export class PostProprietorData { } export class PostLocationData { - survey_area_name: string; - geometry: Feature[]; + name: string; + description: string; + geojson: Feature[]; constructor(obj?: any) { - this.survey_area_name = obj?.survey_area_name || null; - this.geometry = (obj?.geometry?.length && obj.geometry) || []; + this.name = obj?.name || null; + this.description = obj?.description || null; + this.geojson = (obj?.geojson?.length && obj.geojson) || []; } } diff --git a/api/src/models/survey-update.test.ts b/api/src/models/survey-update.test.ts index 4d3d1d5d6e..1195366e35 100644 --- a/api/src/models/survey-update.test.ts +++ b/api/src/models/survey-update.test.ts @@ -40,7 +40,7 @@ describe('PutSurveyObject', () => { }); it('sets location', () => { - expect(data.location).to.equal(null); + expect(data.locations).to.eql([]); }); }); @@ -53,7 +53,6 @@ describe('PutSurveyObject', () => { permit: {}, proprietor: {}, purpose_and_methodology: {}, - location: {}, agreements: {} }; @@ -80,10 +79,6 @@ describe('PutSurveyObject', () => { it('sets purpose_and_methodology', () => { expect(data.purpose_and_methodology).to.instanceOf(PutSurveyPurposeAndMethodologyData); }); - - it('sets location', () => { - expect(data.location).to.instanceOf(PutSurveyLocationData); - }); }); }); @@ -439,12 +434,16 @@ describe('PutLocationData', () => { data = new PutSurveyLocationData(null); }); - it('sets survey_area_name', () => { - expect(data.survey_area_name).to.equal(null); + it('sets name', () => { + expect(data.name).to.equal(null); + }); + + it('sets description', () => { + expect(data.description).to.equal(null); }); - it('sets geometry', () => { - expect(data.geometry).to.eql([]); + it('sets geojson', () => { + expect(data.geojson).to.eql([]); }); it('sets revision_count', () => { @@ -456,8 +455,9 @@ describe('PutLocationData', () => { let data: PutSurveyLocationData; const obj = { - survey_area_name: 'area_name', - geometry: [{}], + name: 'area name', + description: 'area description', + geojson: [{}], revision_count: 0 }; @@ -465,12 +465,16 @@ describe('PutLocationData', () => { data = new PutSurveyLocationData(obj); }); - it('sets survey_area_name', () => { - expect(data.survey_area_name).to.equal(obj.survey_area_name); + it('sets name', () => { + expect(data.name).to.equal(obj.name); + }); + + it('sets description', () => { + expect(data.description).to.equal(obj.description); }); - it('sets geometry', () => { - expect(data.geometry).to.eql(obj.geometry); + it('sets geojson', () => { + expect(data.geojson).to.eql(obj.geojson); }); it('sets revision_count', () => { diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index 255a5cf08d..c57501bc2c 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -9,7 +9,7 @@ export class PutSurveyObject { funding_sources: PutFundingSourceData[]; proprietor: PutSurveyProprietorData; purpose_and_methodology: PutSurveyPurposeAndMethodologyData; - location: PutSurveyLocationData; + locations: PutSurveyLocationData[]; participants: PutSurveyParticipantsData[]; partnerships: PutPartnershipsData; site_selection: PutSiteSelectionData; @@ -24,9 +24,9 @@ export class PutSurveyObject { this.proprietor = (obj?.proprietor && new PutSurveyProprietorData(obj.proprietor)) || null; this.purpose_and_methodology = (obj?.purpose_and_methodology && new PutSurveyPurposeAndMethodologyData(obj.purpose_and_methodology)) || null; - this.location = (obj?.location && new PutSurveyLocationData(obj.location)) || null; this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PutSurveyParticipantsData(p))) || []; + this.locations = (obj?.locations && obj.locations.map((p: any) => new PutSurveyLocationData(p))) || []; this.partnerships = (obj?.partnerships && new PutPartnershipsData(obj.partnerships)) || null; this.site_selection = (obj?.site_selection && new PutSiteSelectionData(obj)) || null; this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; @@ -155,13 +155,17 @@ export class PutSurveyPurposeAndMethodologyData { } export class PutSurveyLocationData { - survey_area_name: string; - geometry: Feature[]; + survey_location_id: number; + name: string; + description: string; + geojson: Feature[]; revision_count: number; constructor(obj?: any) { - this.survey_area_name = obj?.survey_area_name || null; - this.geometry = (obj?.geometry?.length && obj.geometry) || []; + this.survey_location_id = obj?.survey_location_id || null; + this.name = obj?.name || null; + this.description = obj?.description || null; + this.geojson = (obj?.geojson?.length && obj.geojson) || []; this.revision_count = obj?.revision_count ?? null; } } diff --git a/api/src/models/survey-view.test.ts b/api/src/models/survey-view.test.ts index 573d2cc730..ef989dbfaa 100644 --- a/api/src/models/survey-view.test.ts +++ b/api/src/models/survey-view.test.ts @@ -306,12 +306,16 @@ describe('GetSurveyLocationData', () => { data = new GetSurveyLocationData(null); }); - it('sets survey_area_name', () => { - expect(data.survey_area_name).to.equal(''); + it('sets name', () => { + expect(data.name).to.equal(null); }); - it('sets geometry', () => { - expect(data.geometry).to.eql([]); + it('sets description', () => { + expect(data.description).to.equal(null); + }); + + it('sets geojson', () => { + expect(data.geojson).to.eql([]); }); }); @@ -319,20 +323,25 @@ describe('GetSurveyLocationData', () => { let data: GetSurveyLocationData; const obj = { - location_name: 'area_name', - geojson: [{}] + name: 'area name', + description: 'area description', + geojson: [] }; before(() => { data = new GetSurveyLocationData(obj); }); - it('sets survey_area_name', () => { - expect(data.survey_area_name).to.equal(obj.location_name); + it('sets name', () => { + expect(data.name).to.equal(obj.name); }); - it('sets geometry', () => { - expect(data.geometry).to.eql(obj.geojson); + it('sets description', () => { + expect(data.description).to.equal(obj.description); + }); + + it('sets geojson', () => { + expect(data.geojson).to.eql(obj.geojson); }); }); }); diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index d0aab6ac01..655eb23b99 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -3,6 +3,7 @@ import { SurveyMetadataPublish } from '../repositories/history-publish-repositor import { IPermitModel } from '../repositories/permit-repository'; import { SiteSelectionData } from '../repositories/site-selection-strategy-repository'; import { SurveyBlockRecord } from '../repositories/survey-block-repository'; +import { SurveyLocationRecord } from '../repositories/survey-location-repository'; import { SurveyUser } from '../repositories/survey-participation-repository'; export type SurveyObject = { @@ -12,7 +13,7 @@ export type SurveyObject = { funding_sources: GetSurveyFundingSourceData[]; purpose_and_methodology: GetSurveyPurposeAndMethodologyData; proprietor: GetSurveyProprietorData | null; - location: GetSurveyLocationData; + locations: SurveyLocationRecord[]; participants: SurveyUser[]; partnerships: ISurveyPartnerships; site_selection: SiteSelectionData; @@ -33,22 +34,22 @@ export class GetSurveyData { end_date: string; biologist_first_name: string; biologist_last_name: string; - survey_area_name: string; - geometry: Feature[]; survey_types: number[]; revision_count: number; + geometry: Feature[]; constructor(obj?: any) { this.id = obj?.survey_id || null; this.project_id = obj?.project_id || null; this.uuid = obj?.uuid || null; this.survey_name = obj?.name || ''; + this.start_date = obj?.start_date || null; + this.end_date = obj?.end_date || null; this.start_date = String(obj?.start_date) || ''; this.end_date = String(obj?.end_date) || ''; this.geometry = (obj?.geojson?.length && obj.geojson) || []; this.biologist_first_name = obj?.lead_first_name || ''; this.biologist_last_name = obj?.lead_last_name || ''; - this.survey_area_name = obj?.location_name || ''; this.survey_types = (obj?.survey_types?.length && obj.survey_types) || []; this.revision_count = obj?.revision_count || 0; } @@ -169,12 +170,18 @@ export type SurveySupplementaryData = { }; export class GetSurveyLocationData { - survey_area_name: string; - geometry: Feature[]; + survey_spatial_component_id: number; + name: string; + description: string; + geojson: Feature[]; + revision_count: number; constructor(obj?: any) { - this.survey_area_name = obj?.location_name || ''; - this.geometry = (obj?.geojson?.length && obj.geojson) || []; + this.survey_spatial_component_id = obj?.survey_spatial_component_id || null; + this.name = obj?.name || null; + this.description = obj?.description || null; + this.geojson = (obj?.geojson?.length && obj.geojson) || []; + this.revision_count = obj?.revision_count || 0; } } diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index 71b8de12c5..bdf246153b 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -63,8 +63,8 @@ POST.apiDoc = { 'partnerships', 'proprietor', 'purpose_and_methodology', + 'locations', 'site_selection', - 'location', 'agreements', 'participants' ], @@ -220,6 +220,30 @@ POST.apiDoc = { } } }, + locations: { + description: 'Survey location data', + type: 'array', + items: { + type: 'object', + required: ['name', 'description', 'geojson'], + properties: { + name: { + type: 'string', + maxLength: 100 + }, + description: { + type: 'string', + maxLength: 250 + }, + geojson: { + type: 'array', + items: { + ...(GeoJSONFeature as object) + } + } + } + } + }, site_selection: { type: 'object', required: ['strategies', 'stratums'], @@ -247,20 +271,6 @@ POST.apiDoc = { } } }, - location: { - type: 'object', - properties: { - survey_area_name: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) - } - } - } - }, participants: { type: 'array', items: { @@ -345,7 +355,6 @@ export function createSurvey(): RequestHandler { await connection.open(); const surveyService = new SurveyService(connection); - const surveyId = await surveyService.createSurveyAndUploadMetadataToBioHub(projectId, sanitizedPostSurveyData); await connection.commit(); diff --git a/api/src/paths/project/{projectId}/survey/list.ts b/api/src/paths/project/{projectId}/survey/list.ts index 852b6a4c6c..0b5570bab6 100644 --- a/api/src/paths/project/{projectId}/survey/list.ts +++ b/api/src/paths/project/{projectId}/survey/list.ts @@ -71,7 +71,7 @@ GET.apiDoc = { 'permit', 'proprietor', 'purpose_and_methodology', - 'location' + 'locations' ], properties: { survey_details: { @@ -253,18 +253,49 @@ GET.apiDoc = { } } }, - location: { - description: 'Survey location Details', - type: 'object', - required: ['survey_area_name', 'geometry'], - properties: { - survey_area_name: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) + locations: { + description: 'Survey location data', + type: 'array', + items: { + type: 'object', + required: [ + 'survey_location_id', + 'name', + 'description', + 'geometry', + 'geography', + 'geojson', + 'revision_count' + ], + properties: { + survey_location_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 100 + }, + description: { + type: 'string', + maxLength: 250 + }, + geometry: { + type: 'string', + nullable: true + }, + geography: { + type: 'string' + }, + geojson: { + type: 'array', + items: { + ...(GeoJSONFeature as object) + } + }, + revision_count: { + type: 'integer', + minimum: 0 } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts index 1ac709da11..68d825357b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments.ts @@ -243,7 +243,6 @@ export function deployDevice(): RequestHandler { return res.status(201).json(surveyEntry); } catch (error) { defaultLog.error({ label: 'addDeployment', message: 'error', error }); - console.log(JSON.stringify((error as Error).message)); await connection.rollback(); return res.status(500).json((error as AxiosError).response); } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts index 048fcf21f0..d5080e4985 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts @@ -64,17 +64,6 @@ PUT.apiDoc = { schema: { title: 'SurveyProject put request object', type: 'object', - required: [ - 'survey_details', - 'species', - 'permit', - 'funding_sources', - 'partnerships', - 'proprietor', - 'purpose_and_methodology', - 'site_selection', - 'location' - ], properties: { survey_details: { type: 'object', @@ -267,6 +256,38 @@ PUT.apiDoc = { } } }, + locations: { + description: 'Survey location data', + type: 'array', + items: { + type: 'object', + required: ['name', 'description', 'geojson'], + properties: { + survey_location_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 100 + }, + description: { + type: 'string', + maxLength: 250 + }, + geojson: { + type: 'array', + items: { + ...(GeoJSONFeature as object) + } + }, + revision_count: { + type: 'integer', + minimum: 0 + } + } + } + }, site_selection: { type: 'object', required: ['strategies', 'stratums'], @@ -294,24 +315,6 @@ PUT.apiDoc = { } } }, - location: { - type: 'object', - required: ['survey_area_name', 'geometry'], - properties: { - survey_area_name: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) - } - }, - revision_count: { - type: 'number' - } - } - }, participants: { type: 'array', items: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts index 6888f4f60f..30b006c57e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts @@ -79,7 +79,7 @@ GET.apiDoc = { 'funding_sources', 'proprietor', 'purpose_and_methodology', - 'location', + 'locations', 'participants' ], properties: { @@ -301,18 +301,49 @@ GET.apiDoc = { } } }, - location: { - description: 'Survey location Details', - type: 'object', - required: ['survey_area_name', 'geometry'], - properties: { - survey_area_name: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) + locations: { + description: 'Survey location data', + type: 'array', + items: { + type: 'object', + required: [ + 'survey_location_id', + 'name', + 'description', + 'geometry', + 'geography', + 'geojson', + 'revision_count' + ], + properties: { + survey_location_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 100 + }, + description: { + type: 'string', + maxLength: 250 + }, + geometry: { + type: 'string', + nullable: true + }, + geography: { + type: 'string' + }, + geojson: { + type: 'array', + items: { + ...(GeoJSONFeature as object) + } + }, + revision_count: { + type: 'integer', + minimum: 0 } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts index 4cedaa982e..a65e98bf68 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.test.ts @@ -62,13 +62,20 @@ describe('survey/{surveyId}/view', () => { indigenous_partnerships: [], stakeholder_partnerships: [] }, + locations: [ + { + survey_location_id: 1, + name: 'location name', + description: 'location description', + geometry: '', + geography: '', + geojson: [], + revision_count: 0 + } + ], site_selection: { strategies: ['strat1'], stratums: [{ name: 'startum1', description: 'desc' }] - }, - location: { - survey_area_name: 'location', - geometry: [] } }, surveySupplementaryData: { @@ -128,13 +135,20 @@ describe('survey/{surveyId}/view', () => { indigenous_partnerships: [], stakeholder_partnerships: [] }, + locations: [ + { + survey_location_id: 1, + name: 'location name', + description: 'location description', + geometry: null, + geography: '', + geojson: [], + revision_count: 0 + } + ], site_selection: { strategies: ['strat1'], stratums: [{ name: 'startum1', description: null }] - }, - location: { - survey_area_name: 'location', - geometry: [] } }, surveySupplementaryData: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts index 7f3957f817..de3a41b6d7 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts @@ -80,8 +80,8 @@ GET.apiDoc = { 'partnerships', 'proprietor', 'purpose_and_methodology', - 'site_selection', - 'location' + 'locations', + 'site_selection' ], properties: { survey_details: { @@ -314,6 +314,53 @@ GET.apiDoc = { } } }, + locations: { + description: 'Survey location data', + type: 'array', + items: { + type: 'object', + required: [ + 'survey_location_id', + 'name', + 'description', + 'geometry', + 'geography', + 'geojson', + 'revision_count' + ], + properties: { + survey_location_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 100 + }, + description: { + type: 'string', + maxLength: 250 + }, + geometry: { + type: 'string', + nullable: true + }, + geography: { + type: 'string' + }, + geojson: { + type: 'array', + items: { + ...(GeoJSONFeature as object) + } + }, + revision_count: { + type: 'integer', + minimum: 0 + } + } + } + }, site_selection: { type: 'object', required: ['strategies', 'stratums'], @@ -341,22 +388,6 @@ GET.apiDoc = { } } } - }, - location: { - description: 'Survey location Details', - type: 'object', - required: ['survey_area_name', 'geometry'], - properties: { - survey_area_name: { - type: 'string' - }, - geometry: { - type: 'array', - items: { - ...(GeoJSONFeature as object) - } - } - } } } }, diff --git a/api/src/repositories/administrative-activity-repository.ts b/api/src/repositories/administrative-activity-repository.ts index 7e20d6609a..5c4e47cd13 100644 --- a/api/src/repositories/administrative-activity-repository.ts +++ b/api/src/repositories/administrative-activity-repository.ts @@ -5,16 +5,9 @@ import { ADMINISTRATIVE_ACTIVITY_TYPE } from '../constants/administrative-activity'; import { ApiExecuteSQLError } from '../errors/api-error'; +import { jsonSchema } from '../zod-schema/json'; import { BaseRepository } from './base-repository'; -// Defines a Zod Schema for a valid JSON value -const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); -type Literal = z.infer; -type Json = Literal | { [key: string]: Json } | Json[]; -export const JsonSchema: z.ZodType = z.lazy(() => - z.union([literalSchema, z.array(JsonSchema), z.record(JsonSchema)]) -); - export const IAdministrativeActivityStanding = z.object({ has_pending_acccess_request: z.boolean(), has_one_or_more_project_roles: z.boolean() @@ -29,7 +22,7 @@ export const IAdministrativeActivity = z.object({ status: z.number(), status_name: z.string(), description: z.string().nullable(), - data: JsonSchema, + data: jsonSchema, notes: z.string().nullable(), create_date: z.string() }); diff --git a/api/src/repositories/site-selection-strategy-repository.ts b/api/src/repositories/site-selection-strategy-repository.ts index f45ddc3535..f0d5443105 100644 --- a/api/src/repositories/site-selection-strategy-repository.ts +++ b/api/src/repositories/site-selection-strategy-repository.ts @@ -41,7 +41,7 @@ const defaultLog = getLogger('repositories/site-selection-strategy-repository'); */ export class SiteSelectionStrategyRepository extends BaseRepository { /** - * Retreives the site selection strategies and stratums for the given survey + * Retrieves the site selection strategies and stratums for the given survey * * @param {number} surveyId * @return {*} {Promise} @@ -53,7 +53,7 @@ export class SiteSelectionStrategyRepository extends BaseRepository { const strategiesQuery = getKnex() .select('ss.name') .from('survey_site_strategy as sss') - .where('sss.survey_id', 1) + .where('sss.survey_id', surveyId) .leftJoin('site_strategy as ss', 'ss.site_strategy_id', 'sss.site_strategy_id'); const strategiesResponse = await this.connection.knex<{ name: string }>(strategiesQuery); diff --git a/api/src/repositories/survey-location-repository.ts b/api/src/repositories/survey-location-repository.ts new file mode 100644 index 0000000000..67616bb327 --- /dev/null +++ b/api/src/repositories/survey-location-repository.ts @@ -0,0 +1,97 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { PostLocationData } from '../models/survey-create'; +import { PutSurveyLocationData } from '../models/survey-update'; +import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; +import { jsonSchema } from '../zod-schema/json'; +import { BaseRepository } from './base-repository'; + +export const SurveyLocationRecord = z.object({ + survey_location_id: z.number(), + name: z.string(), + description: z.string(), + geometry: z.record(z.any()).nullable(), + geography: z.string(), + geojson: jsonSchema, + revision_count: z.number() +}); + +export type SurveyLocationRecord = z.infer; +export class SurveyLocationRepository extends BaseRepository { + /** + * Creates a survey location for a given survey + * + * @param {number} surveyId + * @param {PostLocationData} data + * @memberof SurveyLocationRepository + */ + async insertSurveyLocation(surveyId: number, data: PostLocationData): Promise { + const sqlStatement = SQL` + INSERT INTO survey_location ( + survey_id, + name, + description, + geojson, + geography + ) + VALUES ( + ${surveyId}, + ${data.name}, + ${data.description}, + ${JSON.stringify(data.geojson)}, + public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(data.geojson)).append(`, 4326) + ) + ) + );`); + await this.connection.sql(sqlStatement); + } + + /** + * Updates survey location data + * + * @param {PutSurveyLocationData} data + * @memberof SurveyLocationRepository + */ + async updateSurveyLocation(data: PutSurveyLocationData): Promise { + const sqlStatement = SQL` + UPDATE + survey_location + SET + name = ${data.name}, + description = ${data.description}, + geojson = ${JSON.stringify(data.geojson)}, + geography = public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(data.geojson)).append(`, 4326) + ) + ) + WHERE + survey_location_id = ${data.survey_location_id}; + `); + + await this.connection.sql(sqlStatement); + } + + /** + * Get Survey location for a given survey ID + * + * @param {number} surveyId + * @returns {*} Promise + * @memberof SurveyLocationRepository + */ + async getSurveyLocationsData(surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + * + FROM + survey_location + WHERE + survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, SurveyLocationRecord); + return response.rows; + } +} diff --git a/api/src/repositories/survey-repository.test.ts b/api/src/repositories/survey-repository.test.ts index fa716a5efa..4f4850b4d0 100644 --- a/api/src/repositories/survey-repository.test.ts +++ b/api/src/repositories/survey-repository.test.ts @@ -6,12 +6,7 @@ import sinonChai from 'sinon-chai'; import { GetReportAttachmentsData } from '../models/project-view'; import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; import { PutSurveyObject } from '../models/survey-update'; -import { - GetAttachmentsData, - GetSurveyLocationData, - GetSurveyProprietorData, - GetSurveyPurposeAndMethodologyData -} from '../models/survey-view'; +import { GetAttachmentsData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData } from '../models/survey-view'; import { getMockDBConnection } from '../__mocks__/db'; import { IObservationSubmissionInsertDetails, @@ -198,19 +193,6 @@ describe('SurveyRepository', () => { }); }); - describe('getSurveyLocationData', () => { - it('should return result', async () => { - const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; - const dbConnection = getMockDBConnection({ sql: () => mockResponse }); - - const repository = new SurveyRepository(dbConnection); - - const response = await repository.getSurveyLocationData(1); - - expect(response).to.eql(new GetSurveyLocationData({ id: 1 })); - }); - }); - describe('getStakeholderPartnershipsBySurveyId', () => { it('should return stakeholder partnerships', async () => { const mockResponse = ({ rows: [{ id: 1 }], rowCount: 1 } as any) as Promise>; @@ -492,7 +474,7 @@ describe('SurveyRepository', () => { intended_outcome_id: 1, surveyed_all_areas: 'Y' }, - location: { geometry: [{ id: 1 }] } + locations: [{ geometry: [{ id: 1 }] }] } as unknown) as PostSurveyObject; const response = await repository.insertSurveyData(1, input); @@ -522,7 +504,7 @@ describe('SurveyRepository', () => { intended_outcome_id: 1, surveyed_all_areas: 'Y' }, - location: { geometry: [] } + locations: [{ geometry: [] }] } as unknown) as PostSurveyObject; const response = await repository.insertSurveyData(1, input); @@ -552,7 +534,7 @@ describe('SurveyRepository', () => { intended_outcome_id: 1, surveyed_all_areas: 'Y' }, - location: { geometry: [{ id: 1 }] } + locations: [{ geometry: [{ id: 1 }] }] } as unknown) as PostSurveyObject; try { @@ -881,7 +863,7 @@ describe('SurveyRepository', () => { surveyed_all_areas: 'Y', revision_count: 1 }, - location: { geometry: [{ id: 1 }] } + locations: [{ geometry: [{ id: 1 }] }] } as unknown) as PutSurveyObject; const response = await repository.updateSurveyDetailsData(1, input); @@ -912,7 +894,7 @@ describe('SurveyRepository', () => { surveyed_all_areas: 'Y', revision_count: 1 }, - location: { geometry: [] } + locations: [{ geometry: [] }] } as unknown) as PutSurveyObject; const response = await repository.updateSurveyDetailsData(1, input); @@ -943,7 +925,7 @@ describe('SurveyRepository', () => { surveyed_all_areas: 'Y', revision_count: 1 }, - location: { geometry: [] } + locations: [{ geometry: [] }] } as unknown) as PutSurveyObject; try { diff --git a/api/src/repositories/survey-repository.ts b/api/src/repositories/survey-repository.ts index df47547bbb..b1b1d971b0 100644 --- a/api/src/repositories/survey-repository.ts +++ b/api/src/repositories/survey-repository.ts @@ -8,12 +8,10 @@ import { PutSurveyObject } from '../models/survey-update'; import { GetAttachmentsData, GetReportAttachmentsData, - GetSurveyLocationData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData } from '../models/survey-view'; import { getLogger } from '../utils/logger'; -import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; import { BaseRepository } from './base-repository'; export interface IGetSpeciesData { @@ -87,11 +85,6 @@ const SurveyRecord = z.object({ additional_details: z.string().nullable(), ecological_season_id: z.number().nullable(), intended_outcome_id: z.number().nullable(), - location_name: z.string(), - location_description: z.string().nullable(), - geometry: z.any().nullable(), - geography: z.any().nullable(), - geojson: z.any().nullable(), comments: z.string().nullable(), create_date: z.string(), create_user: z.number(), @@ -347,30 +340,6 @@ export class SurveyRepository extends BaseRepository { return new GetSurveyProprietorData(result); } - /** - * Get Survey location for a given survey ID - * - * @param {number} surveyId - * @returns {*} Promise - * @memberof SurveyRepository - */ - async getSurveyLocationData(surveyId: number): Promise { - const sqlStatement = SQL` - SELECT - * - FROM - survey - WHERE - survey_id = ${surveyId}; - `; - - const response = await this.connection.sql(sqlStatement); - - const result = response.rows?.[0]; - - return new GetSurveyLocationData(result); - } - /** * Get Occurrence submission for a given survey id. * @@ -619,10 +588,7 @@ export class SurveyRepository extends BaseRepository { field_method_id, additional_details, ecological_season_id, - intended_outcome_id, - location_name, - geojson, - geography + intended_outcome_id ) VALUES ( ${projectId}, ${surveyData.survey_details.survey_name}, @@ -633,39 +599,13 @@ export class SurveyRepository extends BaseRepository { ${surveyData.purpose_and_methodology.field_method_id}, ${surveyData.purpose_and_methodology.additional_details}, ${surveyData.purpose_and_methodology.ecological_season_id}, - ${surveyData.purpose_and_methodology.intended_outcome_id}, - ${surveyData.location.survey_area_name}, - ${JSON.stringify(surveyData.location.geometry)} - `; - - if (surveyData?.location?.geometry?.length) { - const geometryCollectionSQL = generateGeometryCollectionSQL(surveyData.location.geometry); - - sqlStatement.append(SQL` - ,public.geography( - public.ST_Force2D( - public.ST_SetSRID( - `); - - sqlStatement.append(geometryCollectionSQL); - - sqlStatement.append(SQL` - , 4326))) - `); - } else { - sqlStatement.append(SQL` - ,null - `); - } - - sqlStatement.append(SQL` + ${surveyData.purpose_and_methodology.intended_outcome_id} ) RETURNING survey_id as id; - `); + `; const response = await this.connection.sql(sqlStatement); - const result = response.rows?.[0]; if (!result) { @@ -953,8 +893,7 @@ export class SurveyRepository extends BaseRepository { start_date: surveyData.survey_details.start_date, end_date: surveyData.survey_details.end_date, lead_first_name: surveyData.survey_details.lead_first_name, - lead_last_name: surveyData.survey_details.lead_last_name, - revision_count: surveyData.survey_details.revision_count + lead_last_name: surveyData.survey_details.lead_last_name }; } @@ -964,43 +903,14 @@ export class SurveyRepository extends BaseRepository { field_method_id: surveyData.purpose_and_methodology.field_method_id, additional_details: surveyData.purpose_and_methodology.additional_details, ecological_season_id: surveyData.purpose_and_methodology.ecological_season_id, - intended_outcome_id: surveyData.purpose_and_methodology.intended_outcome_id, - revision_count: surveyData.purpose_and_methodology.revision_count - }; - } - - if (surveyData.location) { - const geometrySqlStatement = SQL``; - - if (surveyData?.location?.geometry?.length) { - geometrySqlStatement.append(SQL` - public.geography( - public.ST_Force2D( - public.ST_SetSRID( - `); - - const geometryCollectionSQL = generateGeometryCollectionSQL(surveyData.location.geometry); - geometrySqlStatement.append(geometryCollectionSQL); - - geometrySqlStatement.append(SQL` - , 4326))) - `); - } else { - geometrySqlStatement.append(SQL` - null - `); - } - - fieldsToUpdate = { - ...fieldsToUpdate, - location_name: surveyData.location.survey_area_name, - geojson: JSON.stringify(surveyData.location.geometry), - geography: knex.raw(geometrySqlStatement.sql, geometrySqlStatement.values), - revision_count: surveyData.location.revision_count + intended_outcome_id: surveyData.purpose_and_methodology.intended_outcome_id }; } - const updateSurveyQueryBuilder = knex('survey').update(fieldsToUpdate).where('survey_id', surveyId); + const updateSurveyQueryBuilder = knex('survey') + .update(fieldsToUpdate) + .where('survey_id', surveyId) + .andWhere('revision_count', surveyData.survey_details.revision_count); const result = await this.connection.knex(updateSurveyQueryBuilder); diff --git a/api/src/services/eml-service.ts b/api/src/services/eml-service.ts index a7ec97b5ec..813287244b 100644 --- a/api/src/services/eml-service.ts +++ b/api/src/services/eml-service.ts @@ -908,17 +908,19 @@ export class EmlService extends DBService { * @memberof EmlService */ _getSurveyGeographicCoverage(surveyData: SurveyObject): Record { - if (!surveyData.location.geometry?.length) { + if (!surveyData.locations[0]?.geometry?.length) { return {}; } - const polygonFeatures = this._makePolygonFeatures(surveyData.location.geometry); + const polygonFeatures = this._makePolygonFeatures( + surveyData.locations[0].geometry as Feature[] + ); const datasetGPolygons = this._makeDatasetGPolygons(polygonFeatures); const surveyBoundingBox = bbox(featureCollection(polygonFeatures)); return { geographicCoverage: { - geographicDescription: surveyData.location.survey_area_name, + geographicDescription: surveyData.locations[0].name, boundingCoordinates: { westBoundingCoordinate: surveyBoundingBox[0], eastBoundingCoordinate: surveyBoundingBox[2], diff --git a/api/src/services/project-service.ts b/api/src/services/project-service.ts index dcc70fdbe8..36a6f5e8c2 100644 --- a/api/src/services/project-service.ts +++ b/api/src/services/project-service.ts @@ -269,23 +269,63 @@ export class ProjectService extends DBService { return projectId; } + /** + * Insert project data. + * + * @param {PostProjectObject} postProjectData + * @return {*} {Promise} + * @memberof ProjectService + */ async insertProject(postProjectData: PostProjectObject): Promise { return this.projectRepository.insertProject(postProjectData); } + /** + * Insert IUCN classification data. + * + * @param {number} iucn3_id + * @param {number} project_id + * @return {*} {Promise} + * @memberof ProjectService + */ async insertClassificationDetail(iucn3_id: number, project_id: number): Promise { return this.projectRepository.insertClassificationDetail(iucn3_id, project_id); } + /** + * Insert participation data. + * + * @param {number} projectId + * @param {number} systemUserId + * @param {string} projectParticipantRole + * @return {*} {Promise} + * @memberof ProjectService + */ async postProjectParticipant(projectId: number, systemUserId: number, projectParticipantRole: string): Promise { return this.projectParticipationService.postProjectParticipant(projectId, systemUserId, projectParticipantRole); } + /** + * Insert region data. + * + * @param {number} projectId + * @param {Feature[]} features + * @return {*} {Promise} + * @memberof ProjectService + */ async insertRegion(projectId: number, features: Feature[]): Promise { const regionService = new RegionService(this.connection); return regionService.addRegionsToProjectFromFeatures(projectId, features); } + /** + * Insert programs data. + * + * @param {number} projectId + * @param {number[]} projectPrograms + * @return {*} {Promise} + * @memberof ProjectService + */ async insertPrograms(projectId: number, projectPrograms: number[]): Promise { await this.projectRepository.deletePrograms(projectId); await this.projectRepository.insertProgram(projectId, projectPrograms); diff --git a/api/src/services/survey-location-service.ts b/api/src/services/survey-location-service.ts new file mode 100644 index 0000000000..296794a042 --- /dev/null +++ b/api/src/services/survey-location-service.ts @@ -0,0 +1,61 @@ +import { IDBConnection } from '../database/db'; +import { PostLocationData } from '../models/survey-create'; +import { PutSurveyLocationData } from '../models/survey-update'; +import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; +import { DBService } from './db-service'; + +/** + * Service for reading/writing survey location data. + * + * @export + * @class SurveyLocationService + * @extends {DBService} + */ +export class SurveyLocationService extends DBService { + surveyLocationRepository: SurveyLocationRepository; + + /** + * Creates an instance of SurveyLocationService. + * + * @param {IDBConnection} connection + * @memberof SurveyLocationService + */ + constructor(connection: IDBConnection) { + super(connection); + + this.surveyLocationRepository = new SurveyLocationRepository(connection); + } + + /** + * Insert a new survey location record. + * + * @param {number} surveyId + * @param {PostLocationData} data + * @return {*} {Promise} + * @memberof SurveyLocationService + */ + async insertSurveyLocation(surveyId: number, data: PostLocationData): Promise { + return this.surveyLocationRepository.insertSurveyLocation(surveyId, data); + } + + /** + * Update an existing survey location record. + * + * @param {PutSurveyLocationData} data + * @return {*} {Promise} + * @memberof SurveyLocationService + */ + async updateSurveyLocation(data: PutSurveyLocationData): Promise { + return this.surveyLocationRepository.updateSurveyLocation(data); + } + /** + * Get survey location records for a given survey ID + * + * @param {number} surveyID + * @returns {*} {Promise} + * @memberof SurveyLocationService + */ + async getSurveyLocationsData(surveyId: number): Promise { + return this.surveyLocationRepository.getSurveyLocationsData(surveyId); + } +} diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index ba842d423a..c076835879 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -13,7 +13,6 @@ import { GetAttachmentsData, GetFocalSpeciesData, GetSurveyData, - GetSurveyLocationData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData, SurveyObject @@ -21,6 +20,7 @@ import { import { FundingSourceRepository } from '../repositories/funding-source-repository'; import { PublishStatus } from '../repositories/history-publish-repository'; import { IPermitModel } from '../repositories/permit-repository'; +import { SurveyLocationRecord, SurveyLocationRepository } from '../repositories/survey-location-repository'; import { IGetLatestSurveyOccurrenceSubmission, IGetSpeciesData, @@ -74,9 +74,7 @@ describe('SurveyService', () => { const getSurveyProprietorDataForViewStub = sinon .stub(SurveyService.prototype, 'getSurveyProprietorDataForView') .resolves(({ data: 'proprietorData' } as unknown) as any); - const getSurveyLocationDataStub = sinon - .stub(SurveyService.prototype, 'getSurveyLocationData') - .resolves(({ data: 'locationData' } as unknown) as any); + const getSurveyLocationsDataStub = sinon.stub(SurveyService.prototype, 'getSurveyLocationsData').resolves([]); const getSurveyParticipantsStub = sinon .stub(SurveyParticipationService.prototype, 'getSurveyParticipants') .resolves([{ data: 'participantData' } as any]); @@ -98,7 +96,7 @@ describe('SurveyService', () => { expect(getSurveyFundingSourceDataStub).to.be.calledOnce; expect(getSurveyPurposeAndMethodologyStub).to.be.calledOnce; expect(getSurveyProprietorDataForViewStub).to.be.calledOnce; - expect(getSurveyLocationDataStub).to.be.calledOnce; + expect(getSurveyLocationsDataStub).to.be.calledOnce; expect(getSurveyParticipantsStub).to.be.calledOnce; expect(getSurveyPartnershipsDataStub).to.be.calledOnce; expect(getSurveyBlockStub).to.be.calledOnce; @@ -116,7 +114,7 @@ describe('SurveyService', () => { stakeholder_partnerships: [] }, participants: [{ data: 'participantData' } as any], - location: { data: 'locationData' }, + locations: [], site_selection: { stratums: [], strategies: [] }, blocks: [] }); @@ -186,7 +184,6 @@ describe('SurveyService', () => { const updateSurveyProprietorDataStub = sinon .stub(SurveyService.prototype, 'updateSurveyProprietorData') .resolves(); - const updateSurveyRegionStub = sinon.stub(SurveyService.prototype, 'insertRegion').resolves(); const upsertSurveyParticipantDataStub = sinon .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); @@ -208,9 +205,9 @@ describe('SurveyService', () => { funding_sources: [{}], proprietor: {}, purpose_and_methodology: {}, - location: {}, - site_selection: { stratums: [], strategies: [] }, + locations: [], participants: [{}], + site_selection: { stratums: [], strategies: [] }, blocks: [{}] }); @@ -223,7 +220,6 @@ describe('SurveyService', () => { expect(updateSurveyPermitDataStub).to.have.been.calledOnce; expect(upsertSurveyFundingSourceDataStub).to.have.been.calledOnce; expect(updateSurveyProprietorDataStub).to.have.been.calledOnce; - expect(updateSurveyRegionStub).to.have.been.calledOnce; expect(upsertSurveyParticipantDataStub).to.have.been.calledOnce; expect(upsertBlocks).to.have.been.calledOnce; expect(replaceSurveyStratumsStub).to.have.been.calledOnce; @@ -458,11 +454,11 @@ describe('SurveyService', () => { const dbConnection = getMockDBConnection(); const service = new SurveyService(dbConnection); - const data = new GetSurveyLocationData([{ id: 1 }]); + const data = ([{ survey_location_id: 1 }] as any) as SurveyLocationRecord[]; - const repoStub = sinon.stub(SurveyRepository.prototype, 'getSurveyLocationData').resolves(data); + const repoStub = sinon.stub(SurveyLocationRepository.prototype, 'getSurveyLocationsData').resolves(data); - const response = await service.getSurveyLocationData(1); + const response = await service.getSurveyLocationsData(1); expect(repoStub).to.be.calledOnce; expect(response).to.eql(data); diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index c780ac01a9..b7b7c3d5f3 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -1,8 +1,8 @@ import { Feature } from 'geojson'; import { MESSAGE_CLASS_NAME, SUBMISSION_MESSAGE_TYPE, SUBMISSION_STATUS_TYPE } from '../constants/status'; import { IDBConnection } from '../database/db'; -import { PostProprietorData, PostSurveyObject } from '../models/survey-create'; -import { PutPartnershipsData, PutSurveyObject } from '../models/survey-update'; +import { PostLocationData, PostProprietorData, PostSurveyObject } from '../models/survey-create'; +import { PutPartnershipsData, PutSurveyLocationData, PutSurveyObject } from '../models/survey-update'; import { GetAncillarySpeciesData, GetAttachmentsData, @@ -11,7 +11,6 @@ import { GetReportAttachmentsData, GetSurveyData, GetSurveyFundingSourceData, - GetSurveyLocationData, GetSurveyProprietorData, GetSurveyPurposeAndMethodologyData, ISurveyPartnerships, @@ -21,6 +20,7 @@ import { import { AttachmentRepository } from '../repositories/attachment-repository'; import { PublishStatus } from '../repositories/history-publish-repository'; import { PostSurveyBlock, SurveyBlockRecord } from '../repositories/survey-block-repository'; +import { SurveyLocationRecord } from '../repositories/survey-location-repository'; import { IGetLatestSurveyOccurrenceSubmission, IObservationSubmissionInsertDetails, @@ -38,6 +38,7 @@ import { PlatformService } from './platform-service'; import { RegionService } from './region-service'; import { SiteSelectionStrategyService } from './site-selection-strategy-service'; import { SurveyBlockService } from './survey-block-service'; +import { SurveyLocationService } from './survey-location-service'; import { SurveyParticipationService } from './survey-participation-service'; import { TaxonomyService } from './taxonomy-service'; @@ -98,9 +99,9 @@ export class SurveyService extends DBService { partnerships: await this.getSurveyPartnershipsData(surveyId), purpose_and_methodology: await this.getSurveyPurposeAndMethodology(surveyId), proprietor: await this.getSurveyProprietorDataForView(surveyId), - location: await this.getSurveyLocationData(surveyId), - site_selection: await this.siteSelectionStrategyService.getSiteSelectionDataBySurveyId(surveyId), + locations: await this.getSurveyLocationsData(surveyId), participants: await this.surveyParticipationService.getSurveyParticipants(surveyId), + site_selection: await this.siteSelectionStrategyService.getSiteSelectionDataBySurveyId(surveyId), blocks: await this.getSurveyBlocksForSurveyId(surveyId) }; } @@ -226,11 +227,12 @@ export class SurveyService extends DBService { * Get Survey location for a given survey ID * * @param {number} surveyID - * @returns {*} {Promise} + * @returns {*} {Promise} * @memberof SurveyService */ - async getSurveyLocationData(surveyId: number): Promise { - return this.surveyRepository.getSurveyLocationData(surveyId); + async getSurveyLocationsData(surveyId: number): Promise { + const service = new SurveyLocationService(this.connection); + return service.getSurveyLocationsData(surveyId); } /** @@ -452,9 +454,8 @@ export class SurveyService extends DBService { ) ); - // Handle regions associated to a survey - if (postSurveyData.location) { - promises.push(this.insertRegion(surveyId, postSurveyData.location.geometry)); + if (postSurveyData.locations) { + promises.push(Promise.all(postSurveyData.locations.map((item) => this.insertSurveyLocations(surveyId, item)))); } // Handle site selection strategies @@ -485,6 +486,19 @@ export class SurveyService extends DBService { return surveyId; } + /** + * Inserts location data. + * + * @param {number} surveyId + * @param {PostLocationData} data + * @return {*} {Promise} + * @memberof SurveyService + */ + async insertSurveyLocations(surveyId: number, data: PostLocationData): Promise { + const service = new SurveyLocationService(this.connection); + return service.insertSurveyLocation(surveyId, data); + } + /** * Insert, updates and deletes Survey Blocks for a given survey id * @@ -498,6 +512,14 @@ export class SurveyService extends DBService { return service.upsertSurveyBlocks(surveyId, blocks); } + /** + * Insert region data. + * + * @param {number} projectId + * @param {Feature[]} features + * @return {*} {Promise} + * @memberof SurveyService + */ async insertRegion(projectId: number, features: Feature[]): Promise { const regionService = new RegionService(this.connection); return regionService.addRegionsToSurveyFromFeatures(projectId, features); @@ -661,8 +683,7 @@ export class SurveyService extends DBService { */ async updateSurvey(surveyId: number, putSurveyData: PutSurveyObject): Promise { const promises: Promise[] = []; - - if (putSurveyData?.survey_details || putSurveyData?.purpose_and_methodology || putSurveyData?.location) { + if (putSurveyData?.survey_details || putSurveyData?.purpose_and_methodology) { promises.push(this.updateSurveyDetailsData(surveyId, putSurveyData)); } @@ -689,13 +710,12 @@ export class SurveyService extends DBService { if (putSurveyData?.funding_sources) { promises.push(this.upsertSurveyFundingSourceData(surveyId, putSurveyData)); } - if (putSurveyData?.proprietor) { promises.push(this.updateSurveyProprietorData(surveyId, putSurveyData)); } - if (putSurveyData?.location) { - promises.push(this.insertRegion(surveyId, putSurveyData?.location.geometry)); + if (putSurveyData?.locations) { + promises.push(Promise.all(putSurveyData.locations.map((item) => this.updateSurveyLocation(item)))); } if (putSurveyData?.participants.length) { @@ -730,6 +750,11 @@ export class SurveyService extends DBService { await Promise.all(promises); } + async updateSurveyLocation(data: PutSurveyLocationData): Promise { + const surveyLocationService = new SurveyLocationService(this.connection); + return surveyLocationService.updateSurveyLocation(data); + } + /** * Updates Survey details * diff --git a/api/src/zod-schema/json.ts b/api/src/zod-schema/json.ts index 103dd25af1..4b101baa54 100644 --- a/api/src/zod-schema/json.ts +++ b/api/src/zod-schema/json.ts @@ -1,5 +1,6 @@ import * as z from 'zod'; +// Defines a Zod Schema for a valid JSON value const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); type Literal = z.infer; type Json = Literal | { [key: string]: Json } | Json[]; diff --git a/app/package-lock.json b/app/package-lock.json index cce60258f3..e9f6dad851 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -4478,7 +4478,7 @@ "amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==" + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=" }, "ansi-escapes": { "version": "4.3.2", @@ -4580,12 +4580,12 @@ "array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==" + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, "array-includes": { "version": "3.1.6", @@ -4699,7 +4699,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "ast-types-flow": { "version": "0.0.7", @@ -4716,12 +4716,12 @@ "async-foreach": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", - "integrity": "sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==" + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=" }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "at-least-node": { "version": "1.0.0", @@ -4757,7 +4757,7 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.12.0", @@ -5061,7 +5061,7 @@ "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", "requires": { "tweetnacl": "^0.14.3" } @@ -5093,7 +5093,7 @@ "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", - "integrity": "sha512-OorbnJVPII4DuUKbjARAe8u8EfqOmkEEaSFIyoQ7OjTHn6kafxWl0wLgoZ2rXaYd7MyLcDaU4TmhfxtwgcccMQ==", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", "requires": { "inherits": "~2.0.0" } @@ -5259,7 +5259,7 @@ "camelcase": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==" + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" }, "camelcase-css": { "version": "2.0.1", @@ -5270,7 +5270,7 @@ "camelcase-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", - "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", "requires": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" @@ -5302,7 +5302,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "chalk": { "version": "2.4.2", @@ -5442,7 +5442,7 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==" + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "collect-v8-coverage": { "version": "1.0.2", @@ -5461,7 +5461,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "colord": { "version": "2.9.3", @@ -5563,7 +5563,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "concaveman": { "version": "1.2.1", @@ -5591,7 +5591,7 @@ "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "content-disposition": { "version": "0.5.4", @@ -5619,7 +5619,7 @@ "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, "core-js": { "version": "3.31.1", @@ -6036,7 +6036,7 @@ "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", "requires": { "array-find-index": "^1.0.1" } @@ -6050,7 +6050,7 @@ "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", "requires": { "assert-plus": "^1.0.0" } @@ -6077,7 +6077,7 @@ "decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, "decimal.js": { "version": "10.4.3", @@ -6142,17 +6142,17 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, "dequal": { "version": "2.0.3", @@ -6163,7 +6163,7 @@ "destroy": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==" + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, "detect-newline": { "version": "3.1.0", @@ -6378,7 +6378,7 @@ "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -6387,7 +6387,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, "ejs": { "version": "3.1.9", @@ -6423,7 +6423,7 @@ "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, "enhanced-resolve": { "version": "5.15.0", @@ -6587,12 +6587,12 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { "version": "2.1.0", @@ -7278,7 +7278,7 @@ "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, "eventemitter3": { "version": "4.0.7", @@ -7411,7 +7411,7 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "3.1.3", @@ -7594,7 +7594,7 @@ "find-up": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", "requires": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" @@ -7644,7 +7644,7 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "fork-ts-checker-webpack-plugin": { "version": "6.5.3", @@ -7835,7 +7835,7 @@ "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, "fs-extra": { "version": "10.1.0", @@ -7857,7 +7857,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.3.2", @@ -7902,7 +7902,7 @@ "gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", - "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "requires": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -7930,7 +7930,7 @@ "geojson-equality": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/geojson-equality/-/geojson-equality-0.1.6.tgz", - "integrity": "sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ==", + "integrity": "sha1-oXE3TvBD5dR5eZWEC65GSOB1LXI=", "requires": { "deep-equal": "^1.0.0" } @@ -7966,7 +7966,7 @@ "get-stdin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==" + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" }, "get-stream": { "version": "6.0.1", @@ -7987,7 +7987,7 @@ "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", "requires": { "assert-plus": "^1.0.0" } @@ -8141,7 +8141,7 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==" + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.5", @@ -8169,7 +8169,7 @@ "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", "requires": { "ansi-regex": "^2.0.0" } @@ -8183,7 +8183,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-property-descriptors": { "version": "1.0.0", @@ -8214,7 +8214,7 @@ "has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" }, "he": { "version": "1.2.0", @@ -8427,7 +8427,7 @@ "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -8437,7 +8437,7 @@ "https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", - "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, "https-proxy-agent": { @@ -8504,7 +8504,7 @@ "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" }, "immer": { "version": "9.0.21", @@ -8553,7 +8553,7 @@ "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "requires": { "once": "^1.3.0", "wrappy": "1" @@ -8609,7 +8609,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" }, "is-bigint": { "version": "1.0.4", @@ -8687,7 +8687,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "requires": { "number-is-nan": "^1.0.0" } @@ -8864,12 +8864,12 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-utf8": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" }, "is-weakmap": { "version": "2.0.1", @@ -8908,17 +8908,17 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-lib-coverage": { "version": "3.2.0", @@ -11457,7 +11457,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { "version": "16.7.0", @@ -11554,7 +11554,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "2.2.3", @@ -11751,7 +11751,7 @@ "leaflet-fullscreen": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/leaflet-fullscreen/-/leaflet-fullscreen-1.0.2.tgz", - "integrity": "sha512-1Yxm8RZg6KlKX25+hbP2H/wnOAphH7hFcvuADJFb4QZTN7uOSN9Hsci5EZpow8vtNej9OGzu59Jxmn+0qKOO9Q==" + "integrity": "sha1-CcYcS6xF9jsu4Sav2H5c2XZQ/Bs=" }, "leaflet.locatecontrol": { "version": "0.76.1", @@ -11849,7 +11849,7 @@ "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" } } }, @@ -11904,7 +11904,7 @@ "loud-rejection": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", - "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", "requires": { "currently-unhandled": "^0.4.1", "signal-exit": "^3.0.0" @@ -11963,7 +11963,7 @@ "map-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==" + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" }, "mdn-data": { "version": "2.0.4", @@ -11974,7 +11974,7 @@ "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "memfs": { "version": "3.5.3", @@ -11993,7 +11993,7 @@ "meow": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", - "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", "requires": { "camelcase-keys": "^2.0.0", "decamelize": "^1.1.2", @@ -12010,7 +12010,7 @@ "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, "merge-stream": { "version": "2.0.0", @@ -12027,7 +12027,7 @@ "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" }, "mgrs": { "version": "1.0.0", @@ -12301,12 +12301,12 @@ "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==" + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" }, "chalk": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "requires": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -12318,14 +12318,14 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==" + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" } } }, "nopt": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", "requires": { "abbrev": "1" } @@ -12398,7 +12398,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==" + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, "nwsapi": { "version": "2.2.7", @@ -12414,7 +12414,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-hash": { "version": "3.0.0", @@ -12519,7 +12519,7 @@ "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", "requires": { "ee-first": "1.1.1" } @@ -12533,7 +12533,7 @@ "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "requires": { "wrappy": "1" } @@ -12575,12 +12575,12 @@ "os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", - "integrity": "sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==" + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" }, "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, "osenv": { "version": "0.1.5", @@ -12689,7 +12689,7 @@ "path-exists": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", "requires": { "pinkie-promise": "^2.0.0" } @@ -12697,7 +12697,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -12713,7 +12713,7 @@ "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, "path-type": { "version": "4.0.0", @@ -12723,7 +12723,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picocolors": { "version": "1.0.0", @@ -12739,17 +12739,17 @@ "pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" }, "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==" + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" }, "pinkie-promise": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", "requires": { "pinkie": "^2.0.0" } @@ -13756,7 +13756,7 @@ "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, "psl": { "version": "1.9.0", @@ -15214,7 +15214,7 @@ "read-pkg-up": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", "requires": { "find-up": "^1.0.0", "read-pkg": "^1.0.0" @@ -15439,7 +15439,7 @@ "repeating": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", - "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", "requires": { "is-finite": "^1.0.0" } @@ -15486,7 +15486,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, "require-from-string": { "version": "2.0.2", @@ -15788,7 +15788,7 @@ "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", - "integrity": "sha512-dYE8LhncfBUar6POCxMTm0Ln+erjeczqEvCJib5/7XNkdw1FkUGgwMPY360FY0FgPWQxHWCx29Jl3oejyGLM9Q==", + "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", "requires": { "js-base64": "^2.1.8", "source-map": "^0.4.2" @@ -15797,7 +15797,7 @@ "source-map": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", - "integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", "requires": { "amdefine": ">=0.0.4" } @@ -15945,7 +15945,7 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, "setprototypeof": { "version": "1.2.0", @@ -15988,7 +15988,7 @@ "lru-cache": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", - "integrity": "sha512-WpibWJ60c3AgAz8a2iYErDrcT2C7OmKnsWhIcHOjkUHFjkXncJhtLxNSqUmxRxRunpb5I8Vprd7aNSd2NtksJQ==" + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=" } } }, @@ -16040,7 +16040,7 @@ "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" }, "source-map-js": { "version": "1.0.2", @@ -16216,7 +16216,7 @@ "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, "stdout-stream": { "version": "1.4.1", @@ -16271,7 +16271,7 @@ "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -16356,7 +16356,7 @@ "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" } @@ -16364,7 +16364,7 @@ "strip-bom": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", "requires": { "is-utf8": "^0.2.0" } @@ -16793,7 +16793,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" }, "to-regex-range": { "version": "5.0.1", @@ -16812,7 +16812,7 @@ "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" }, "tough-cookie": { "version": "2.5.0", @@ -16835,7 +16835,7 @@ "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", - "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==" + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=" }, "true-case-path": { "version": "1.0.3", @@ -16911,7 +16911,7 @@ "tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", "requires": { "safe-buffer": "^5.0.1" } @@ -16919,7 +16919,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type-check": { "version": "0.4.0", @@ -17070,7 +17070,7 @@ "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, "unquote": { "version": "1.1.1", @@ -17127,7 +17127,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.1", @@ -17150,7 +17150,7 @@ "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { "version": "8.3.2", @@ -17185,12 +17185,12 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -17894,7 +17894,7 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" }, "string-width": { "version": "3.1.0", @@ -17919,7 +17919,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "4.0.2", @@ -17940,7 +17940,7 @@ "xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", - "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=", "dev": true }, "xml-name-validator": { @@ -18003,7 +18003,7 @@ "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==" + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" }, "string-width": { "version": "3.1.0", diff --git a/app/src/components/fields/MultiAutocompleteField.tsx b/app/src/components/fields/MultiAutocompleteField.tsx index c4bb952c60..a90265e062 100644 --- a/app/src/components/fields/MultiAutocompleteField.tsx +++ b/app/src/components/fields/MultiAutocompleteField.tsx @@ -1,11 +1,16 @@ import CheckBox from '@mui/icons-material/CheckBox'; import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank'; -import { Chip } from '@mui/material'; -import Autocomplete, { AutocompleteInputChangeReason, createFilterOptions } from '@mui/material/Autocomplete'; +import Autocomplete, { + AutocompleteChangeReason, + AutocompleteInputChangeReason, + createFilterOptions +} from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; +import Chip from '@mui/material/Chip'; import TextField from '@mui/material/TextField'; import { useFormikContext } from 'formik'; +import get from 'lodash-es/get'; import { useEffect, useState } from 'react'; export interface IMultiAutocompleteFieldOption { @@ -17,9 +22,15 @@ export interface IMultiAutocompleteField { id: string; label: string; options: IMultiAutocompleteFieldOption[]; + selectedOptions?: IMultiAutocompleteFieldOption[]; required?: boolean; filterLimit?: number; chipVisible?: boolean; + onChange?: ( + _event: React.ChangeEvent, + selectedOptions: IMultiAutocompleteFieldOption[], + reason: AutocompleteChangeReason + ) => void; handleSearchResults?: (input: string) => Promise; } @@ -44,8 +55,7 @@ export const sortAutocompleteOptions = ( }; const MultiAutocompleteField: React.FC = (props) => { - const { getFieldMeta, setFieldValue } = useFormikContext(); - const { value, touched, error } = getFieldMeta(props.id); + const { values, touched, errors, setFieldValue } = useFormikContext(); const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState(props.options || []); // store options if provided @@ -65,12 +75,12 @@ const MultiAutocompleteField: React.FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputValue]); - const handleOnChange = (_event: React.ChangeEvent, selectedOptions: IMultiAutocompleteFieldOption[]) => { + const defaultHandleOnChange = (_event: React.ChangeEvent, selectedOptions: IMultiAutocompleteFieldOption[]) => { setOptions(sortAutocompleteOptions(selectedOptions, options)); setSelectedOptions(selectedOptions); setFieldValue( props.id, - selectedOptions.map((item) => item) + selectedOptions.map((item) => item.value) ); }; @@ -93,45 +103,45 @@ const MultiAutocompleteField: React.FC = (props) => { } }; - const defaultChipDisplay = (option: any, renderProps: any, checkedStatus: any) => { - return ( - - } - checkedIcon={} - style={{ marginRight: 8 }} - checked={checkedStatus} - disabled={(options && options?.indexOf(option) !== -1) || false} - value={option.value} - color="default" - /> - {option.label} - - ); + const getExistingValue = (existingValues?: (number | string)[]): IMultiAutocompleteFieldOption[] => { + if (existingValues) { + return options.filter((option) => existingValues.includes(option.value)); + } + return []; }; - const existingValues: IMultiAutocompleteFieldOption[] = - value && value.length > 0 ? options.filter((option) => value.includes(option)) : []; return ( option.label} isOptionEqualToValue={handleGetOptionSelected} - filterOptions={createFilterOptions({ limit: props.filterLimit })} disableCloseOnSelect - onChange={handleOnChange} + disableListWrap inputValue={inputValue} onInputChange={handleOnInputChange} - renderTags={(tagValue, getTagProps) => { - if (props.chipVisible) { - return tagValue.map((option, index) => ); - } + onChange={props.onChange ? props.onChange : defaultHandleOnChange} + filterOptions={createFilterOptions({ limit: props.filterLimit })} + renderOption={(renderProps, renderOption, { selected }) => { + return ( + + } + checkedIcon={} + checked={selected} + disabled={props.options.includes(renderOption) || false} + value={renderOption.value} + color="default" + /> + {renderOption.label} + + ); }} - renderOption={(_renderProps, option, { selected }) => defaultChipDisplay(option, _renderProps, selected)} renderInput={(params) => ( { @@ -140,18 +150,23 @@ const MultiAutocompleteField: React.FC = (props) => { } }} {...params} + name={props.id} required={props.required} label={props.label} variant="outlined" fullWidth - error={touched && Boolean(error)} - helperText={touched && error} - placeholder={'Begin typing to filter results...'} - InputLabelProps={{ - shrink: true - }} + placeholder="Type to start searching" + error={get(touched, props.id) && Boolean(get(errors, props.id))} + helperText={get(touched, props.id) && get(errors, props.id)} /> )} + renderTags={(tagValue, getTagProps) => { + if (props.chipVisible === false) { + return; + } + + return tagValue.map((option, index) => ); + }} /> ); }; diff --git a/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx b/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx index 51c9b5810e..e8cb052405 100644 --- a/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx +++ b/app/src/components/fields/MultiAutocompleteFieldVariableSize.tsx @@ -248,7 +248,7 @@ const MultiAutocompleteFieldVariableSize: React.FC = (p return ( >} @@ -272,7 +272,7 @@ const MultiAutocompleteFieldVariableSize: React.FC = (p checkedIcon={} checked={selected} // Always seem to be disabled - disabled={(props.options && props.options?.indexOf(renderOption) !== -1) || false} + disabled={props.options?.includes(renderOption) || false} value={renderOption.value} color="default" /> @@ -288,6 +288,7 @@ const MultiAutocompleteFieldVariableSize: React.FC = (p label={props.label} variant="outlined" fullWidth + placeholder="Type to start searching" error={get(touched, props.id) && Boolean(get(errors, props.id))} helperText={get(touched, props.id) && get(errors, props.id)} /> diff --git a/app/src/components/fields/SingleDateField.tsx b/app/src/components/fields/SingleDateField.tsx index 27b15c2e83..43313cd1d7 100644 --- a/app/src/components/fields/SingleDateField.tsx +++ b/app/src/components/fields/SingleDateField.tsx @@ -1,3 +1,5 @@ +import { mdiCalendar } from '@mdi/js'; +import Icon from '@mdi/react'; import { TextFieldProps } from '@mui/material/TextField'; import { DatePicker } from '@mui/x-date-pickers'; import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; @@ -16,6 +18,10 @@ interface IDateProps { other?: TextFieldProps; } +const CalendarIcon = () => { + return ; +}; + /** * Single date field * @@ -40,6 +46,9 @@ const SingleDateField: React.FC = (props) => { return ( = (props) => { maxDate={moment(DATE_LIMIT.max)} value={formattedDateValue} onChange={(value) => { + if (!value || String(value.creationData().input) === 'Invalid Date') { + // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will + // contain an actual date string value if the field is not empty but is invalid. + setFieldValue(name, null); + return; + } + setFieldValue(name, moment(value).format(DATE_FORMAT.ShortDateFormat)); }} /> diff --git a/app/src/components/fields/StartEndDateFields.tsx b/app/src/components/fields/StartEndDateFields.tsx index cb0983cc36..67a1d7b26a 100644 --- a/app/src/components/fields/StartEndDateFields.tsx +++ b/app/src/components/fields/StartEndDateFields.tsx @@ -19,11 +19,11 @@ interface IStartEndDateFieldsProps { endDateHelperText?: string; } -const CalendarStartIcon: React.FC = () => { +const CalendarStartIcon = () => { return ; }; -const CalendarEndIcon: React.FC = () => { +const CalendarEndIcon = () => { return ; }; @@ -88,6 +88,13 @@ const StartEndDateFields: React.FC = (props) => { maxDate={moment(DATE_LIMIT.max)} value={formattedStartDateValue} onChange={(value) => { + if (!value || String(value.creationData().input) === 'Invalid Date') { + // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will + // contain an actual date string value if the field is not empty but is invalid. + setFieldValue(startName, null); + return; + } + setFieldValue(startName, moment(value).format(DATE_FORMAT.ShortDateFormat)); }} /> @@ -119,7 +126,14 @@ const StartEndDateFields: React.FC = (props) => { minDate={moment(DATE_LIMIT.min)} maxDate={moment(DATE_LIMIT.max)} value={formattedEndDateValue} - onChange={(value) => { + onChange={(value: moment.Moment | null) => { + if (!value || String(value.creationData().input) === 'Invalid Date') { + // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will + // contain an actual date string value if the field is not empty but is invalid. + setFieldValue(endName, null); + return; + } + setFieldValue(endName, moment(value).format(DATE_FORMAT.ShortDateFormat)); }} /> diff --git a/app/src/features/projects/components/ProjectDetailsForm.tsx b/app/src/features/projects/components/ProjectDetailsForm.tsx index 64a570c4c6..fb2878008f 100644 --- a/app/src/features/projects/components/ProjectDetailsForm.tsx +++ b/app/src/features/projects/components/ProjectDetailsForm.tsx @@ -80,7 +80,7 @@ const ProjectDetailsForm: React.FC = (props) => { /> - + , []>() + }, + project: { + createProject: jest.fn, []>() + }, + user: { + searchSystemUser: jest.fn, []>() } }; @@ -70,6 +78,8 @@ describe('CreateProjectPage', () => { mockUseApi.draft.getDraft.mockClear(); mockUseApi.spatial.getRegions.mockClear(); mockUseApi.codes.getAllCodeSets.mockClear(); + mockUseApi.project.createProject.mockClear(); + mockUseApi.user.searchSystemUser.mockClear(); mockUseApi.spatial.getRegions.mockResolvedValue({ regions: [] diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index b3b386e59c..990cf1000f 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -43,7 +43,7 @@ import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from './components/PurposeAndMethodologyForm'; import SamplingMethodsForm from './components/SamplingMethodsForm'; -import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from './components/StudyAreaForm'; +import StudyAreaForm, { SurveyLocationInitialValues, SurveyLocationYupSchema } from './components/StudyAreaForm'; import { SurveyBlockInitialValues } from './components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, @@ -134,11 +134,11 @@ const CreateSurveyPage = () => { const [surveyInitialValues] = useState({ ...GeneralInformationInitialValues, ...PurposeAndMethodologyInitialValues, - ...StudyAreaInitialValues, ...SurveyFundingSourceFormInitialValues, ...SurveyPartnershipsFormInitialValues, ...ProprietaryDataInitialValues, ...AgreementsInitialValues, + ...SurveyLocationInitialValues, ...SurveySiteSelectionInitialValues, ...SurveyUserJobFormInitialValues, ...SurveyBlockInitialValues @@ -179,12 +179,12 @@ const CreateSurveyPage = () => { `Survey end date cannot be after ${getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, DATE_LIMIT.max)}` ) }) - .concat(StudyAreaYupSchema) .concat(PurposeAndMethodologyYupSchema) .concat(ProprietaryDataYupSchema) .concat(SurveyFundingSourceFormYupSchema) .concat(AgreementsYupSchema) .concat(SurveyUserJobYupSchema) + .concat(SurveyLocationYupSchema) .concat(SurveySiteSelectionYupSchema) .concat(SurveyPartnershipsFormYupSchema); @@ -266,7 +266,6 @@ const CreateSurveyPage = () => { if (!codes || !projectData) { return ; } - return ( <> @@ -430,7 +429,9 @@ const CreateSurveyPage = () => { type="submit" variant="contained" color="primary" - onClick={() => formikRef.current?.submitForm()} + onClick={() => { + formikRef.current?.submitForm(); + }} className={classes.actionButton}> Save and Exit diff --git a/app/src/features/surveys/components/StudyAreaForm.test.tsx b/app/src/features/surveys/components/StudyAreaForm.test.tsx index 9f56da32f4..ad0079d7e2 100644 --- a/app/src/features/surveys/components/StudyAreaForm.test.tsx +++ b/app/src/features/surveys/components/StudyAreaForm.test.tsx @@ -1,9 +1,9 @@ import { cleanup } from '@testing-library/react-hooks'; import MapBoundary from 'components/boundary/MapBoundary'; import StudyAreaForm, { - IStudyAreaForm, - StudyAreaInitialValues, - StudyAreaYupSchema + ISurveyLocationForm, + SurveyLocationInitialValues, + SurveyLocationYupSchema } from 'features/surveys/components/StudyAreaForm'; import { Formik } from 'formik'; import { render, waitFor } from 'test-helpers/test-utils'; @@ -24,8 +24,8 @@ describe('Study Area Form', () => { it('renders correctly with default values', async () => { const { getByLabelText, getByTestId } = render( @@ -37,43 +37,46 @@ describe('Study Area Form', () => { // Assert MapBoundary was rendered with the right propsF expect(MapBoundary).toHaveBeenCalledWith( { - name: 'location.geometry', + name: 'locations[0].geojson', title: 'Study Area Boundary', mapId: 'study_area_form_map', bounds: undefined, - formikProps: expect.objectContaining({ values: StudyAreaInitialValues }) + formikProps: expect.objectContaining({ values: SurveyLocationInitialValues }) }, expect.anything() ); // Assert survey area name field is visible and populated correctly expect(getByLabelText('Survey Area Name', { exact: false })).toBeVisible(); - expect(getByTestId('location.survey_area_name')).toHaveValue(''); + expect(getByTestId('locations[0].name')).toHaveValue(''); }); }); it('renders correctly with non default values', async () => { - const existingFormValues: IStudyAreaForm = { - location: { - survey_area_name: 'a study area name', - geometry: [ - { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [125.6, 10.1] - }, - properties: { - name: 'Dinagat Islands' + const existingFormValues: ISurveyLocationForm = { + locations: [ + { + name: 'a study area name', + description: 'a study area description', + geojson: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [125.6, 10.1] + }, + properties: { + name: 'Dinagat Islands' + } } - } - ] - } + ] + } + ] }; const { getByLabelText, getByTestId } = render( @@ -85,7 +88,7 @@ describe('Study Area Form', () => { // Assert MapBoundary was rendered with the right propsF expect(MapBoundary).toHaveBeenCalledWith( { - name: 'location.geometry', + name: 'locations[0].geojson', title: 'Study Area Boundary', mapId: 'study_area_form_map', bounds: undefined, @@ -95,7 +98,7 @@ describe('Study Area Form', () => { ); // Assert survey area name field is visible and populated correctly expect(getByLabelText('Survey Area Name', { exact: false })).toBeVisible(); - expect(getByTestId('location.survey_area_name')).toHaveValue('a study area name'); + expect(getByTestId('locations[0].name')).toHaveValue('a study area name'); }); }); }); diff --git a/app/src/features/surveys/components/StudyAreaForm.tsx b/app/src/features/surveys/components/StudyAreaForm.tsx index 84f0c26a34..53bab39238 100644 --- a/app/src/features/surveys/components/StudyAreaForm.tsx +++ b/app/src/features/surveys/components/StudyAreaForm.tsx @@ -5,25 +5,38 @@ import { useFormikContext } from 'formik'; import { Feature } from 'geojson'; import yup from 'utils/YupSchema'; -export interface IStudyAreaForm { - location: { - survey_area_name: string; - geometry: Feature[]; - }; +export interface ISurveyLocationForm { + locations: { + survey_location_id?: number; + name: string; + description: string; + geojson: Feature[]; + revision_count?: number; + }[]; } -export const StudyAreaInitialValues: IStudyAreaForm = { - location: { - survey_area_name: '', - geometry: [] - } +export const SurveyLocationInitialValues: ISurveyLocationForm = { + locations: [ + { + survey_location_id: null as unknown as number, + name: '', + // TODO description is temporarily hardcoded until the new UI to populate this field is implemented in + // https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-219 + description: 'Insert description here', + geojson: [], + revision_count: 0 + } + ] }; -export const StudyAreaYupSchema = yup.object().shape({ - location: yup.object().shape({ - survey_area_name: yup.string().required('Survey Area Name is Required'), - geometry: yup.array().min(1, 'A survey study area is required').required('A survey study area is required') - }) +export const SurveyLocationYupSchema = yup.object({ + locations: yup.array( + yup.object({ + name: yup.string().max(100, 'Name cannot exceed 100 characters').required('Name is Required'), + description: yup.string().max(250, 'Description cannot exceed 250 characters'), + geojson: yup.array().min(1, 'A geometry is required').required('A geometry is required') + }) + ) }); /** @@ -32,15 +45,14 @@ export const StudyAreaYupSchema = yup.object().shape({ * @return {*} */ const StudyAreaForm = () => { - const formikProps = useFormikContext(); + const formikProps = useFormikContext(); const { handleSubmit } = formikProps; - return (
{ /> { - const entries = (stratums || []).map((stratum) => new String(stratum.name).trim()); + const entries = (stratums || []).map((stratum) => String(stratum.name).trim()); return new Set(entries).size === stratums?.length; }) - */ }) }); @@ -79,31 +78,34 @@ const SurveySiteSelectionForm = (props: ISurveySiteSelectionFormProps) => { const siteStrategies = codesContext.codesDataLoader.data.site_selection_strategies.map((code) => { return { label: code.name, value: code.name }; }); + const selectedSiteStrategies = siteStrategies.filter((item) => values.site_selection.strategies.includes(item.value)); const handleConfirmDeleteAllStratums = () => { - // Delete all Stratums and hide the Stratums form + // Delete all Stratums setFieldValue('site_selection.stratums', []); + // Remove 'Stratified' from the list of selected strategies + setFieldValue( + 'site_selection.strategies', + values.site_selection.strategies.filter((item) => item !== 'Stratified') + ); + // Hide Stratums form props.onChangeStratumEntryVisibility(false); + // Close dialogue setShowStratumDeleteConfirmModal(false); }; const handleCancelDeleteAllStratums = () => { + // Close dialogue and do nothing setShowStratumDeleteConfirmModal(false); - setFieldValue('site_selection.strategies', [...values.site_selection.strategies, 'Stratified']); }; useEffect(() => { if (values.site_selection.strategies.includes('Stratified')) { props.onChangeStratumEntryVisibility(true); - } else if (values.site_selection.stratums.length > 0) { - // Prompt to confirm removing all stratums - setShowStratumDeleteConfirmModal(true); } else { props.onChangeStratumEntryVisibility(false); } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values.site_selection.strategies]); + }, [props, values.site_selection.strategies]); return ( <> @@ -123,11 +125,31 @@ const SurveySiteSelectionForm = (props: ISurveySiteSelectionFormProps) => { onClose={handleCancelDeleteAllStratums} onYes={handleConfirmDeleteAllStratums} /> - { + // If the user clicks to remove the 'Stratified' option and there are Stratums already defined, then show + // a warning dialogue asking the user if they are sure they want to remove the option and delete the Stratums + if ( + reason === 'removeOption' && + values.site_selection.strategies.includes('Stratified') && + !selectedOptions.map((item) => item.value).includes('Stratified') && + values.site_selection.stratums.length + ) { + setShowStratumDeleteConfirmModal(true); + return; + } + + // Update selected options + setFieldValue( + 'site_selection.strategies', + selectedOptions.map((item) => item.value) + ); + }} /> ); diff --git a/app/src/features/surveys/components/SurveyStratumForm.tsx b/app/src/features/surveys/components/SurveyStratumForm.tsx index ae2192d9fd..f674928491 100644 --- a/app/src/features/surveys/components/SurveyStratumForm.tsx +++ b/app/src/features/surveys/components/SurveyStratumForm.tsx @@ -1,14 +1,19 @@ import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; -import { ListItemIcon, Menu, MenuItem, MenuProps } from '@mui/material'; +import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Card from '@mui/material/Card'; import CardHeader from '@mui/material/CardHeader'; import Collapse from '@mui/material/Collapse'; import { grey } from '@mui/material/colors'; import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; import { FormikProps, useFormikContext } from 'formik'; import { IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import get from 'lodash-es/get'; import { useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import yup from 'utils/YupSchema'; @@ -51,7 +56,7 @@ const SurveyStratumForm = () => { const [anchorEl, setAnchorEl] = useState(null); const formikProps = useFormikContext(); - const { values, handleSubmit, setFieldValue } = formikProps; + const { values, errors, handleSubmit, setFieldValue } = formikProps; const handleSave = (formikProps: FormikProps | null) => { if (!formikProps) { @@ -137,6 +142,14 @@ const SurveyStratumForm = () => { + {get(errors, 'site_selection.stratums') && ( + // Show array level error, if any + + + {get(errors, 'site_selection.stratums') as string} + + + )} {values.site_selection.stratums.map((stratum: IStratum, index: number) => { const key = `${stratum.name}-${index}`; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index 160418f9be..16b2049cce 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -28,7 +28,7 @@ import GeneralInformationForm, { import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/ProprietaryDataForm'; import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from '../components/PurposeAndMethodologyForm'; import SamplingMethodsForm from '../components/SamplingMethodsForm'; -import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from '../components/StudyAreaForm'; +import StudyAreaForm, { SurveyLocationInitialValues, SurveyLocationYupSchema } from '../components/StudyAreaForm'; import { SurveyBlockInitialValues } from '../components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, @@ -80,7 +80,7 @@ const EditSurveyForm: React.FC = (props) => { vantage_code_ids: [] } }, - ...StudyAreaInitialValues, + ...SurveyLocationInitialValues, ...SurveyFundingSourceFormInitialValues, ...SurveyPartnershipsFormInitialValues, ...SurveySiteSelectionInitialValues, @@ -140,7 +140,7 @@ const EditSurveyForm: React.FC = (props) => { ) .nullable() }) - .concat(StudyAreaYupSchema) + .concat(SurveyLocationYupSchema) .concat(PurposeAndMethodologyYupSchema) .concat(ProprietaryDataYupSchema) .concat(SurveyFundingSourceFormYupSchema) diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx index 7e7587dc4f..4187e864cb 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -4,7 +4,6 @@ import { GetRegionsResponse } from 'hooks/api/useSpatialApi'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { DataLoader } from 'hooks/useDataLoader'; import { IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; -import { geoJsonFeature } from 'test-helpers/spatial-helpers'; import { getSurveyForViewResponse, surveyObject, surveySupplementaryData } from 'test-helpers/survey-helpers'; import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; import SurveyStudyArea from './SurveyStudyArea'; @@ -72,7 +71,7 @@ describe('SurveyStudyArea', () => { ...getSurveyForViewResponse, surveyData: { ...getSurveyForViewResponse.surveyData, - survey_details: { ...getSurveyForViewResponse.surveyData.survey_details, geometry: [] } + survey_details: { ...getSurveyForViewResponse.surveyData.survey_details, geojson: [] } } } } as DataLoader; @@ -96,7 +95,7 @@ describe('SurveyStudyArea', () => { await waitFor(() => { expect(container).toBeVisible(); - expect(queryByTestId('survey_map_center_button')).not.toBeInTheDocument(); + expect(queryByTestId('survey_map_center_button')).toBeInTheDocument(); }); }); @@ -187,31 +186,35 @@ describe('SurveyStudyArea', () => { await waitFor(() => { expect(mockUseApi.survey.updateSurvey).toBeCalledWith(1, getSurveyForViewResponse.surveyData.survey_details.id, { - location: { - geometry: [ - { - geometry: { - coordinates: [ - [ - [-128, 55], - [-128, 55.5], - [-128, 56], - [-126, 58], - [-128, 55] - ] - ], - type: 'Polygon' - }, - id: 'myGeo', - properties: { - name: 'Biohub Islands' - }, - type: 'Feature' - } - ], - revision_count: 0, - survey_area_name: 'study area' - } + locations: [ + { + survey_location_id: 1, + geojson: [ + { + geometry: { + coordinates: [ + [ + [-128, 55], + [-128, 55.5], + [-128, 56], + [-126, 58], + [-128, 55] + ] + ], + type: 'Polygon' + }, + id: 'myGeo', + properties: { + name: 'Biohub Islands' + }, + type: 'Feature' + } + ], + revision_count: 0, + name: 'study area', + description: 'study area description' + } + ] }); }); }); @@ -233,8 +236,6 @@ describe('SurveyStudyArea', () => { end_date: '2021-01-25', biologist_first_name: 'firstttt', biologist_last_name: 'lastttt', - survey_area_name: 'study area is this', - geometry: [geoJsonFeature], survey_types: [1], revision_count: 0 } diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.tsx index 91eaa63c7c..1dc2c3d129 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.tsx @@ -22,9 +22,9 @@ import { EditSurveyStudyAreaI18N } from 'constants/i18n'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { SurveyContext } from 'contexts/surveyContext'; import StudyAreaForm, { - IStudyAreaForm, - StudyAreaInitialValues, - StudyAreaYupSchema + ISurveyLocationForm, + SurveyLocationInitialValues, + SurveyLocationYupSchema } from 'features/surveys/components/StudyAreaForm'; import { Feature } from 'geojson'; import { APIError } from 'hooks/api/useAxios'; @@ -79,11 +79,12 @@ const SurveyStudyArea = () => { const [markerLayers, setMarkerLayers] = useState([]); const [staticLayers, setStaticLayers] = useState([]); - const survey_details = surveyContext.surveyDataLoader.data?.surveyData?.survey_details; - const surveyGeometry = useMemo(() => survey_details?.geometry || [], [survey_details]); + const surveyLocations = surveyContext.surveyDataLoader.data?.surveyData?.locations; + const surveyLocation = surveyLocations[0] || null; + const surveyGeometry = useMemo(() => surveyLocation?.geojson || [], [surveyLocation]); const [openEditDialog, setOpenEditDialog] = useState(false); - const [studyAreaFormData, setStudyAreaFormData] = useState(StudyAreaInitialValues); + const [studyAreaFormData, setStudyAreaFormData] = useState(SurveyLocationInitialValues); const [bounds, setBounds] = useState(undefined); const [showFullScreenViewMapDialog, setShowFullScreenViewMapDialog] = useState(false); @@ -155,32 +156,41 @@ const SurveyStudyArea = () => { }; const handleDialogEditOpen = () => { - if (!survey_details) { + if (!surveyLocation) { return; } setStudyAreaFormData({ - location: { - survey_area_name: survey_details.survey_area_name, - geometry: survey_details.geometry - } + locations: [ + { + survey_location_id: surveyLocation.survey_location_id, + name: surveyLocation.name, + description: surveyLocation.description, + geojson: surveyLocation.geojson, + revision_count: surveyLocation.revision_count + } + ] }); setOpenEditDialog(true); }; - const handleDialogEditSave = async (values: IStudyAreaForm) => { - if (!survey_details) { + const handleDialogEditSave = async (values: ISurveyLocationForm) => { + if (!surveyLocation) { return; } try { const surveyData = { - location: { - survey_area_name: values.location.survey_area_name, - geometry: values.location.geometry, - revision_count: survey_details.revision_count - } + locations: values.locations.map((item) => { + return { + survey_location_id: item.survey_location_id, + name: item.name, + description: item.description, + geojson: item.geojson, + revision_count: surveyLocation.revision_count + }; + }) }; await biohubApi.survey.updateSurvey(surveyContext.projectId, surveyContext.surveyId, surveyData); @@ -211,7 +221,7 @@ const SurveyStudyArea = () => { component={{ element: , initialValues: studyAreaFormData, - validationSchema: StudyAreaYupSchema + validationSchema: SurveyLocationYupSchema }} onCancel={() => setOpenEditDialog(false)} onSave={handleDialogEditSave} @@ -231,7 +241,7 @@ const SurveyStudyArea = () => { staticLayers={staticLayers} /> } - description={survey_details?.survey_area_name} + description={surveyLocation?.name} layers={} backButtonTitle={'Back To Survey'} mapTitle={'Study Area'} @@ -283,7 +293,7 @@ const SurveyStudyArea = () => { Study Area Name - {survey_details?.survey_area_name} + {surveyLocation?.name} diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index f339b444f8..69d1bb1896 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -3,7 +3,7 @@ import { IAgreementsForm } from 'features/surveys/components/AgreementsForm'; import { IGeneralInformationForm } from 'features/surveys/components/GeneralInformationForm'; import { IProprietaryDataForm } from 'features/surveys/components/ProprietaryDataForm'; import { IPurposeAndMethodologyForm } from 'features/surveys/components/PurposeAndMethodologyForm'; -import { IStudyAreaForm } from 'features/surveys/components/StudyAreaForm'; +import { ISurveyLocationForm } from 'features/surveys/components/StudyAreaForm'; import { ISurveyFundingSource, ISurveyFundingSourceForm } from 'features/surveys/components/SurveyFundingSourceForm'; import { ISurveySiteSelectionForm } from 'features/surveys/components/SurveySiteSelectionForm'; import { Feature } from 'geojson'; @@ -19,10 +19,10 @@ import { ICritterDetailedResponse } from './useCritterApi.interface'; export interface ICreateSurveyRequest extends IGeneralInformationForm, IPurposeAndMethodologyForm, - IStudyAreaForm, IProprietaryDataForm, IAgreementsForm, IParticipantsJobForm, + ISurveyLocationForm, ISurveyBlockForm {} /** @@ -58,8 +58,6 @@ export interface IGetSurveyForViewResponseDetails { end_date: string; biologist_first_name: string; biologist_last_name: string; - survey_area_name: string; - geometry: Feature[]; survey_types: number[]; revision_count: number; } @@ -103,6 +101,16 @@ export interface IGetSurveyForUpdateResponsePartnerships { stakeholder_partnerships: string[]; } +export interface IGetSurveyLocation { + survey_location_id: number; + name: string; + description: string; + geometry: Feature[]; + geography: string | null; + geojson: Feature[]; + revision_count: number; +} + export interface SurveyViewObject { survey_details: IGetSurveyForViewResponseDetails; species: IGetSpecies; @@ -113,9 +121,10 @@ export interface SurveyViewObject { proprietor: IGetSurveyForViewResponseProprietor | null; participants: IGetSurveyParticipant[]; partnerships: IGetSurveyForViewResponsePartnerships; + locations: IGetSurveyLocation[]; } -export interface SurveyUpdateObject { +export interface SurveyUpdateObject extends ISurveyLocationForm { survey_details?: { survey_name: string; start_date: string; @@ -161,11 +170,6 @@ export interface SurveyUpdateObject { category_rationale: string; disa_required: StringBoolean; }; - location?: { - survey_area_name: string; - geometry: Feature[]; - revision_count: number; - }; participants?: { identity_source: string; email: string | null; @@ -339,7 +343,7 @@ export interface IDetailedCritterWithInternalId extends ICritterDetailedResponse export type IEditSurveyRequest = IGeneralInformationForm & IPurposeAndMethodologyForm & ISurveyFundingSourceForm & - IStudyAreaForm & + ISurveyLocationForm & IProprietaryDataForm & IUpdateAgreementsForm & { partnerships: IGetSurveyForViewResponsePartnerships } & ISurveySiteSelectionForm & IParticipantsJobForm; diff --git a/app/src/test-helpers/survey-helpers.ts b/app/src/test-helpers/survey-helpers.ts index a9087bbfa2..20c8ecab18 100644 --- a/app/src/test-helpers/survey-helpers.ts +++ b/app/src/test-helpers/survey-helpers.ts @@ -17,8 +17,6 @@ export const surveyObject: SurveyViewObject = { end_date: '2021-02-26', biologist_first_name: 'first', biologist_last_name: 'last', - survey_area_name: 'study area', - geometry: [geoJsonFeature], survey_types: [1], revision_count: 0 }, @@ -83,6 +81,17 @@ export const surveyObject: SurveyViewObject = { survey_job_id: 1, survey_job_name: 'survey job name' } + ], + locations: [ + { + survey_location_id: 1, + name: 'study area', + description: 'study area description', + geometry: [geoJsonFeature], + geography: null, + geojson: [geoJsonFeature], + revision_count: 0 + } ] }; diff --git a/database/src/migrations/20230802000000_new_funding_table.ts b/database/src/migrations/20230802000000_new_funding_table.ts index 5a4bebdae3..6d41bf8fef 100644 --- a/database/src/migrations/20230802000000_new_funding_table.ts +++ b/database/src/migrations/20230802000000_new_funding_table.ts @@ -1,7 +1,7 @@ import { Knex } from 'knex'; /** - * Added new program and project_program for tracking programs (used to be project type) + * Added new funding source and survey funding tables. * * @export * @param {Knex} knex diff --git a/database/src/migrations/20230831111300_survey_locations.ts b/database/src/migrations/20230831111300_survey_locations.ts new file mode 100644 index 0000000000..e352ff8ec9 --- /dev/null +++ b/database/src/migrations/20230831111300_survey_locations.ts @@ -0,0 +1,129 @@ +import { Knex } from 'knex'; + +/** + * Replace/enhance the old (unused) survey_location table. + * Migrate existing spatial data from survey table into new survey_location table. + * Drop old spatial data columns from survey table. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ------------------------------------------------------------------------- + -- Drop views/tables/constraints + ------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + DROP VIEW IF EXISTS survey; + + DROP VIEW IF EXISTS survey_location; + + SET SEARCH_PATH=biohub,public; + + DROP TABLE IF EXISTS survey_location; + + ------------------------------------------------------------------------- + -- Create new tables + ------------------------------------------------------------------------- + + CREATE TABLE survey_location( + survey_location_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_id integer NOT NULL, + name varchar(100) NOT NULL, + description varchar(250) NOT NULL, + geometry geometry(geometry, 3005), + geography geography(geometry) NOT NULL, + geojson jsonb NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT survey_location_pk PRIMARY KEY (survey_location_id) + ); + + COMMENT ON COLUMN survey_location.survey_location_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN survey_location.name IS 'The name of the spatial record.'; + COMMENT ON COLUMN survey_location.description IS 'The description of the spatial record.'; + COMMENT ON COLUMN survey_location.geometry IS 'The containing geometry of the record.'; + COMMENT ON COLUMN survey_location.geography IS 'The containing geography of the record.'; + COMMENT ON COLUMN survey_location.geojson IS 'A GeoJSON representation of the geometry which may contain additional metadata.'; + COMMENT ON COLUMN survey_location.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN survey_location.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN survey_location.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN survey_location.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN survey_location.revision_count IS 'Revision count used for concurrency control.'; + COMMENT ON TABLE survey_location IS 'Spatial records associated to a survey.'; + + ---------------------------------------------------------------------------------------- + -- Create Indexes and Constraints + ---------------------------------------------------------------------------------------- + + ALTER TABLE survey_location ADD CONSTRAINT survey_location_fk1 + FOREIGN KEY (survey_id) + REFERENCES survey(survey_id); + + -- Add indexes on foreign key columns + CREATE INDEX survey_location_idx1 ON survey_location(survey_id); + + ------------------------------------------------------------------------- + -- Create audit and journal triggers + ------------------------------------------------------------------------- + + CREATE TRIGGER audit_survey_location BEFORE INSERT OR UPDATE OR DELETE ON survey_location for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_survey_location AFTER INSERT OR UPDATE OR DELETE ON survey_location for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Migrate existing spatial data from survey table into new survey spatial components table + ---------------------------------------------------------------------------------------- + + INSERT INTO survey_location ( + survey_id, + name, + description, + geometry, + geography, + geojson, + create_date, + create_user + )( + select + survey.survey_id, + survey.location_name, + 'Insert description here' as description, + survey.geometry, + survey.geography, + survey.geojson, + survey.create_date, + survey.create_user + FROM + survey + ); + + ---------------------------------------------------------------------------------------- + -- Drop old spatial data columns from survey table + ---------------------------------------------------------------------------------------- + + ALTER TABLE survey DROP COLUMN geometry; + ALTER TABLE survey DROP COLUMN geography; + ALTER TABLE survey DROP COLUMN geojson; + ALTER TABLE survey DROP COLUMN location_description; + ALTER TABLE survey DROP COLUMN location_name; + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW survey_location AS SELECT * FROM biohub.survey_location; + CREATE OR REPLACE VIEW survey AS SELECT * FROM biohub.survey; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index f8a6f740bc..7d3570c4fb 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -63,6 +63,7 @@ export async function seed(knex: Knex): Promise { ${insertSurveyStakeholderData(surveyId)} ${insertSurveyVantageData(surveyId)} ${insertSurveyParticipationData(surveyId)} + ${insertSurveyLocationData(surveyId)} `); } } @@ -240,35 +241,21 @@ const insertSurveyPermitData = (surveyId: number) => ` `; /** - * SQL to insert Survey data + * SQL to insert Survey location data * */ -const insertSurveyData = (projectId: number) => ` - INSERT into survey +const insertSurveyLocationData = (surveyId: number) => ` + INSERT into survey_location ( - project_id, + survey_id, name, - field_method_id, - additional_details, - start_date, - end_date, - lead_first_name, - lead_last_name, - location_name, + description, geography, - geojson, - ecological_season_id, - intended_outcome_id + geojson ) VALUES ( - ${projectId}, - 'Seed Survey', - (select field_method_id from field_method order by random() limit 1), - $$${faker.lorem.sentences(2)}$$, - $$${faker.date.between({ from: '2010-01-01T00:00:00-08:00', to: '2015-01-01T00:00:00-08:00' }).toISOString()}$$, - $$${faker.date.between({ from: '2020-01-01T00:00:00-08:00', to: '2025-01-01T00:00:00-08:00' }).toISOString()}$$, - $$${faker.person.firstName()}$$, - $$${faker.person.lastName()}$$, + ${surveyId}, + $$${faker.lorem.words(2)}$$, $$${faker.lorem.words(6)}$$, 'POLYGON ((-121.904297 50.930738, -121.904297 51.971346, -120.19043 51.971346, -120.19043 50.930738, -121.904297 50.930738))', '[ @@ -303,7 +290,37 @@ const insertSurveyData = (projectId: number) => ` }, "properties": {} } - ]', + ]' + ); +`; + +/** + * SQL to insert Survey data + * + */ +const insertSurveyData = (projectId: number) => ` + INSERT into survey + ( + project_id, + name, + field_method_id, + additional_details, + start_date, + end_date, + lead_first_name, + lead_last_name, + ecological_season_id, + intended_outcome_id + ) + VALUES ( + ${projectId}, + 'Seed Survey', + (select field_method_id from field_method order by random() limit 1), + $$${faker.lorem.sentences(2)}$$, + $$${faker.date.between({ from: '2010-01-01T00:00:00-08:00', to: '2015-01-01T00:00:00-08:00' }).toISOString()}$$, + $$${faker.date.between({ from: '2020-01-01T00:00:00-08:00', to: '2025-01-01T00:00:00-08:00' }).toISOString()}$$, + $$${faker.person.firstName()}$$, + $$${faker.person.lastName()}$$, (select ecological_season_id from ecological_season order by random() limit 1), (select intended_outcome_id from intended_outcome order by random() limit 1) )