From 68a6d84385c8a37badf37402d3440dca263dc1d9 Mon Sep 17 00:00:00 2001 From: Curtis Upshall Date: Thu, 31 Aug 2023 12:39:51 -0700 Subject: [PATCH 1/3] SIMSBIOHUB-262: Create Survey Blocks table (#1081) * Creates new tables: survey_block, survey_stratrum, site_strategy, survey_site_strategy * Amends the survey deletion procedure to delete blocks as well as stratum records upon survey deletion --- .../features/projects/view/ProjectDetails.tsx | 7 - ...230830123800_create_survey_block_tables.ts | 87 +++++++ ...830143800_create_survey_site_strategies.ts | 244 ++++++++++++++++++ 3 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 database/src/migrations/20230830123800_create_survey_block_tables.ts create mode 100644 database/src/migrations/20230830143800_create_survey_site_strategies.ts diff --git a/app/src/features/projects/view/ProjectDetails.tsx b/app/src/features/projects/view/ProjectDetails.tsx index 13a66ad81b..735b793850 100644 --- a/app/src/features/projects/view/ProjectDetails.tsx +++ b/app/src/features/projects/view/ProjectDetails.tsx @@ -92,13 +92,6 @@ const ProjectDetails = () => { - - - Partnerships - - - - {/* TODO: (https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-162) Commenting out IUCN form (view) temporarily, while its decided if IUCN information is desired */} {/* diff --git a/database/src/migrations/20230830123800_create_survey_block_tables.ts b/database/src/migrations/20230830123800_create_survey_block_tables.ts new file mode 100644 index 0000000000..cec65bfb4d --- /dev/null +++ b/database/src/migrations/20230830123800_create_survey_block_tables.ts @@ -0,0 +1,87 @@ +import { Knex } from 'knex'; + +/** + * Adds a new table for creating blocks, which are associated to surveys; + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + ---------------------------------------------------------------------------------------- + -- Create new survey_block table + ---------------------------------------------------------------------------------------- + + SET search_path=biohub; + + CREATE TABLE survey_block( + survey_block_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_id integer NOT NULL, + name varchar(300), + description varchar(3000), + 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_block_pk PRIMARY KEY (survey_block_id) + ); + + COMMENT ON COLUMN survey_block.survey_block_id IS 'System generated surrogate primary key identifier.' + ; + COMMENT ON COLUMN survey_block.survey_id IS 'System generated surrogate primary key identifier.' + ; + COMMENT ON COLUMN survey_block.name IS 'The name of the block.' + ; + COMMENT ON COLUMN survey_block.description IS 'The description of the block.' + ; + COMMENT ON COLUMN survey_block.create_date IS 'The datetime the record was created.' + ; + COMMENT ON COLUMN survey_block.create_user IS 'The id of the user who created the record as identified in the system user table.' + ; + COMMENT ON COLUMN survey_block.update_date IS 'The datetime the record was updated.' + ; + COMMENT ON COLUMN survey_block.update_user IS 'The id of the user who updated the record as identified in the system user table.' + ; + COMMENT ON COLUMN survey_block.revision_count IS 'Revision count used for concurrency control.' + ; + COMMENT ON TABLE survey_block IS 'blocks associated with a given survey.' + ; + + + ---------------------------------------------------------------------------------------- + -- Create new keys and indices + ---------------------------------------------------------------------------------------- + + -- Add foreign key constraint from child table to parent table on survey_id + ALTER TABLE survey_block ADD CONSTRAINT survey_block_fk1 + FOREIGN KEY (survey_id) + REFERENCES survey(survey_id); + + -- Add foreign key index + CREATE INDEX survey_block_idx1 ON survey_block(survey_id); + + -- Add unique constraint + + CREATE UNIQUE INDEX survey_block_uk1 ON survey_block(name, survey_id); + + -- Create audit and journal triggers + create trigger audit_survey_block before insert or update or delete on survey_block for each row execute procedure tr_audit_trigger(); + create trigger journal_survey_block after insert or update or delete on survey_block for each row execute procedure tr_journal_trigger(); + + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + set search_path=biohub_dapi_v1; + + create or replace view survey_block as select * from biohub.survey_block; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20230830143800_create_survey_site_strategies.ts b/database/src/migrations/20230830143800_create_survey_site_strategies.ts new file mode 100644 index 0000000000..dc3f81bd93 --- /dev/null +++ b/database/src/migrations/20230830143800_create_survey_site_strategies.ts @@ -0,0 +1,244 @@ +import { Knex } from 'knex'; + +/** + * 1. Adds two new tables: + * - Codes table for site selection strategy + * - Xref table for survey site selection strategies + * 2. Updates the survey delete procedure to account for blocks, stratums and survey site selection strategies + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + ---------------------------------------------------------------------------------------- + -- Create new site_strategy table + ---------------------------------------------------------------------------------------- + + SET search_path=biohub; + + CREATE TABLE site_strategy( + site_strategy_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(50) NOT NULL, + description varchar(250), + record_effective_date date NOT NULL, + record_end_date date, + 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 site_strategy_pk PRIMARY KEY (site_strategy_id) + ); + + + COMMENT ON COLUMN site_strategy.site_strategy_id IS 'System generated surrogate primary key identifier.' + ; + COMMENT ON COLUMN site_strategy.name IS 'The name of the site selection strategy.' + ; + COMMENT ON COLUMN site_strategy.record_effective_date IS 'Record level effective date.' + ; + COMMENT ON COLUMN site_strategy.description IS 'The description of the site selection strategy.' + ; + COMMENT ON COLUMN site_strategy.record_end_date IS 'Record level end date.' + ; + COMMENT ON COLUMN site_strategy.create_date IS 'The datetime the record was created.' + ; + COMMENT ON COLUMN site_strategy.create_user IS 'The id of the user who created the record as identified in the system user table.' + ; + COMMENT ON COLUMN site_strategy.update_date IS 'The datetime the record was updated.' + ; + COMMENT ON COLUMN site_strategy.update_user IS 'The id of the user who updated the record as identified in the system user table.' + ; + COMMENT ON COLUMN site_strategy.revision_count IS 'Revision count used for concurrency control.' + ; + COMMENT ON TABLE site_strategy IS 'Broad classification for the site_strategy code of the survey.' + ; + + + ---------------------------------------------------------------------------------------- + -- Create new keys and indices + ---------------------------------------------------------------------------------------- + + -- Add unique constraint + CREATE UNIQUE INDEX site_strategy_nuk1 ON site_strategy(name, (record_end_date is NULL)) where record_end_date is null; + + -- Create audit and journal triggers + create trigger audit_site_strategy before insert or update or delete on site_strategy for each row execute procedure tr_audit_trigger(); + create trigger journal_site_strategy after insert or update or delete on site_strategy for each row execute procedure tr_journal_trigger(); + + + ---------------------------------------------------------------------------------------- + -- Insert seed values + ---------------------------------------------------------------------------------------- + + insert into site_strategy (name, record_effective_date) values ('Random', now()); + insert into site_strategy (name, record_effective_date) values ('Stratified', now()); + insert into site_strategy (name, record_effective_date) values ('Systematic', now()); + + ------------------------------------------------------------------------- + -- Create new survey_site_strategy table + ------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub; + + CREATE TABLE survey_site_strategy( + survey_site_strategy_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_id integer NOT NULL, + site_strategy_id integer 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_site_strategy_pk PRIMARY KEY (survey_site_strategy_id) + ); + + COMMENT ON COLUMN survey_site_strategy.survey_site_strategy_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN survey_site_strategy.survey_id IS 'A foreign key pointing to the survey table.'; + COMMENT ON COLUMN survey_site_strategy.site_strategy_id IS 'A foreign key pointing to the type table.'; + COMMENT ON COLUMN survey_site_strategy.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN survey_site_strategy.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN survey_site_strategy.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN survey_site_strategy.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN survey_site_strategy.revision_count IS 'Revision count used for concurrency control.'; + COMMENT ON TABLE survey_site_strategy IS 'Site selection strategy classification for the survey.'; + + ------------------------------------------------------------------------- + -- Add survey_site_strategy constraints and indexes + ------------------------------------------------------------------------- + + -- add foreign key constraints + ALTER TABLE survey_site_strategy ADD CONSTRAINT survey_site_strategy_fk1 + FOREIGN KEY (survey_id) + REFERENCES survey(survey_id); + + ALTER TABLE survey_site_strategy ADD CONSTRAINT survey_site_strategy_fk2 + FOREIGN KEY (site_strategy_id) + REFERENCES site_strategy(site_strategy_id); + + -- add indexes for foreign keys + CREATE INDEX survey_site_strategy_idx1 ON survey_site_strategy(survey_id); + CREATE INDEX survey_site_strategy_idx2 ON survey_site_strategy(site_strategy_id); + + -- add unique index + CREATE UNIQUE INDEX survey_site_strategy_uk1 ON survey_site_strategy(survey_id, site_strategy_id); + + ------------------------------------------------------------------------- + -- Create audit and journal triggers for survey_site_strategy table + ------------------------------------------------------------------------- + + CREATE TRIGGER audit_survey_site_strategy BEFORE INSERT OR UPDATE OR DELETE ON survey_site_strategy for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_survey_site_strategy AFTER INSERT OR UPDATE OR DELETE ON survey_site_strategy for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create new views for both new tables + ---------------------------------------------------------------------------------------- + + set search_path=biohub_dapi_v1; + + create or replace view site_strategy as select * from biohub.site_strategy; + create or replace view survey_site_strategy as select * from biohub.survey_site_strategy; + + ---------------------------------------------------------------------------------------- + -- Update api_delete_survey procedure + ---------------------------------------------------------------------------------------- + + set search_path=biohub; + + CREATE OR REPLACE PROCEDURE api_delete_survey(p_survey_id integer) + LANGUAGE plpgsql + SECURITY DEFINER + AS $procedure$ + -- ******************************************************************* + -- Procedure: api_delete_survey + -- Purpose: deletes a survey and dependencies + -- + -- MODIFICATION HISTORY + -- Person Date Comments + -- ---------------- ----------- -------------------------------------- + -- shreyas.devalapurkar@quartech.com + -- 2021-06-18 initial release + -- charlie.garrettjones@quartech.com + -- 2021-06-21 added occurrence submission delete + -- charlie.garrettjones@quartech.com + -- 2021-09-21 added survey summary submission delete + -- kjartan.einarsson@quartech.com + -- 2022-08-28 added survey_vantage, survey_spatial_component, survey delete + -- charlie.garrettjones@quartech.com + -- 2022-09-07 changes to permit model + -- charlie.garrettjones@quartech.com + -- 2022-10-05 1.3.0 model changes + -- charlie.garrettjones@quartech.com + -- 2022-10-05 1.5.0 model changes, drop concept of occurrence deletion for published data + -- charlie.garrettjones@quartech.com + -- 2023-03-14 1.7.0 model changes + -- alfred.rosenthal@quartech.com + -- 2023-03-15 added missing publish tables to survey delete + -- curtis.upshall@quartech.com + -- 2023-04-28 change order of survey delete procedure + -- alfred.rosenthal@quartech.com + -- 2023-07-26 delete regions + -- curtis.upshall@quartech.com + -- 2023-08-24 delete partnerships + -- curtis.upshall@quartech.com + -- 2023-08-24 delete survey blocks and stratums and participation + -- ******************************************************************* + declare + + begin + with occurrence_submissions as (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id), submission_spatial_components as (select submission_spatial_component_id from submission_spatial_component + where occurrence_submission_id in (select occurrence_submission_id from occurrence_submissions)) + delete from spatial_transform_submission where submission_spatial_component_id in (select submission_spatial_component_id from submission_spatial_components); + delete from submission_spatial_component where occurrence_submission_id in (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id); + + with occurrence_submissions as (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id) + , submission_statuses as (select submission_status_id from submission_status + where occurrence_submission_id in (select occurrence_submission_id from occurrence_submissions)) + delete from submission_message where submission_status_id in (select submission_status_id from submission_statuses); + delete from submission_status where occurrence_submission_id in (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id); + + delete from occurrence_submission_publish where occurrence_submission_id in (select occurrence_submission_id from occurrence_submission where survey_id = p_survey_id); + + delete from occurrence_submission where survey_id = p_survey_id; + + delete from survey_summary_submission_publish where survey_summary_submission_id in (select survey_summary_submission_id from survey_summary_submission where survey_id = p_survey_id); + delete from survey_summary_submission_message where survey_summary_submission_id in (select survey_summary_submission_id from survey_summary_submission where survey_id = p_survey_id); + delete from survey_summary_submission where survey_id = p_survey_id; + delete from survey_proprietor where survey_id = p_survey_id; + delete from survey_attachment_publish where survey_attachment_id in (select survey_attachment_id from survey_attachment where survey_id = p_survey_id); + delete from survey_attachment where survey_id = p_survey_id; + delete from survey_report_author where survey_report_attachment_id in (select survey_report_attachment_id from survey_report_attachment where survey_id = p_survey_id); + delete from survey_report_publish where survey_report_attachment_id in (select survey_report_attachment_id from survey_report_attachment where survey_id = p_survey_id); + delete from survey_report_attachment where survey_id = p_survey_id; + delete from study_species where survey_id = p_survey_id; + delete from survey_funding_source where survey_id = p_survey_id; + delete from survey_vantage where survey_id = p_survey_id; + delete from survey_spatial_component where survey_id = p_survey_id; + delete from survey_metadata_publish where survey_id = p_survey_id; + delete from survey_region where survey_id = p_survey_id; + delete from survey_first_nation_partnership where survey_id = p_survey_id; + delete from survey_block where survey_id = p_survey_id; + delete from permit where survey_id = p_survey_id; + delete from survey_type where survey_id = p_survey_id; + delete from survey_first_nation_partnership where survey_id = p_survey_id; + delete from survey_stakeholder_partnership where survey_id = p_survey_id; + delete from survey_participation where survey_id = p_survey_id; + delete from survey_stratum where survey_id = p_survey_id; + delete from survey_block where survey_id = p_survey_id; + delete from survey_site_strategy where survey_id = p_survey_id; + delete from survey where survey_id = p_survey_id; + + exception + when others THEN + raise; + end; + $procedure$; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} From df258a070177b61efd0da17d56c421629dafc593 Mon Sep 17 00:00:00 2001 From: JeremyQuartech <123425360+JeremyQuartech@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:34:28 -0700 Subject: [PATCH 2/3] SIMSBIOHUB-239: Proxy BCTW/Critterbase requests through SIMS backend using SIMS Service Account (#1077) * Adds '/telemetry' endpoint to API for proxied BCTW requests. * Adds new service to handle authenticated requests to Critterbase API. * Adds new '/critter-data' endpoint to API for proxied Critterbase requests. * Modifies Add Animal form's api hooks to point at /critter-data endpoint instead of Critterbase directly. * Adds new method to Keycloak service for service account token retrieval. --------- Co-authored-by: Mac Deluca Co-authored-by: Graham Stewart Co-authored-by: Alfred Rosenthal Co-authored-by: Curtis Upshall Co-authored-by: Nick Phura --- api/.pipeline/config.js | 6 + api/.pipeline/lib/api.deploy.js | 2 + api/.pipeline/templates/api.dc.yaml | 10 + .../paths/critter-data/critters/index.test.ts | 64 ++++ api/src/paths/critter-data/critters/index.ts | 160 ++++++++++ .../critter-data/critters/{critterId}.test.ts | 68 +++++ .../critter-data/critters/{critterId}.ts | 94 ++++++ .../paths/critter-data/family/index.test.ts | 41 +++ api/src/paths/critter-data/family/index.ts | 87 ++++++ .../critter-data/family/{familyId}.test.ts | 41 +++ .../paths/critter-data/family/{familyId}.ts | 112 +++++++ .../paths/critter-data/lookups/{key}.test.ts | 53 ++++ api/src/paths/critter-data/lookups/{key}.ts | 129 ++++++++ api/src/paths/critter-data/signup.test.ts | 40 +++ api/src/paths/critter-data/signup.ts | 82 +++++ .../xref/taxon-marking-body-locations.test.ts | 45 +++ .../xref/taxon-marking-body-locations.ts | 92 ++++++ .../xref/taxon-measurements.test.ts | 45 +++ .../critter-data/xref/taxon-measurements.ts | 97 ++++++ ...on-qualitative-measurement-options.test.ts | 47 +++ .../taxon-qualitative-measurement-options.ts | 98 ++++++ api/src/paths/telemetry/vendors.test.ts | 42 +++ api/src/paths/telemetry/vendors.ts | 77 +++++ api/src/services/bctw-service.test.ts | 214 ++++++++++++++ api/src/services/bctw-service.ts | 188 ++++++++++++ api/src/services/critterbase-service.test.ts | 160 ++++++++++ api/src/services/critterbase-service.ts | 279 ++++++++++++++++++ api/src/services/keycloak-service.ts | 40 ++- api/src/utils/shared-api-docs.ts | 21 ++ app/src/components/fields/CbSelectField.tsx | 4 +- app/src/contexts/configContext.tsx | 7 - .../features/surveys/view/SurveyAnimals.tsx | 10 +- app/src/features/surveys/view/SurveyPage.tsx | 15 +- .../survey-animals/IndividualAnimalForm.tsx | 2 +- .../form-sections/GeneralAnimalForm.tsx | 2 +- .../form-sections/MarkingAnimalForm.tsx | 10 +- .../form-sections/MeasurementAnimalForm.tsx | 4 +- .../form-sections/MortalityAnimalForm.tsx | 12 +- .../cb_api/useAuthenticationApi.test.tsx | 28 ++ app/src/hooks/cb_api/useAuthenticationApi.tsx | 11 +- app/src/hooks/cb_api/useCritterApi.test.tsx | 81 +++++ app/src/hooks/cb_api/useCritterApi.tsx | 24 +- app/src/hooks/cb_api/useFamilyApi.test.tsx | 42 +++ app/src/hooks/cb_api/useFamilyApi.tsx | 13 +- app/src/hooks/cb_api/useLookupApi.test.tsx | 57 ++++ app/src/hooks/cb_api/useLookupApi.tsx | 65 +--- app/src/hooks/useCritterbaseApi.ts | 2 +- docker-compose.yml | 2 + env_config/env.docker | 16 + 49 files changed, 2736 insertions(+), 105 deletions(-) create mode 100644 api/src/paths/critter-data/critters/index.test.ts create mode 100644 api/src/paths/critter-data/critters/index.ts create mode 100644 api/src/paths/critter-data/critters/{critterId}.test.ts create mode 100644 api/src/paths/critter-data/critters/{critterId}.ts create mode 100644 api/src/paths/critter-data/family/index.test.ts create mode 100644 api/src/paths/critter-data/family/index.ts create mode 100644 api/src/paths/critter-data/family/{familyId}.test.ts create mode 100644 api/src/paths/critter-data/family/{familyId}.ts create mode 100644 api/src/paths/critter-data/lookups/{key}.test.ts create mode 100644 api/src/paths/critter-data/lookups/{key}.ts create mode 100644 api/src/paths/critter-data/signup.test.ts create mode 100644 api/src/paths/critter-data/signup.ts create mode 100644 api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts create mode 100644 api/src/paths/critter-data/xref/taxon-marking-body-locations.ts create mode 100644 api/src/paths/critter-data/xref/taxon-measurements.test.ts create mode 100644 api/src/paths/critter-data/xref/taxon-measurements.ts create mode 100644 api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts create mode 100644 api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts create mode 100644 api/src/paths/telemetry/vendors.test.ts create mode 100644 api/src/paths/telemetry/vendors.ts create mode 100755 api/src/services/bctw-service.test.ts create mode 100644 api/src/services/bctw-service.ts create mode 100644 api/src/services/critterbase-service.test.ts create mode 100644 api/src/services/critterbase-service.ts create mode 100644 app/src/hooks/cb_api/useAuthenticationApi.test.tsx create mode 100644 app/src/hooks/cb_api/useCritterApi.test.tsx create mode 100644 app/src/hooks/cb_api/useFamilyApi.test.tsx create mode 100644 app/src/hooks/cb_api/useLookupApi.test.tsx diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index d8c104b6d8..975609512b 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -83,6 +83,8 @@ const phases = { backboneIntakePath: '/api/dwc/submission/queue', backboneArtifactIntakePath: '/api/artifact/intake', backboneIntakeEnabled: false, + bctwApiHost: 'https://moe-bctw-api-dev.apps.silver.devops.gov.bc.ca', + critterbaseApiHost: 'https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api', env: 'dev', elasticsearchURL: 'http://es01.a0ec71-dev:9200', elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', @@ -113,6 +115,8 @@ const phases = { backboneIntakePath: '/api/dwc/submission/queue', backboneArtifactIntakePath: '/api/artifact/intake', backboneIntakeEnabled: false, + bctwApiHost: 'https://moe-bctw-api-test.apps.silver.devops.gov.bc.ca', + critterbaseApiHost: 'https://moe-critterbase-api-test.apps.silver.devops.gov.bc.ca/api', env: 'test', elasticsearchURL: 'http://es01.a0ec71-dev:9200', elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', @@ -143,6 +147,8 @@ const phases = { backboneIntakePath: '/api/dwc/submission/queue', backboneArtifactIntakePath: '/api/artifact/intake', backboneIntakeEnabled: false, + bctwApiHost: 'https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca', + critterbaseApiHost: 'https://moe-critterbase-api-prod.apps.silver.devops.gov.bc.ca/api', env: 'prod', elasticsearchURL: 'http://es01.a0ec71-prod:9200', elasticsearchTaxonomyIndex: 'taxonomy_3.0.0', diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index 3ca8d141bd..d9781f7306 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -35,6 +35,8 @@ const apiDeploy = async (settings) => { BACKBONE_INTAKE_PATH: phases[phase].backboneIntakePath, BACKBONE_ARTIFACT_INTAKE_PATH: phases[phase].backboneArtifactIntakePath, BACKBONE_INTAKE_ENABLED: phases[phase].backboneIntakeEnabled, + BCTW_API_HOST: phases[phase].bctwApiHost, + CB_API_HOST: phases[phase].critterbaseApiHost, NODE_ENV: phases[phase].env || 'dev', ELASTICSEARCH_URL: phases[phase].elasticsearchURL, ELASTICSEARCH_TAXONOMY_INDEX: phases[phase].elasticsearchTaxonomyIndex, diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index 2718ad12fe..1b821fcd13 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -27,6 +27,12 @@ parameters: - name: BACKBONE_API_HOST required: true description: API host for BioHub Platform Backbone. Example "https://platform.com". + - name: CB_API_HOST + required: true + description: API host for the Critterbase service, SIMS API will hit this to retrieve critter metadata. Example "https://critterbase.com". + - name: BCTW_API_HOST + required: true + description: API host for the BC Telemetry Warehouse service. SIMS API will hit this for device deployments and other telemetry operations. Example "https://bctw.com". - name: BACKBONE_INTAKE_PATH required: true description: API path for BioHub Platform Backbone DwCA submission intake endpoint. Example "/api/path/to/intake". @@ -190,6 +196,10 @@ objects: value: ${APP_HOST} - name: BACKBONE_API_HOST value: ${BACKBONE_API_HOST} + - name: CB_API_HOST + value: ${CB_API_HOST} + - name: BCTW_API_HOST + value: ${BCTW_API_HOST} - name: BACKBONE_INTAKE_PATH value: ${BACKBONE_INTAKE_PATH} - name: BACKBONE_ARTIFACT_INTAKE_PATH diff --git a/api/src/paths/critter-data/critters/index.test.ts b/api/src/paths/critter-data/critters/index.test.ts new file mode 100644 index 0000000000..e93af6b0db --- /dev/null +++ b/api/src/paths/critter-data/critters/index.test.ts @@ -0,0 +1,64 @@ +import Ajv from 'ajv'; +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { CritterbaseService, IBulkCreate } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import * as createCritter from './index'; + +chai.use(sinonChai); + +describe('paths/critter-data/critters/post', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((createCritter.POST.apiDoc as unknown) as object)).to.be.true; + }); + + const payload: IBulkCreate = { + critters: [], + captures: [], + mortalities: [], + locations: [], + markings: [], + qualitative_measurements: [], + quantitative_measurements: [], + families: [], + collections: [] + }; + + describe('createCritter', () => { + afterEach(() => { + sinon.restore(); + }); + it('should succeed', async () => { + const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves({ count: 0 }); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = payload; + const requestHandler = createCritter.createCritter(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockCreateCritter).to.have.been.calledOnceWith(payload); + //expect(mockCreateCritter).calledWith(payload); + expect(mockRes.statusValue).to.equal(201); + expect(mockRes.json.calledWith({ count: 0 })).to.be.true; + }); + it('should fail', async () => { + const mockError = new Error('mock error'); + const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = payload; + const requestHandler = createCritter.createCritter(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockCreateCritter).to.have.been.calledOnceWith(payload); + } + }); + }); +}); diff --git a/api/src/paths/critter-data/critters/index.ts b/api/src/paths/critter-data/critters/index.ts new file mode 100644 index 0000000000..944f50e4c2 --- /dev/null +++ b/api/src/paths/critter-data/critters/index.ts @@ -0,0 +1,160 @@ +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'); +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' + } + ] + }; + }), + createCritter() +]; + +POST.apiDoc = { + description: + 'Creates a new critter in critterbase. Optionally make captures, markings, measurements, etc. along with it.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + 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' + } + } + } + } + } + } + }, + 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 createCritter(): 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.createCritter(req.body); + return res.status(201).json(result); + } catch (error) { + defaultLog.error({ label: 'createCritter', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/critters/{critterId}.test.ts b/api/src/paths/critter-data/critters/{critterId}.test.ts new file mode 100644 index 0000000000..8bab9ca13b --- /dev/null +++ b/api/src/paths/critter-data/critters/{critterId}.test.ts @@ -0,0 +1,68 @@ +import Ajv from 'ajv'; +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import * as critter from './{critterId}'; + +chai.use(sinonChai); + +describe('paths/critter-data/critters/{critterId}', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((critter.GET.apiDoc as unknown) as object)).to.be.true; + }); + + const mockCritter = { + critter_id: 'asdf', + wlh_id: '17-10748', + animal_id: '6', + sex: 'Female', + taxon: 'Caribou', + collection_units: [ + { + category_name: 'Population Unit', + unit_name: 'Itcha-Ilgachuz', + collection_unit_id: '0284c4ca-a279-4135-b6ef-d8f4f8c3d1e6', + collection_category_id: '9dcf05a8-9bfe-421b-b487-ce65299441ca' + } + ], + mortality_timestamp: new Date() + }; + + describe('getCritter', async () => { + afterEach(() => { + sinon.restore(); + }); + it('should succeed', async () => { + const mockGetCritter = sinon.stub(CritterbaseService.prototype, 'getCritter').resolves(mockCritter); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { critterId: 'asdf' }; + const requestHandler = critter.getCritter(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetCritter.calledOnce).to.be.true; + expect(mockGetCritter).calledWith('asdf'); + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockCritter)).to.be.true; + }); + it('should fail', async () => { + const mockError = new Error('mock error'); + const mockGetCritter = sinon.stub(CritterbaseService.prototype, 'getCritter').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = critter.getCritter(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetCritter.calledOnce).to.be.true; + } + }); + }); +}); diff --git a/api/src/paths/critter-data/critters/{critterId}.ts b/api/src/paths/critter-data/critters/{critterId}.ts new file mode 100644 index 0000000000..dacdaf6187 --- /dev/null +++ b/api/src/paths/critter-data/critters/{critterId}.ts @@ -0,0 +1,94 @@ +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/{critterId}'); + +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' + } + ] + }; + }), + getCritter() +]; + +GET.apiDoc = { + description: 'Gets detailed information about a critter.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'critterId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Critter response object', + content: { + 'application/json': { + schema: { + title: 'Critter 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 getCritter(): 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.getCritter(req.params.critterId); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getCritter', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/family/index.test.ts b/api/src/paths/critter-data/family/index.test.ts new file mode 100644 index 0000000000..16fdbb3570 --- /dev/null +++ b/api/src/paths/critter-data/family/index.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getFamilies } from '.'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; + +describe('getFamilies', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets families', async () => { + const mockFamilies = ['family1', 'family2']; + const mockGetFamilies = sinon.stub(CritterbaseService.prototype, 'getFamilies').resolves(mockFamilies); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilies(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetFamilies.calledOnce).to.be.true; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockFamilies)).to.be.true; + }); + + it('handles error', async () => { + const mockError = new Error('mock error'); + const mockGetFamilies = sinon.stub(CritterbaseService.prototype, 'getFamilies').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilies(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetFamilies.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/family/index.ts b/api/src/paths/critter-data/family/index.ts new file mode 100644 index 0000000000..66833d1d27 --- /dev/null +++ b/api/src/paths/critter-data/family/index.ts @@ -0,0 +1,87 @@ +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/family'); + +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' + } + ] + }; + }), + getFamilies() +]; + +GET.apiDoc = { + description: 'Gets a list of all families available in critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Family response', + content: { + 'application/json': { + schema: { + title: 'Family response object', + type: 'array', + items: { + title: 'Family', + 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 getFamilies(): 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.getFamilies(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getFamilies', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/family/{familyId}.test.ts b/api/src/paths/critter-data/family/{familyId}.test.ts new file mode 100644 index 0000000000..3bccc2a3b1 --- /dev/null +++ b/api/src/paths/critter-data/family/{familyId}.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getFamilyById } from './{familyId}'; + +describe('getFamilyById', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets a family by id', async () => { + const mockFamily = { id: '1', name: 'family1' }; + const mockGetFamilyById = sinon.stub(CritterbaseService.prototype, 'getFamilyById').resolves(mockFamily); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilyById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetFamilyById.calledOnce).to.be.true; + expect(mockRes.json.calledOnce).to.be.true; + expect(mockRes.json.args[0][0]).to.deep.equal(mockFamily); + }); + + it('handles errors', async () => { + const mockError = new Error('error'); + const mockGetFamilyById = sinon.stub(CritterbaseService.prototype, 'getFamilyById').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilyById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetFamilyById.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/family/{familyId}.ts b/api/src/paths/critter-data/family/{familyId}.ts new file mode 100644 index 0000000000..a5756b5970 --- /dev/null +++ b/api/src/paths/critter-data/family/{familyId}.ts @@ -0,0 +1,112 @@ +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'; + +// TODO: Put this all into an existing endpoint +const defaultLog = getLogger('paths/critter-data/family'); + +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' + } + ] + }; + }), + getFamilyById() +]; + +GET.apiDoc = { + description: 'Gets allowed values for colours in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'familyId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Family by ID response object', + content: { + 'application/json': { + schema: { + title: 'Family object', + type: 'object', + properties: { + parents: { + type: 'array', + items: { + title: 'Parent critter', + type: 'object' + } + }, + children: { + type: 'array', + items: { + title: 'Child critter', + 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 getFamilyById(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + + try { + const key: string = req.params.familyId; + const cb = new CritterbaseService(user); + const result = await cb.getFamilyById(key); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getFamilyById', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/lookups/{key}.test.ts b/api/src/paths/critter-data/lookups/{key}.test.ts new file mode 100644 index 0000000000..d6764309e2 --- /dev/null +++ b/api/src/paths/critter-data/lookups/{key}.test.ts @@ -0,0 +1,53 @@ +import Ajv from 'ajv'; +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import * as key from './{key}'; + +chai.use(sinonChai); + +describe('paths/critter-data/lookups/{key}', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((key.GET.apiDoc as unknown) as object)).to.be.true; + }); + + const mockSelectOptions = [{ key: 'a', value: 'a', id: 'a' }]; + + describe('getSelectOptions', () => { + afterEach(() => { + sinon.restore(); + }); + it('should succeed', async () => { + const mockGetLookupValues = sinon + .stub(CritterbaseService.prototype, 'getLookupValues') + .resolves(mockSelectOptions); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = key.getLookupValues(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetLookupValues.calledOnce).to.be.true; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockSelectOptions)).to.be.true; + }); + it('should fail', async () => { + const mockError = new Error('mock error'); + const mockGetLookupValues = sinon.stub(CritterbaseService.prototype, 'getLookupValues').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = key.getLookupValues(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetLookupValues.calledOnce).to.be.true; + } + }); + }); +}); diff --git a/api/src/paths/critter-data/lookups/{key}.ts b/api/src/paths/critter-data/lookups/{key}.ts new file mode 100644 index 0000000000..65696e70ee --- /dev/null +++ b/api/src/paths/critter-data/lookups/{key}.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 { CbRouteKey, CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/lookups'); +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' + } + ] + }; + }), + getLookupValues() +]; + +GET.apiDoc = { + description: 'Gets allowed values for colours in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'key', + schema: { + type: 'string' + }, + required: true + }, + { + in: 'query', + name: 'format', + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'Lookup response object', + content: { + 'application/json': { + schema: { + anyOf: [ + { + title: 'asSelect', + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'string' + }, + id: { + type: 'string' + }, + value: { + type: 'string' + } + } + } + }, + { + title: 'Enum response', + type: 'array', + items: { + title: 'Enum value', + type: 'string' + } + } + ] + } + } + } + }, + 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 getLookupValues(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const key: CbRouteKey = req.params.key as CbRouteKey; + const cb = new CritterbaseService(user); + const params = []; + for (const [a, b] of Object.entries(req.query)) { + params.push({ key: String(a), value: String(b) }); + } + try { + const result = await cb.getLookupValues(key, params); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'lookups', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/signup.test.ts b/api/src/paths/critter-data/signup.test.ts new file mode 100644 index 0000000000..63d139918e --- /dev/null +++ b/api/src/paths/critter-data/signup.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../__mocks__/db'; +import { signUp } from './signup'; + +describe('signUp', () => { + afterEach(() => { + sinon.restore(); + }); + it('adds a user to critterbase and returns status 200', async () => { + const mockAddUser = sinon.stub(CritterbaseService.prototype, 'signUp').resolves({ message: 'User created' }); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = signUp(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockAddUser.calledOnce).to.be.true; + expect(mockRes.status.calledOnce).to.be.true; + expect(mockRes.status.args[0][0]).to.equal(200); + expect(mockRes.json.calledOnce).to.be.true; + expect(mockRes.json.args[0][0]).to.deep.equal({ message: 'User created' }); + }); + it('catches and re-throws error', async () => { + const mockError = new Error('mockError'); + const mockAddUser = sinon.stub(CritterbaseService.prototype, 'signUp').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = signUp(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockAddUser.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/signup.ts b/api/src/paths/critter-data/signup.ts new file mode 100644 index 0000000000..e4f5cfe48f --- /dev/null +++ b/api/src/paths/critter-data/signup.ts @@ -0,0 +1,82 @@ +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/signup'); + +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' + } + ] + }; + }), + signUp() +]; + +POST.apiDoc = { + description: 'Creates a new user in critterbase based on this SIMS user"s keycloak details.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Responds with the users UUID in critterbase.', + content: { + 'application/json': { + schema: { + title: 'User 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 signUp(): 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.signUp(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'signUp', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts b/api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts new file mode 100644 index 0000000000..2a5bb46f09 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getTaxonBodyLocations } from './taxon-marking-body-locations'; + +describe('getTaxonBodyLocations', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets taxon body locations', async () => { + const mockTaxonBodyLocations = ['bodyLocation1', 'bodyLocation2']; + const mockGetTaxonBodyLocations = sinon + .stub(CritterbaseService.prototype, 'getTaxonBodyLocations') + .resolves(mockTaxonBodyLocations); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonBodyLocations(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetTaxonBodyLocations.calledOnce).to.be.true; + expect(mockRes.status.calledOnceWith(200)).to.be.true; + expect(mockRes.json.calledOnceWith(mockTaxonBodyLocations)).to.be.true; + }); + + it('handles errors', async () => { + const mockError = new Error('mock error'); + const mockGetTaxonBodyLocations = sinon + .stub(CritterbaseService.prototype, 'getTaxonBodyLocations') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonBodyLocations(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetTaxonBodyLocations.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/xref/taxon-marking-body-locations.ts b/api/src/paths/critter-data/xref/taxon-marking-body-locations.ts new file mode 100644 index 0000000000..117222de12 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-marking-body-locations.ts @@ -0,0 +1,92 @@ +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'; +import { critterbaseCommonLookupResponse } from '../../../utils/shared-api-docs'; + +const defaultLog = getLogger('paths/critter-data/xref'); + +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' + } + ] + }; + }), + getTaxonBodyLocations() +]; + +GET.apiDoc = { + description: 'Gets allowed values for a particular taxons body locations in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'taxon_id', + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + 200: { + description: 'Taxon marking body location response object', + content: { + 'application/json': { + schema: critterbaseCommonLookupResponse + } + } + }, + 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 getTaxonBodyLocations(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const taxon_id = String(req.query.taxon_id); + const cb = new CritterbaseService(user); + + try { + const result = await cb.getTaxonBodyLocations(taxon_id); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getTaxonBodyLocations', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/xref/taxon-measurements.test.ts b/api/src/paths/critter-data/xref/taxon-measurements.test.ts new file mode 100644 index 0000000000..e6318f3078 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-measurements.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getTaxonMeasurements } from './taxon-measurements'; + +describe('getTaxonMeasurements', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets taxon measurements', async () => { + const mockTaxonMeasurements = ['measurement1', 'measurement2']; + const mockGetTaxonMeasurements = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .resolves(mockTaxonMeasurements); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonMeasurements(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetTaxonMeasurements.calledOnce).to.be.true; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockTaxonMeasurements)).to.be.true; + }); + + it('handles errors', async () => { + const mockError = new Error('mock error'); + const mockGetTaxonMeasurements = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonMeasurements(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetTaxonMeasurements.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/xref/taxon-measurements.ts b/api/src/paths/critter-data/xref/taxon-measurements.ts new file mode 100644 index 0000000000..0fe0709004 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-measurements.ts @@ -0,0 +1,97 @@ +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/xref'); + +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' + } + ] + }; + }), + getTaxonMeasurements() +]; + +GET.apiDoc = { + description: 'Gets allowed values a particular taxons measurement types in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'taxon_id', + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + 200: { + description: 'Lookup response object', + content: { + 'application/json': { + schema: { + title: 'Taxon measurements', + type: 'array', + items: { + title: 'Measurement type', + 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 getTaxonMeasurements(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const taxon_id = String(req.query.taxon_id); + const cb = new CritterbaseService(user); + try { + const result = await cb.getTaxonMeasurements(taxon_id); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getTaxonMeasurements', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts new file mode 100644 index 0000000000..6d20f7f297 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getQualMeasurementOptions } from './taxon-qualitative-measurement-options'; + +describe('getQualMeasurementOptions', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets qualitative measurement options', async () => { + const mockQualMeasurementOptions = ['qualMeasurementOption1', 'qualMeasurementOption2']; + const mockGetQualMeasurementOptions = sinon + .stub(CritterbaseService.prototype, 'getQualitativeOptions') + .resolves(mockQualMeasurementOptions); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getQualMeasurementOptions(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetQualMeasurementOptions.calledOnce).to.be.true; + expect(mockRes.status.calledOnce).to.be.true; + expect(mockRes.status.getCall(0).args[0]).to.equal(200); + expect(mockRes.json.calledOnce).to.be.true; + expect(mockRes.json.getCall(0).args[0]).to.deep.equal(mockQualMeasurementOptions); + }); + + it('handles errors', async () => { + const mockError = new Error('mockError'); + const mockGetQualMeasurementOptions = sinon + .stub(CritterbaseService.prototype, 'getQualitativeOptions') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getQualMeasurementOptions(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetQualMeasurementOptions.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts new file mode 100644 index 0000000000..9393435e20 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts @@ -0,0 +1,98 @@ +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'; +import { critterbaseCommonLookupResponse } from '../../../utils/shared-api-docs'; + +const defaultLog = getLogger('paths/critter-data/xref'); + +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' + } + ] + }; + }), + getQualMeasurementOptions() +]; + +GET.apiDoc = { + description: 'Gets allowed values for a qualitative measurement, dependent on taxon.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'taxon_measurement_id', + schema: { + type: 'string', + format: 'uuid' + } + }, + { + in: 'query', + name: 'format', + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'Allowed values for qualitative measurement', + content: { + 'application/json': { + schema: critterbaseCommonLookupResponse + } + } + }, + 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 getQualMeasurementOptions(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const taxon_id = String(req.query.taxon_measurement_id); + const cb = new CritterbaseService(user); + try { + const result = await cb.getQualitativeOptions(taxon_id); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getQualMeasurementOptions', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/vendors.test.ts b/api/src/paths/telemetry/vendors.test.ts new file mode 100644 index 0000000000..eb3622797f --- /dev/null +++ b/api/src/paths/telemetry/vendors.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService } from '../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../__mocks__/db'; +import { getCollarVendors } from './vendors'; + +describe('getCollarVendors', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets collar vendors', async () => { + const mockVendors = ['vendor1', 'vendor2']; + const mockGetCollarVendors = sinon.stub(BctwService.prototype, 'getCollarVendors').resolves(mockVendors); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCollarVendors(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockVendors); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetCollarVendors).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockError = new Error('a test error'); + + const mockGetCollarVendors = sinon.stub(BctwService.prototype, 'getCollarVendors').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCollarVendors(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetCollarVendors).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/vendors.ts b/api/src/paths/telemetry/vendors.ts new file mode 100644 index 0000000000..2f8ad336dd --- /dev/null +++ b/api/src/paths/telemetry/vendors.ts @@ -0,0 +1,77 @@ +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/vendors'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getCollarVendors() +]; + +GET.apiDoc = { + description: 'Get a list of supported collar vendors.', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Collar vendors response object.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + 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 getCollarVendors(): 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 result = await bctwService.getCollarVendors(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getCollarVendors', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts new file mode 100755 index 0000000000..b4efad9ffa --- /dev/null +++ b/api/src/services/bctw-service.test.ts @@ -0,0 +1,214 @@ +import { AxiosResponse } from 'axios'; +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + BctwService, + BCTW_API_HOST, + DEPLOY_DEVICE_ENDPOINT, + GET_COLLAR_VENDORS_ENDPOINT, + GET_DEPLOYMENTS_ENDPOINT, + HEALTH_ENDPOINT, + IDeployDevice, + IDeploymentUpdate, + UPDATE_DEPLOYMENT_ENDPOINT +} from './bctw-service'; +import { KeycloakService } from './keycloak-service'; + +chai.use(sinonChai); + +describe('BctwService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + describe('getUserHeader', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return a JSON string', async () => { + const bctwService = new BctwService(mockUser); + const result = bctwService.getUserHeader(); + expect(result).to.be.a('string'); + expect(JSON.parse(result)).to.deep.equal(mockUser); + }); + }); + + describe('getToken', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return a string from the keycloak service', async () => { + const mockToken = 'abc123'; + const bctwService = new BctwService(mockUser); + const getKeycloakServiceTokenStub = sinon + .stub(KeycloakService.prototype, 'getKeycloakServiceToken') + .resolves(mockToken); + + const result = await bctwService.getToken(); + expect(result).to.equal(mockToken); + expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; + }); + }); + + // 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(); + }); + + it('should make an axios get request', async () => { + const bctwService = new BctwService(mockUser); + const endpoint = '/endpoint'; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves(mockResponse); + + const result = await bctwService._makeGetRequest(endpoint); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${BCTW_API_HOST}${endpoint}`); + }); + + it('should make an axios get request with params', async () => { + const bctwService = new BctwService(mockUser); + const endpoint = '/endpoint'; + const queryParams = { param: 'param' }; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves(mockResponse); + + const result = await bctwService._makeGetRequest(endpoint, queryParams); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${BCTW_API_HOST}${endpoint}?param=${queryParams['param']}`); + }); + }); + + // 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(); + }); + + const bctwService = new BctwService(mockUser); + const mockDevice: IDeployDevice = { + device_id: 1, + frequency: 100, + manufacturer: 'Lotek', + model: 'model', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + critter_id: 'abc123' + }; + const mockDeployment: IDeploymentUpdate = { + deployment_id: 'adcd', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02' + }; + + describe('deployDevice', () => { + it('should send a post request', async () => { + const mockAxios = sinon.stub(bctwService.axiosInstance, 'post'); + + await bctwService.deployDevice(mockDevice); + + expect(mockAxios).to.have.been.calledOnceWith(DEPLOY_DEVICE_ENDPOINT, mockDevice); + }); + }); + + describe('getDeployments', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getDeployments(); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_ENDPOINT); + }); + }); + + describe('updateDeployment', () => { + it('should send a patch request', async () => { + const mockAxios = sinon.stub(bctwService.axiosInstance, 'patch'); + + await bctwService.updateDeployment(mockDeployment); + + expect(mockAxios).to.have.been.calledOnceWith(UPDATE_DEPLOYMENT_ENDPOINT, mockDeployment); + }); + }); + + describe('getCollarVendors', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getCollarVendors(); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_COLLAR_VENDORS_ENDPOINT); + }); + }); + + describe('getHealth', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getHealth(); + + expect(mockGetRequest).to.have.been.calledOnceWith(HEALTH_ENDPOINT); + }); + }); + }); +}); diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts new file mode 100644 index 0000000000..cb65b86e0a --- /dev/null +++ b/api/src/services/bctw-service.ts @@ -0,0 +1,188 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { URLSearchParams } from 'url'; +import { z } from 'zod'; +import { ApiError, ApiErrorType } from '../errors/api-error'; +import { KeycloakService } from './keycloak-service'; + +export const IDeployDevice = z.object({ + device_id: z.number(), + frequency: z.number(), + manufacturer: z.string(), + model: z.string(), + attachment_start: z.string(), + attachment_end: z.string(), + critter_id: z.string() +}); + +export type IDeployDevice = z.infer; + +export const IDeploymentUpdate = z.object({ + deployment_id: z.string(), + attachment_start: z.string(), + attachment_end: z.string() +}); + +export type IDeploymentUpdate = z.infer; + +export const IDeploymentRecord = z.object({ + assignment_id: z.string(), + collar_id: z.string(), + critter_id: z.string(), + created_at: z.string(), + created_by_user_id: z.string(), + updated_at: z.string(), + updated_by_user_id: z.string(), + valid_from: z.string(), + valid_to: z.string(), + attachment_start: z.string(), + attachment_end: z.string(), + deployment_id: z.number() +}); + +export type IDeploymentRecord = z.infer; + +export const IBctwUser = z.object({ + keycloak_guid: z.string(), + username: z.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 UPDATE_DEPLOYMENT_ENDPOINT = '/update-deployment'; +export const GET_COLLAR_VENDORS_ENDPOINT = '/get-collar-vendors'; +export const HEALTH_ENDPOINT = '/health'; + +export class BctwService { + user: IBctwUser; + keycloak: KeycloakService; + axiosInstance: AxiosInstance; + + constructor(user: IBctwUser) { + this.user = user; + this.keycloak = new KeycloakService(); + this.axiosInstance = axios.create({ + headers: { + user: this.getUserHeader() + }, + baseURL: BCTW_API_HOST + }); + + this.axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + (error: AxiosError) => { + return Promise.reject( + new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`) + ); + } + ); + + // Async request interceptor + this.axiosInstance.interceptors.request.use( + async (config) => { + const token = await this.getToken(); + config.headers['Authorization'] = `Bearer ${token}`; + + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + } + + /** + * Return user information as a JSON string. + * + * @return {*} {string} + * @memberof BctwService + */ + getUserHeader(): string { + return JSON.stringify(this.user); + } + + /** + * Retrieve an authentication token using Keycloak service. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getToken(): Promise { + const token = await this.keycloak.getKeycloakServiceToken(); + return token; + } + + /** + * Send an authorized get request to the BCTW API. + * + * @param {string} endpoint + * @param {Record} [queryParams] - An object containing query parameters as key-value pairs + * @return {*} + * @memberof BctwService + */ + async _makeGetRequest(endpoint: string, queryParams?: Record) { + let url = endpoint; + if (queryParams) { + const params = new URLSearchParams(queryParams); + url += `?${params.toString()}`; + } + const response = await this.axiosInstance.get(url); + return response.data; + } + + /** + * Create a new deployment for a telemetry device on a critter. + * + * @param {IDeployDevice} device + * @return {*} {Promise} + * @memberof BctwService + */ + async deployDevice(device: IDeployDevice): Promise { + return await this.axiosInstance.post(DEPLOY_DEVICE_ENDPOINT, device); + } + + /** + * Get all existing deployments. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getDeployments(): Promise { + return this._makeGetRequest(GET_DEPLOYMENTS_ENDPOINT); + } + + /** + * Update the start and end dates of an existing deployment. + * + * @param {IDeploymentUpdate} deployment + * @return {*} {Promise} + * @memberof BctwService + */ + async updateDeployment(deployment: IDeploymentUpdate): Promise { + return await this.axiosInstance.patch(UPDATE_DEPLOYMENT_ENDPOINT, deployment); + } + + /** + * Get a list of all supported collar vendors. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getCollarVendors(): Promise { + return this._makeGetRequest(GET_COLLAR_VENDORS_ENDPOINT); + } + + /** + * Get the health of the platform. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getHealth(): Promise { + return this._makeGetRequest(HEALTH_ENDPOINT); + } +} diff --git a/api/src/services/critterbase-service.test.ts b/api/src/services/critterbase-service.test.ts new file mode 100644 index 0000000000..85ad7e83e2 --- /dev/null +++ b/api/src/services/critterbase-service.test.ts @@ -0,0 +1,160 @@ +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 { KeycloakService } from './keycloak-service'; + +chai.use(sinonChai); + +describe('CritterbaseService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + describe('getUserHeader', () => { + const cb = new CritterbaseService(mockUser); + const result = cb.getUserHeader(); + expect(result).to.be.a('string'); + }); + + describe('getToken', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return a string from the keycloak service', async () => { + const mockToken = 'abc123'; + const cb = new CritterbaseService(mockUser); + const getKeycloakServiceTokenStub = sinon + .stub(KeycloakService.prototype, 'getKeycloakServiceToken') + .resolves(mockToken); + + const result = await cb.getToken(); + expect(result).to.equal(mockToken); + expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; + }); + }); + + describe('makeGetRequest', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should make an axios get request', async () => { + const cb = new CritterbaseService(mockUser); + const endpoint = '/endpoint'; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(cb.axiosInstance, 'get').resolves(mockResponse); + + const result = await cb._makeGetRequest(endpoint, []); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${CRITTERBASE_API_HOST}${endpoint}?`); + }); + + it('should make an axios get request with params', async () => { + const cb = new CritterbaseService(mockUser); + const endpoint = '/endpoint'; + const queryParams = [{ key: 'param', value: 'param' }]; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(cb.axiosInstance, 'get').resolves(mockResponse); + + const result = await cb._makeGetRequest(endpoint, queryParams); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${CRITTERBASE_API_HOST}${endpoint}?param=param`); + }); + }); + + 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(); + }); + + 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'); + }); + }); +}); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts new file mode 100644 index 0000000000..f8c5c7ce32 --- /dev/null +++ b/api/src/services/critterbase-service.ts @@ -0,0 +1,279 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { URLSearchParams } from 'url'; +import { ApiError, ApiErrorType } from '../errors/api-error'; +import { KeycloakService } from './keycloak-service'; + +export interface ICritterbaseUser { + username: string; + keycloak_guid: string; +} + +export interface QueryParam { + key: string; + value: string; +} + +export interface ICritter { + critter_id?: string; + wlh_id: string; + animal_id: string; + sex: string; + critter_comment: string; +} + +export interface ICapture { + capture_id?: string; + critter_id: string; + capture_location_id: string; + release_location_id: string; + capture_timestamp: string; + release_timestamp: string; + capture_comment: string; + release_comment: string; +} + +export interface IMortality { + mortality_id?: string; + critter_id: string; + location_id: string; + mortality_timestamp: string; + proximate_cause_of_death_id: string; + proximate_cause_of_death_confidence: string; + proximate_predated_by_taxon_id: string; + ultimate_cause_of_death_id: string; + ultimate_cause_of_death_confidence: string; + ultimate_predated_by_taxon_id: string; + mortality_comment: string; +} + +export interface ILocation { + location_id?: string; + latitude: number; + longitude: number; +} + +export interface IMarking { + marking_id?: string; + critter_id: string; + capture_id: string; + mortality_id: string; + taxon_marking_body_location_id: string; + marking_type_id: string; + marking_material_id: string; + primary_colour_id: string; + secondary_colour_id: string; + text_colour_id: string; + identifier: string; + frequency: number; + frequency_unit: string; + order: number; + comment: string; + attached_timestamp: string; + removed_timestamp: string; +} + +export interface IQualMeasurement { + measurement_qualitative_id?: string; + critter_id: string; + taxon_measurement_id: string; + capture_id?: string; + mortality_id?: string; + qualitative_option_id: string; + measurement_comment: string; + measured_timestamp: string; +} + +export interface IQuantMeasurement { + measurement_quantitative_id?: string; + taxon_measurement_id: string; + capture_id?: string; + mortality_id?: string; + value: number; + measurement_comment?: string; + measured_timestamp?: string; +} + +export interface IFamilyPayload { + families: { family_id: string; family_label: string }[]; + parents: { family_id: string; parent_critter_id: string }[]; + children: { family_id: string; child_critter_id: string }[]; +} + +export interface ICollection { + critter_collection_unit?: string; + critter_id: string; + collection_unit_id: string; +} + +export interface IBulkCreate { + critters: ICritter[]; + captures: ICapture[]; + collections: ICollection[]; + mortalities: IMortality[]; + locations: ILocation[]; + markings: IMarking[]; + quantitative_measurements: IQuantMeasurement[]; + qualitative_measurements: IQualMeasurement[]; + families: IFamilyPayload[]; +} + +export interface ICbSelectRows { + key: string; + id: string; + value: string; +} + +const lookups = '/lookups'; +const xref = '/xref'; +const lookupsEnum = lookups + '/enum'; +const lookupsTaxons = lookups + '/taxons'; +export const CbRoutes = { + // lookups + ['region-envs']: `${lookups}/region-envs`, + ['region_nrs']: `${lookups}/region-nrs`, + wmus: `${lookups}/wmus`, + cods: `${lookups}/cods`, + ['marking-materials']: `${lookups}/marking-materials`, + ['marking-types']: `${lookups}/marking-types`, + ['collection-categories']: `${lookups}/collection-unit-categories`, + taxons: lookupsTaxons, + species: `${lookupsTaxons}/species`, + colours: `${lookups}/colours`, + + // lookups/enum + sex: `${lookupsEnum}/sex`, + ['critter-status']: `${lookupsEnum}/critter-status`, + ['cause-of-death-confidence']: `${lookupsEnum}/cod-confidence`, + ['coordinate-uncertainty-unit']: `${lookupsEnum}/coordinate-uncertainty-unit`, + ['frequency-units']: `${lookupsEnum}/frequency-units`, + ['measurement-units']: `${lookupsEnum}/measurement-units`, + + // xref + ['collection-units']: `${xref}/collection-units`, + + // taxon xrefs + ['taxon-measurements']: `${xref}/taxon-measurements`, + ['taxon_qualitative_measurements']: `${xref}/taxon-qualitative-measurements`, + ['taxon-qualitative-measurement-options']: `${xref}/taxon-qualitative-measurement-options`, + ['taxon-quantitative-measurements']: `${xref}/taxon-quantitative-measurements`, + ['taxon-collection-categories']: `${xref}/taxon-collection-categories`, + ['taxon-marking-body-locations']: `${xref}/taxon-marking-body-locations` +} as const; + +export type CbRouteKey = keyof typeof CbRoutes; + +export const CRITTERBASE_API_HOST = process.env.CB_API_HOST || ``; +const CRITTER_ENDPOINT = '/critters'; +const BULK_ENDPOINT = '/bulk'; +const SIGNUP_ENDPOINT = '/signup'; +const FAMILY_ENDPOINT = '/family'; + +export class CritterbaseService { + user: ICritterbaseUser; + keycloak: KeycloakService; + axiosInstance: AxiosInstance; + + constructor(user: ICritterbaseUser) { + this.user = user; + this.keycloak = new KeycloakService(); + this.axiosInstance = axios.create({ + headers: { + user: this.getUserHeader() + }, + baseURL: CRITTERBASE_API_HOST + }); + + this.axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + (error: AxiosError) => { + return Promise.reject( + new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`) + ); + } + ); + + // Async request interceptor + this.axiosInstance.interceptors.request.use( + async (config) => { + const token = await this.getToken(); + config.headers['Authorization'] = `Bearer ${token}`; + + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + } + + async getToken(): Promise { + const token = await this.keycloak.getKeycloakServiceToken(); + return token; + } + + /** + * Return user information as a JSON string. + * + * @return {*} {string} + * @memberof BctwService + */ + getUserHeader(): string { + return JSON.stringify(this.user); + } + + async _makeGetRequest(endpoint: string, params: QueryParam[]) { + const appendParams = new URLSearchParams(); + for (const p of params) { + appendParams.append(p.key, p.value); + } + const url = `${endpoint}?${appendParams.toString()}`; + const response = await this.axiosInstance.get(url); + return response.data; + } + + async getLookupValues(route: CbRouteKey, params: QueryParam[]) { + return this._makeGetRequest(CbRoutes[route], params); + } + + async getTaxonMeasurements(taxon_id: string) { + return this._makeGetRequest(CbRoutes['taxon-measurements'], [{ key: 'taxon_id', value: taxon_id }]); + } + + async getTaxonBodyLocations(taxon_id: string) { + return this._makeGetRequest(CbRoutes['taxon-marking-body-locations'], [ + { key: 'taxon_id', value: taxon_id }, + { key: 'format', value: 'asSelect' } + ]); + } + + async getQualitativeOptions(taxon_measurement_id: string, format = 'asSelect') { + return this._makeGetRequest(CbRoutes['taxon-qualitative-measurement-options'], [ + { key: 'taxon_measurement_id', value: taxon_measurement_id }, + { key: 'format', value: format } + ]); + } + + async getFamilies() { + return this._makeGetRequest(FAMILY_ENDPOINT, []); + } + + async getFamilyById(family_id: string) { + return this._makeGetRequest(`${FAMILY_ENDPOINT}/${family_id}`, []); + } + + async getCritter(critter_id: string) { + return this._makeGetRequest(`${CRITTER_ENDPOINT}/${critter_id}`, [{ key: 'format', value: 'detail' }]); + } + + async createCritter(data: IBulkCreate) { + const response = await this.axiosInstance.post(BULK_ENDPOINT, data); + return response.data; + } + + async signUp() { + const response = await this.axiosInstance.post(SIGNUP_ENDPOINT); + return response.data; + } +} diff --git a/api/src/services/keycloak-service.ts b/api/src/services/keycloak-service.ts index 9da4af9e3f..9e221d0250 100644 --- a/api/src/services/keycloak-service.ts +++ b/api/src/services/keycloak-service.ts @@ -57,6 +57,11 @@ const defaultLog = getLogger('services/keycloak-service'); * @class KeycloakService */ export class KeycloakService { + // Used to authenticate with the SIMS Service Credentials + keycloakHost: string; + keycloakServiceClientId: string; + keycloakServiceClientSecret: string; + // Used to authenticate with the CSS API using the SIMS API credentials keycloakApiTokenUrl: string; keycloakApiClientId: string; @@ -68,6 +73,10 @@ export class KeycloakService { keycloakEnvironment: string; constructor() { + this.keycloakHost = `${process.env.KEYCLOAK_HOST}`; + this.keycloakServiceClientId = `${process.env.KEYCLOAK_ADMIN_USERNAME}`; + this.keycloakServiceClientSecret = `${process.env.KEYCLOAK_ADMIN_PASSWORD}`; + this.keycloakApiTokenUrl = `${process.env.KEYCLOAK_API_TOKEN_URL}`; this.keycloakApiClientId = `${process.env.KEYCLOAK_API_CLIENT_ID}`; this.keycloakApiClientSecret = `${process.env.KEYCLOAK_API_CLIENT_SECRET}`; @@ -78,7 +87,36 @@ export class KeycloakService { } /** - * Get an access token from keycloak for the service account user. + * Get an access token from keycloak for the SIMS Service account. + * + * @return {*} {Promise} + * @memberof KeycloakService + */ + async getKeycloakServiceToken(): Promise { + try { + const { data } = await axios.post( + `${this.keycloakHost}/realms/standard/protocol/openid-connect/token`, + qs.stringify({ + grant_type: 'client_credentials', + client_id: this.keycloakServiceClientId, + client_secret: this.keycloakServiceClientSecret + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + return data.access_token as string; + } catch (error) { + defaultLog.debug({ label: 'getKeycloakServiceToken', message: 'error', error: error }); + throw new ApiGeneralError('Failed to authenticate with keycloak', [(error as Error).message]); + } + } + + /** + * Get an access token from keycloak for the sims-team account user. * * @return {*} {Promise} * @memberof KeycloakService diff --git a/api/src/utils/shared-api-docs.ts b/api/src/utils/shared-api-docs.ts index 3de10aa287..89170780b3 100644 --- a/api/src/utils/shared-api-docs.ts +++ b/api/src/utils/shared-api-docs.ts @@ -1,3 +1,5 @@ +import { SchemaObject } from 'ajv'; + export const attachmentApiDocObject = (basicDescription: string, successDescription: string) => { return { description: basicDescription, @@ -45,3 +47,22 @@ export const attachmentApiDocObject = (basicDescription: string, successDescript } }; }; + +export const critterbaseCommonLookupResponse: SchemaObject = { + title: 'asSelect', + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'string' + }, + id: { + type: 'string' + }, + value: { + type: 'string' + } + } + } +}; diff --git a/app/src/components/fields/CbSelectField.tsx b/app/src/components/fields/CbSelectField.tsx index a1193b4da2..dacd6d1611 100644 --- a/app/src/components/fields/CbSelectField.tsx +++ b/app/src/components/fields/CbSelectField.tsx @@ -1,6 +1,6 @@ import { FormControlProps, MenuItem, SelectChangeEvent } from '@mui/material'; import { useFormikContext } from 'formik'; -import { ICbRouteKey, ICbSelectRows } from 'hooks/cb_api/useLookupApi'; +import { ICbSelectRows } from 'hooks/cb_api/useLookupApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import useIsMounted from 'hooks/useIsMounted'; @@ -16,7 +16,7 @@ export interface ICbSelectSharedProps { export interface ICbSelectField extends ICbSelectSharedProps { id: string; - route: ICbRouteKey; + route: string; param?: string; query?: string; handleChangeSideEffect?: (value: string, label: string) => void; diff --git a/app/src/contexts/configContext.tsx b/app/src/contexts/configContext.tsx index 29c31204bf..87e4b0ada5 100644 --- a/app/src/contexts/configContext.tsx +++ b/app/src/contexts/configContext.tsx @@ -5,7 +5,6 @@ import { ensureProtocol } from 'utils/Utils'; export interface IConfig { API_HOST: string; - CB_API_HOST: string; CHANGE_VERSION: string; NODE_ENV: string; REACT_APP_NODE_ENV: string; @@ -19,7 +18,6 @@ export interface IConfig { export const ConfigContext = React.createContext({ API_HOST: '', - CB_API_HOST: '', CHANGE_VERSION: '', NODE_ENV: '', REACT_APP_NODE_ENV: '', @@ -44,17 +42,12 @@ const getLocalConfig = (): IConfig => { const API_HOST = process.env.REACT_APP_API_HOST; const API_PORT = process.env.REACT_APP_API_PORT; - const CB_API_HOST = process.env.REACT_APP_CRITTERBASE_API_HOST; - const CB_API_PORT = process.env.REACT_APP_CRITTERBASE_API_PORT; - const API_URL = (API_PORT && `${API_HOST}:${API_PORT}`) || API_HOST || 'localhost'; - const CB_API_URL = (CB_API_PORT && `${CB_API_HOST}:${CB_API_PORT}`) || CB_API_HOST || 'localhost'; const OBJECT_STORE_URL = process.env.OBJECT_STORE_URL || 'nrs.objectstore.gov.bc.ca'; const OBJECT_STORE_BUCKET_NAME = process.env.OBJECT_STORE_BUCKET_NAME || 'gblhvt'; return { API_HOST: ensureProtocol(API_URL, 'http://'), - CB_API_HOST: ensureProtocol(CB_API_URL, 'http://'), CHANGE_VERSION: process.env.CHANGE_VERSION || 'NA', NODE_ENV: process.env.NODE_ENV, REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV || 'dev', diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index fbc53006f1..6e7ae78cc7 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -40,12 +40,12 @@ const SurveyAnimals: React.FC = () => { const handleOnSave = async (animal: IAnimal) => { const critter = new Critter(animal); const postCritterPayload = async () => { - const res = await cbApi.critters.createCritter(critter); + await cbApi.critters.createCritter(critter); dialogContext.setSnackbar({ open: true, snackbarMessage: ( - {`${pluralize('Animal', res.count)} added to Survey`} + {`Animal added to Survey`} ) }); @@ -54,10 +54,7 @@ const SurveyAnimals: React.FC = () => { try { await postCritterPayload(); } catch (err) { - //Temp solution for keycloak timeout bug - //TODO fix this in useAxios or higher level component. - //This error can occur when keycloak token refresh happens - await postCritterPayload(); + console.log(`Critter submission error ${JSON.stringify(err)}`); } }; @@ -104,5 +101,4 @@ const SurveyAnimals: React.FC = () => { ); }; - export default SurveyAnimals; diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index 210413b501..bf7450b173 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -7,7 +7,6 @@ import SurveySubmissionAlertBar from 'components/publish/SurveySubmissionAlertBa import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContext } from 'contexts/codesContext'; -import { ConfigContext } from 'contexts/configContext'; import { SurveyContext } from 'contexts/surveyContext'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; import React, { useContext, useEffect } from 'react'; @@ -26,7 +25,6 @@ import SurveyHeader from './SurveyHeader'; * @return {*} */ const SurveyPage: React.FC = () => { - const config = useContext(ConfigContext); const codesContext = useContext(CodesContext); const surveyContext = useContext(SurveyContext); @@ -66,14 +64,11 @@ const SurveyPage: React.FC = () => { - {/* Temporarily hiding section while integrating */} - {config?.CB_API_HOST ? ( - - - - - - ) : null} + + + + + diff --git a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx index f4c72c025a..8b71888fac 100644 --- a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx @@ -1,7 +1,7 @@ import { Typography } from '@mui/material'; import FormikDevDebugger from 'components/formik/FormikDevDebugger'; import { Form, useFormikContext } from 'formik'; -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import { Critter, IAnimal } from './animal'; import CaptureAnimalForm from './form-sections/CaptureAnimalForm'; import FamilyAnimalForm from './form-sections/FamilyAnimalForm'; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx index e1287aa969..65bc776455 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx @@ -36,7 +36,7 @@ const GeneralAnimalForm = () => { controlProps={{ required: isRequiredInSchema(AnimalGeneralSchema, 'taxon_id'), size: 'small' }} label={'Taxon'} id={'taxon'} - route={'taxons'} + route={'lookups/taxons'} handleChangeSideEffect={handleTaxonName} /> diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx index a8e16c80b7..9042e375df 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx @@ -5,7 +5,7 @@ import { SurveyAnimalsI18N } from 'constants/i18n'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; -import React, { Fragment, useEffect } from 'react'; +import { Fragment, useEffect } from 'react'; import { v4 } from 'uuid'; import { AnimalMarkingSchema, @@ -68,7 +68,7 @@ const MarkingAnimalForm = () => { label="Marking Type" name={getAnimalFieldName(name, 'marking_type_id', index)} id="marking_type" - route="marking_type" + route="lookups/marking-types" controlProps={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'marking_type_id') @@ -80,7 +80,7 @@ const MarkingAnimalForm = () => { label="Marking Body Location" name={getAnimalFieldName(name, 'taxon_marking_body_location_id', index)} id="marking_body_location" - route="taxon_marking_body_locations" + route="xref/taxon-marking-body-locations" query={`taxon_id=${values.general.taxon_id}`} controlProps={{ size: 'small', @@ -93,7 +93,7 @@ const MarkingAnimalForm = () => { label="Primary Colour" name={getAnimalFieldName(name, 'primary_colour_id', index)} id="primary_colour_id" - route="colours" + route="lookups/colours" controlProps={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'primary_colour_id') @@ -105,7 +105,7 @@ const MarkingAnimalForm = () => { label="Secondary Colour" name={getAnimalFieldName(name, 'secondary_colour_id', index)} id="secondary_colour_id" - route="colours" + route="lookups/colours" controlProps={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'secondary_colour_id') diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx index 72a8ae8b1b..ad8c8898b6 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx @@ -9,7 +9,7 @@ import { IMeasurementStub } from 'hooks/cb_api/useLookupApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import { has } from 'lodash-es'; -import React, { Fragment, useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { v4 } from 'uuid'; import { AnimalMeasurementSchema, @@ -156,7 +156,7 @@ const MeasurementFormContent = ({ index, measurements }: MeasurementFormContentP label="Value" name={optionName} id="qualitative_option" - route="taxon_qualitative_measurement_options" + route="xref/taxon-qualitative-measurement-options" query={`taxon_measurement_id=${taxonMeasurementId}`} controlProps={{ size: 'small', diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx index 347403dd22..00a2009e3b 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx @@ -109,7 +109,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_reason') }} id={`${index}-pcod-reason`} - route={'cod'} + route={'lookups/cods'} /> @@ -121,7 +121,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_confidence') }} id={`${index}-pcod-confidence`} - route={'cause_of_death_confidence'} + route={'lookups/cause-of-death-confidence'} /> @@ -134,7 +134,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_taxon_id') }} id={`${index}-pcod-taxon`} - route={'taxons'} + route={'lookups/taxons'} /> @@ -149,7 +149,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_reason') }} id={`${index}-ucod-reason`} - route={'cod'} + route={'lookups/cods'} /> @@ -161,7 +161,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_confidence') }} id={`${index}-ucod-confidence`} - route={'cause_of_death_confidence'} + route={'lookups/cause-of-death-confidence'} /> @@ -174,7 +174,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_taxon_id') }} id={`${index}-ucod-taxon`} - route={'taxons'} + route={'lookups/taxons'} /> diff --git a/app/src/hooks/cb_api/useAuthenticationApi.test.tsx b/app/src/hooks/cb_api/useAuthenticationApi.test.tsx new file mode 100644 index 0000000000..b1097435a0 --- /dev/null +++ b/app/src/hooks/cb_api/useAuthenticationApi.test.tsx @@ -0,0 +1,28 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; +import { useAuthentication } from './useAuthenticationApi'; + +describe('useAuthenticationApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockUuid = v4(); + + it('basic success case', async () => { + mock.onPost('/api/critter-data/signup').reply(200, { + user_id: mockUuid + }); + + const result = await useAuthentication(axios).signUp(); + + expect(result?.user_id).toBe(mockUuid); + }); +}); diff --git a/app/src/hooks/cb_api/useAuthenticationApi.tsx b/app/src/hooks/cb_api/useAuthenticationApi.tsx index 1095bd0d25..cbda33f22b 100644 --- a/app/src/hooks/cb_api/useAuthenticationApi.tsx +++ b/app/src/hooks/cb_api/useAuthenticationApi.tsx @@ -2,8 +2,15 @@ import { AxiosInstance } from 'axios'; const useAuthentication = (axios: AxiosInstance) => { const signUp = async (): Promise<{ user_id: string } | null> => { - const { data } = await axios.post('/api/signup'); - return data; + try { + const { data } = await axios.post('/api/critter-data/signup'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return null; }; return { diff --git a/app/src/hooks/cb_api/useCritterApi.test.tsx b/app/src/hooks/cb_api/useCritterApi.test.tsx new file mode 100644 index 0000000000..a7da80bee3 --- /dev/null +++ b/app/src/hooks/cb_api/useCritterApi.test.tsx @@ -0,0 +1,81 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Critter, IAnimal } from 'features/surveys/view/survey-animals/animal'; +import { v4 } from 'uuid'; +import { useCritterApi } from './useCritterApi'; + +describe('useCritterApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockId = v4(); + + const mockCritter = { + critter_id: mockId, + wlh_id: '17-10748', + animal_id: '6', + sex: 'Female', + taxon: 'Caribou', + collection_units: [ + { + category_name: 'Population Unit', + unit_name: 'Itcha-Ilgachuz', + collection_unit_id: '0284c4ca-a279-4135-b6ef-d8f4f8c3d1e6', + collection_category_id: '9dcf05a8-9bfe-421b-b487-ce65299441ca' + } + ], + mortality_timestamp: new Date() + }; + + /*it('should fetch an array of critter objects', async () => { + mock.onGet('/api/critter-data/critters').reply(200, [mockCritter]); + + const result = await useCritterApi(axios).getAllCritters(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].critter_id).toBeDefined(); + });*/ + + it('should fetch a single critter by id', async () => { + mock.onGet('/api/critter-data/critters/' + mockId).reply(200, mockCritter); + + const result = await useCritterApi(axios).getCritterByID(mockId); + expect(result.critter_id).toBe(mockId); + expect(typeof result.wlh_id).toBe('string'); + expect(typeof result.sex).toBe('string'); + expect(typeof result.taxon).toBe('string'); + expect(Array.isArray(result.collection_units)).toBe(true); + expect(typeof result.mortality_timestamp).toBe('string'); + }); + + it('should create a critter in critterbase', async () => { + const forCritter: IAnimal = { + ...mockCritter, + captures: [], + markings: [], + measurements: [], + general: { + taxon_id: mockCritter.taxon, + animal_id: mockCritter.animal_id, + taxon_name: 'Joe' + }, + mortality: [], + family: [], + images: [], + device: undefined + }; + const payload = new Critter(forCritter); + + mock.onPost('/api/critter-data/critters').reply(201, { count: 1 }); + + const result = await useCritterApi(axios).createCritter(payload); + expect(result.count).toBe(1); + }); +}); diff --git a/app/src/hooks/cb_api/useCritterApi.tsx b/app/src/hooks/cb_api/useCritterApi.tsx index b99faab49b..63f7c48275 100644 --- a/app/src/hooks/cb_api/useCritterApi.tsx +++ b/app/src/hooks/cb_api/useCritterApi.tsx @@ -3,13 +3,27 @@ import { Critter } from 'features/surveys/view/survey-animals/animal'; const useCritterApi = (axios: AxiosInstance) => { const getAllCritters = async (): Promise[]> => { - const { data } = await axios.get('/api/critters'); - return data; + try { + const { data } = await axios.get('/api/critter-data/critters'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return []; }; const getCritterByID = async (critter_id: string): Promise> => { - const { data } = await axios.get('/api/critters/' + critter_id); - return data; + try { + const { data } = await axios.get(`/api/critter-data/critters/${critter_id}`); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return {}; }; const createCritter = async (critter: Critter): Promise<{ count: number }> => { @@ -21,7 +35,7 @@ const useCritterApi = (axios: AxiosInstance) => { quantitative_measurements: critter.measurements.quantitative, ...critter }; - const { data } = await axios.post('/api/bulk', payload); + const { data } = await axios.post('/api/critter-data/critters', payload); return data; }; diff --git a/app/src/hooks/cb_api/useFamilyApi.test.tsx b/app/src/hooks/cb_api/useFamilyApi.test.tsx new file mode 100644 index 0000000000..a98cbf5ab5 --- /dev/null +++ b/app/src/hooks/cb_api/useFamilyApi.test.tsx @@ -0,0 +1,42 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; +import { useFamilyApi } from './useFamilyApi'; + +describe('useFamily', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const family = { + family_id: v4(), + family_label: 'fam' + }; + + const immediateFamily = { + parents: [], + children: [] + }; + + it('should return a list of families', async () => { + mock.onGet('/api/critter-data/family').reply(200, [family]); + const result = await useFamilyApi(axios).getAllFamilies(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].family_id).toBeDefined(); + }); + + it('should return an immediate family by id', async () => { + const familyId = v4(); + mock.onGet('/api/critter-data/family/' + familyId).reply(200, immediateFamily); + const result = await useFamilyApi(axios).getImmediateFamily(familyId); + expect(Array.isArray(result.parents)).toBe(true); + expect(Array.isArray(result.children)).toBe(true); + }); +}); diff --git a/app/src/hooks/cb_api/useFamilyApi.tsx b/app/src/hooks/cb_api/useFamilyApi.tsx index 84f547c418..428e9a7dae 100644 --- a/app/src/hooks/cb_api/useFamilyApi.tsx +++ b/app/src/hooks/cb_api/useFamilyApi.tsx @@ -7,12 +7,19 @@ export type IFamily = { const useFamilyApi = (axios: AxiosInstance) => { const getAllFamilies = async (): Promise => { - const { data } = await axios.get('/api/family'); - return data; + try { + const { data } = await axios.get('/api/critter-data/family'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return []; }; const getImmediateFamily = async (family_id: string): Promise<{ parents: any[]; siblings: any[]; children: any }> => { - const { data } = await axios.get('/api/family/' + family_id); + const { data } = await axios.get(`/api/critter-data/family/${family_id}`); return data; }; diff --git a/app/src/hooks/cb_api/useLookupApi.test.tsx b/app/src/hooks/cb_api/useLookupApi.test.tsx new file mode 100644 index 0000000000..7ce0cb21d1 --- /dev/null +++ b/app/src/hooks/cb_api/useLookupApi.test.tsx @@ -0,0 +1,57 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; +import { ICbSelectRows, useLookupApi } from './useLookupApi'; + +describe('useLookup', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockLookup = [ + { + key: 'colour_id', + id: '7a516697-c7ee-43b3-9e17-2fc31572d819', + value: 'Blue' + } + ]; + + const mockMeasurement = [ + { + taxon_measurement_id: '29425067-e5ea-4284-b629-26c3cac4cbbf', + taxon_id: '0db0129f-5969-4892-824d-459e5ac38dc2', + measurement_name: 'Life Stage', + measurement_desc: null, + create_user: 'dab7cd7c-75af-474e-abbf-fd31ae166577', + update_user: 'dab7cd7c-75af-474e-abbf-fd31ae166577', + create_timestamp: '2023-07-25T18:18:27.933Z', + update_timestamp: '2023-07-25T18:18:27.933Z' + } + ]; + + it('should return a lookup table in a format to be used by select components', async () => { + mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours' }); + expect(Array.isArray(result)).toBe(true); + expect(typeof result).not.toBe('string'); + const res = result as ICbSelectRows[]; + expect(res[0].key).toBe('colour_id'); + expect(res[0].value).toBe('Blue'); + expect(res[0].id).toBeDefined(); + }); + + it('should retrieve all possible measurements for a specific taxon', async () => { + const taxon_id = v4(); + mock.onGet('/api/critter-data/xref/taxon-measurements?taxon_id=' + taxon_id).reply(200, mockMeasurement); + const result = await useLookupApi(axios).getTaxonMeasurements(taxon_id); + expect(Array.isArray(result)).toBe(true); + expect(typeof result?.[0].taxon_measurement_id).toBe('string'); + expect(typeof result?.[0].measurement_name).toBe('string'); + }); +}); diff --git a/app/src/hooks/cb_api/useLookupApi.tsx b/app/src/hooks/cb_api/useLookupApi.tsx index 868c2c2299..1090f60aba 100644 --- a/app/src/hooks/cb_api/useLookupApi.tsx +++ b/app/src/hooks/cb_api/useLookupApi.tsx @@ -6,48 +6,10 @@ export interface ICbSelectRows { value: string; } -const lookups = '/api/lookups'; -const xref = '/api/xref'; -const lookupsEnum = lookups + '/enum'; -const lookupsTaxons = lookups + '/taxons'; -const CbRoutes = { - // lookups - region_env: `${lookups}/region-envs`, - region_nr: `${lookups}/region-nrs`, - wmu: `${lookups}/wmus`, - cod: `${lookups}/cods`, - marking_materials: `${lookups}/marking-materials`, - marking_type: `${lookups}/marking-types`, - collection_category: `${lookups}/collection-unit-categories`, - taxons: lookupsTaxons, - species: `${lookupsTaxons}/species`, - colours: `${lookups}/colours`, - - // lookups/enum - sex: `${lookupsEnum}/sex`, - critter_status: `${lookupsEnum}/critter-status`, - cause_of_death_confidence: `${lookupsEnum}/cod-confidence`, - coordinate_uncertainty_unit: `${lookupsEnum}/coordinate-uncertainty-unit`, - frequency_units: `${lookupsEnum}/frequency-units`, - measurement_units: `${lookupsEnum}/measurement-units`, - supported_systems: `${lookupsEnum}/supported-systems`, - - // xref - collection_units: `${xref}/collection-units`, - - // taxon xrefs - taxon_measurements: `${xref}/taxon-measurements`, - taxon_qualitative_measurements: `${xref}/taxon-qualitative-measurements`, - taxon_qualitative_measurement_options: `${xref}/taxon-qualitative-measurement-options`, - taxon_quantitative_measurements: `${xref}/taxon-quantitative-measurements`, - taxon_collection_categories: `${xref}/taxon-collection-categories`, - taxon_marking_body_locations: `${xref}/taxon-marking-body-locations` -} as const; - -export type ICbRouteKey = keyof typeof CbRoutes; +//export type ICbRouteKey = keyof typeof CbRoutes; interface SelectOptionsProps { - route: ICbRouteKey; + route: string; param?: string; query?: string; asSelect?: boolean; @@ -60,12 +22,6 @@ export interface IMeasurementStub { max_value?: number; unit?: string; } -export interface IMarkingStub { - taxon_marking_body_location_id: string; - body_location: string; - taxon_id: string; -} - const useLookupApi = (axios: AxiosInstance) => { const getSelectOptions = async ({ route, @@ -74,19 +30,24 @@ const useLookupApi = (axios: AxiosInstance) => { }: SelectOptionsProps): Promise> => { const _param = param ? `/${param}` : ``; const _query = query ? `&${query}` : ``; - - const { data } = await axios.get(`${CbRoutes[route]}${_param}?format=asSelect${_query}`); + const { data } = await axios.get(`/api/critter-data/${route}${_param}?format=asSelect${_query}`); return data; }; - const getTaxonMeasurements = async (taxon_id: string): Promise | undefined> => { - const { data } = await axios.get(`${CbRoutes.taxon_measurements}?taxon_id=${taxon_id}`); + const getTaxonMeasurements = async (taxon_id?: string): Promise | undefined> => { + if (!taxon_id) { + return; + } + const { data } = await axios.get(`/api/critter-data/xref/taxon-measurements?taxon_id=${taxon_id}`); return data; }; - const getTaxonMarkingBodyLocations = async (taxon_id: string): Promise | undefined> => { - const { data } = await axios.get(`${CbRoutes.taxon_marking_body_locations}?taxon_id=${taxon_id}`); + const getTaxonMarkingBodyLocations = async (taxon_id?: string): Promise> => { + if (!taxon_id) { + return []; + } + const { data } = await axios.get(`/api/critter-data/xref/taxon-marking-body-locations?taxon_id=${taxon_id}`); return data; }; diff --git a/app/src/hooks/useCritterbaseApi.ts b/app/src/hooks/useCritterbaseApi.ts index 1042699576..97e966d4e1 100644 --- a/app/src/hooks/useCritterbaseApi.ts +++ b/app/src/hooks/useCritterbaseApi.ts @@ -14,7 +14,7 @@ import { useMarkings } from './cb_api/useMarkings'; */ export const useCritterbaseApi = () => { const config = useContext(ConfigContext); - const apiAxios = useAxios(config?.CB_API_HOST); + const apiAxios = useAxios(config?.API_HOST); const markings = useMarkings(apiAxios); const authentication = useAuthentication(apiAxios); const lookup = useLookupApi(apiAxios); diff --git a/docker-compose.yml b/docker-compose.yml index c646dd1795..cc2bcb52cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,8 @@ services: - BACKBONE_INTAKE_PATH=${BACKBONE_INTAKE_PATH} - BACKBONE_ARTIFACT_INTAKE_PATH=${BACKBONE_ARTIFACT_INTAKE_PATH} - BACKBONE_INTAKE_ENABLED=${BACKBONE_INTAKE_ENABLED} + - BCTW_API_HOST=${BCTW_API_HOST} + - CB_API_HOST=${CB_API_HOST} volumes: - ./api:/opt/app-root/src - /opt/app-root/src/node_modules # prevents local node_modules overriding container node_modules diff --git a/env_config/env.docker b/env_config/env.docker index 1a49ca1270..9e88783c92 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -57,6 +57,22 @@ BACKBONE_ARTIFACT_INTAKE_PATH=/api/artifact/intake # Set to `true` to enable SIMS submitting data to the BioHub Backbone BACKBONE_INTAKE_ENABLED=false +# ------------------------------------------------------------------------------ +# API - BC Telemetry Warehouse Connection +# ------------------------------------------------------------------------------ +# BCTW Platform - BCTW API URL +# If running the BCTW api locally, use `http://:` +# To find the ip use `ip addr show eth0 | grep 'inet\b' | awk '{print $2}' | cut -d/ -f1` +BCTW_API_HOST=https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca + +# ------------------------------------------------------------------------------ +# API - Critterbase Connection +# ------------------------------------------------------------------------------ +# Critterbase API URL +# If running the Critterbase api locally, use `http://:` +# To find the ip use `ip addr show eth0 | grep 'inet\b' | awk '{print $2}' | cut -d/ -f1` +CB_API_HOST=https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api + # ------------------------------------------------------------------------------ # Postgres Database # ------------------------------------------------------------------------------ From 5d807226a9ee6e3e3eea99956ea0cb1ae6727453 Mon Sep 17 00:00:00 2001 From: Al Rosenthal Date: Fri, 8 Sep 2023 16:40:56 -0700 Subject: [PATCH 3/3] SISMBIOHUB-259: Survey Block (#1085) Survey Block UI/ API endpoints added --- api/src/models/survey-create.ts | 3 + api/src/models/survey-update.ts | 3 + api/src/models/survey-view.ts | 2 + .../project/{projectId}/survey/create.ts | 15 ++ .../{projectId}/survey/{surveyId}/update.ts | 19 ++ .../survey/{surveyId}/update/get.ts | 18 ++ .../survey-block-repository.test.ts | 210 ++++++++++++++++++ .../repositories/survey-block-repository.ts | 145 ++++++++++++ api/src/services/survey-block-service.test.ts | 175 +++++++++++++++ api/src/services/survey-block-service.ts | 73 ++++++ api/src/services/survey-service.test.ts | 12 +- api/src/services/survey-service.ts | 31 ++- app/src/features/surveys/CreateSurveyPage.tsx | 11 +- .../features/surveys/components/BlockForm.tsx | 38 ++++ .../components/CreateSurveyBlockDialog.tsx | 74 ++++++ .../components/EditSurveyBlockDialog.tsx | 88 ++++++++ .../surveys/components/SurveyBlockSection.tsx | 169 ++++++++++++++ .../features/surveys/edit/EditSurveyForm.tsx | 11 +- app/src/interfaces/useSurveyApi.interface.ts | 11 +- 19 files changed, 1102 insertions(+), 6 deletions(-) create mode 100644 api/src/repositories/survey-block-repository.test.ts create mode 100644 api/src/repositories/survey-block-repository.ts create mode 100644 api/src/services/survey-block-service.test.ts create mode 100644 api/src/services/survey-block-service.ts create mode 100644 app/src/features/surveys/components/BlockForm.tsx create mode 100644 app/src/features/surveys/components/CreateSurveyBlockDialog.tsx create mode 100644 app/src/features/surveys/components/EditSurveyBlockDialog.tsx create mode 100644 app/src/features/surveys/components/SurveyBlockSection.tsx diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 77a59a500b..0ef9b427f7 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { PostSurveyBlock } from '../repositories/survey-block-repository'; export class PostSurveyObject { survey_details: PostSurveyDetailsData; @@ -11,6 +12,7 @@ export class PostSurveyObject { agreements: PostAgreementsData; participants: PostParticipationData[]; partnerships: PostPartnershipsData; + blocks: PostSurveyBlock[]; constructor(obj?: any) { this.survey_details = (obj?.survey_details && new PostSurveyDetailsData(obj.survey_details)) || null; @@ -26,6 +28,7 @@ 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.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } } diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index 502f526655..e54623a04a 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { PostSurveyBlock } from '../repositories/survey-block-repository'; export class PutSurveyObject { survey_details: PutSurveyDetailsData; @@ -10,6 +11,7 @@ export class PutSurveyObject { location: PutSurveyLocationData; participants: PutSurveyParticipantsData[]; partnerships: PutPartnershipsData; + blocks: PostSurveyBlock[]; constructor(obj?: any) { this.survey_details = (obj?.survey_details && new PutSurveyDetailsData(obj.survey_details)) || null; @@ -24,6 +26,7 @@ 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.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } } diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index ef04263302..5358b3c3a0 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 { SurveyBlockRecord } from '../repositories/survey-block-repository'; import { SurveyUser } from '../repositories/survey-participation-repository'; export type SurveyObject = { @@ -13,6 +14,7 @@ export type SurveyObject = { location: GetSurveyLocationData; participants: SurveyUser[]; partnerships: ISurveyPartnerships; + blocks: SurveyBlockRecord[]; }; export interface ISurveyPartnerships { diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index ff1984b304..0115b203aa 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -248,6 +248,21 @@ POST.apiDoc = { } } } + }, + blocks: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts index 26571e3a1c..d1a7b49fb9 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts @@ -305,6 +305,25 @@ PUT.apiDoc = { } } } + }, + blocks: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + survey_block_id: { + type: 'number', + nullable: true + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } 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 bf9a2acc44..6888f4f60f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts @@ -341,6 +341,24 @@ GET.apiDoc = { } } } + }, + blocks: { + type: 'array', + items: { + type: 'object', + required: ['survey_block_id', 'name', 'description'], + properties: { + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/repositories/survey-block-repository.test.ts b/api/src/repositories/survey-block-repository.test.ts new file mode 100644 index 0000000000..59838972d4 --- /dev/null +++ b/api/src/repositories/survey-block-repository.test.ts @@ -0,0 +1,210 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getMockDBConnection } from '../__mocks__/db'; +import { PostSurveyBlock, SurveyBlockRepository } from './survey-block-repository'; + +chai.use(sinonChai); + +describe('SurveyBlockRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getSurveyBlocksForSurveyId', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: '', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const response = await repo.getSurveyBlocksForSurveyId(1); + + response.forEach((item) => { + expect(item.survey_id).to.be.eql(1); + }); + }); + + it('should succeed with empty data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const response = await repo.getSurveyBlocksForSurveyId(1); + expect(response).to.be.empty; + }); + }); + + describe('updateSurveyBlock', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'Updated name', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const block: PostSurveyBlock = { survey_block_id: 1, survey_id: 1, name: 'Updated name', description: 'block' }; + const response = await repo.updateSurveyBlock(block); + expect(response.survey_block_id).to.be.eql(1); + expect(response.name).to.be.eql('Updated name'); + }); + + it('should failed with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + try { + await repo.updateSurveyBlock(block); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to update survey block'); + } + }); + }); + + describe('insertSurveyBlock', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'new', + description: 'block', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + const repo = new SurveyBlockRepository(dbConnection); + + const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const response = await repo.insertSurveyBlock(block); + + expect(response.name).to.be.eql('new'); + expect(response.description).to.be.eql('block'); + }); + + it('should fail with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + const repo = new SurveyBlockRepository(dbConnection); + try { + const block = ({ + survey_block_id: null, + survey_id: 1, + name: null, + description: null + } as any) as PostSurveyBlock; + await repo.insertSurveyBlock(block); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert survey block'); + } + }); + }); + + describe('deleteSurveyBlockRecord', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'Deleted record', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const response = await repo.deleteSurveyBlockRecord(1); + expect(response.survey_block_id).to.be.eql(1); + }); + + it('should failed with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + try { + await repo.deleteSurveyBlockRecord(1); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to delete survey block record'); + } + }); + }); +}); diff --git a/api/src/repositories/survey-block-repository.ts b/api/src/repositories/survey-block-repository.ts new file mode 100644 index 0000000000..9f70ec3aa7 --- /dev/null +++ b/api/src/repositories/survey-block-repository.ts @@ -0,0 +1,145 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { BaseRepository } from './base-repository'; + +export interface PostSurveyBlock { + survey_block_id: number | null; + survey_id: number; + name: string; + description: string; +} + +// This describes the a row in the database for Survey Block +export const SurveyBlockRecord = z.object({ + survey_block_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); +export type SurveyBlockRecord = z.infer; + +/** + * A repository class for accessing Survey Block data. + * + * @export + * @class SurveyBlockRepository + * @extends {BaseRepository} + */ +export class SurveyBlockRepository extends BaseRepository { + /** + * Gets all Survey Block Records for a given survey id. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async getSurveyBlocksForSurveyId(surveyId: number): Promise { + const sql = SQL` + SELECT * + FROM survey_block + WHERE survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sql, SurveyBlockRecord); + return response.rows || []; + } + + /** + * Updates a survey block record. + * + * @param {SurveyBlock} block + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async updateSurveyBlock(block: PostSurveyBlock): Promise { + const sql = SQL` + UPDATE survey_block + SET + name = ${block.name}, + description = ${block.description}, + survey_id=${block.survey_id} + WHERE + survey_block_id = ${block.survey_block_id} + RETURNING + *; + `; + const response = await this.connection.sql(sql, SurveyBlockRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to update survey block', [ + 'SurveyBlockRepository->updateSurveyBlock', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Inserts a survey block record. + * + * @param {SurveyBlock} block + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async insertSurveyBlock(block: PostSurveyBlock): Promise { + const sql = SQL` + INSERT INTO survey_block ( + survey_id, + name, + description + ) VALUES ( + ${block.survey_id}, + ${block.name}, + ${block.description} + ) + RETURNING + *; + `; + const response = await this.connection.sql(sql, SurveyBlockRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to insert survey block', [ + 'SurveyBlockRepository->postSurveyBlock', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Deletes a survey block record. + * + * @param {number} surveyBlockId + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async deleteSurveyBlockRecord(surveyBlockId: number): Promise { + const sqlStatement = SQL` + DELETE FROM + survey_block + WHERE + survey_block_id = ${surveyBlockId} + RETURNING + *; + `; + + const response = await this.connection.sql(sqlStatement, SurveyBlockRecord); + + if (!response?.rowCount) { + throw new ApiExecuteSQLError('Failed to delete survey block record', [ + 'SurveyBlockRepository->deleteSurveyBlockRecord', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } +} diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts new file mode 100644 index 0000000000..922d54ed3b --- /dev/null +++ b/api/src/services/survey-block-service.test.ts @@ -0,0 +1,175 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { PostSurveyBlock, SurveyBlockRepository } from '../repositories/survey-block-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SurveyBlockService } from './survey-block-service'; + +chai.use(sinonChai); + +describe('SurveyBlockService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getSurveyBlocksForSurveyId', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: '', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + const response = await service.getSurveyBlocksForSurveyId(1); + + response.forEach((item) => { + expect(item.survey_id).to.be.eql(1); + }); + }); + + it('should succeed with empty data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + const response = await service.getSurveyBlocksForSurveyId(1); + expect(response).to.be.empty; + }); + }); + + describe('upsertSurveyBlocks', () => { + it('should succeed with valid data', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyBlockService(dbConnection); + + const getOldBlocks = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([]); + const deleteBlock = sinon.stub(SurveyBlockService.prototype, 'deleteSurveyBlock').resolves(); + const insertBlock = sinon.stub(SurveyBlockRepository.prototype, 'insertSurveyBlock').resolves(); + const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); + + const blocks: PostSurveyBlock[] = [ + { survey_block_id: null, survey_id: 1, name: 'Old Block', description: 'Updated' }, + { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + ]; + await service.upsertSurveyBlocks(1, blocks); + + expect(getOldBlocks).to.be.calledOnce; + expect(insertBlock).to.be.calledTwice; + expect(deleteBlock).to.not.be.calledOnce; + expect(updateBlock).to.not.be.calledOnce; + }); + + it('should run delete block code', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyBlockService(dbConnection); + + const getOldBlocks = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([ + { + survey_block_id: 10, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + }, + { + survey_block_id: 11, + survey_id: 1, + name: 'Old Block', + description: 'Going to be deleted', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ]); + const deleteBlock = sinon.stub(SurveyBlockService.prototype, 'deleteSurveyBlock').resolves(); + const insertBlock = sinon.stub(SurveyBlockRepository.prototype, 'insertSurveyBlock').resolves(); + const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); + + const blocks: PostSurveyBlock[] = [ + { survey_block_id: 10, survey_id: 1, name: 'Old Block', description: 'Updated' }, + { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + ]; + await service.upsertSurveyBlocks(1, blocks); + + expect(getOldBlocks).to.be.calledOnce; + expect(deleteBlock).to.be.calledOnce; + expect(insertBlock).to.be.calledOnce; + expect(updateBlock).to.be.calledOnce; + }); + }); + + describe('deleteSurveyBlockRecord', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'Deleted record', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + const response = await service.deleteSurveyBlock(1); + expect(response.survey_block_id).to.be.eql(1); + }); + + it('should failed with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + try { + await service.deleteSurveyBlock(1); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to delete survey block record'); + } + }); + }); +}); diff --git a/api/src/services/survey-block-service.ts b/api/src/services/survey-block-service.ts new file mode 100644 index 0000000000..b7688f2301 --- /dev/null +++ b/api/src/services/survey-block-service.ts @@ -0,0 +1,73 @@ +import { IDBConnection } from '../database/db'; +import { PostSurveyBlock, SurveyBlockRecord, SurveyBlockRepository } from '../repositories/survey-block-repository'; +import { DBService } from './db-service'; + +export class SurveyBlockService extends DBService { + surveyBlockRepository: SurveyBlockRepository; + + constructor(connection: IDBConnection) { + super(connection); + this.surveyBlockRepository = new SurveyBlockRepository(connection); + } + + /** + * Gets Block Survey Records for a given survey id + * + * @param {number} surveyId + * @return {*} {Promise} + * @returns + */ + async getSurveyBlocksForSurveyId(surveyId: number): Promise { + return await this.surveyBlockRepository.getSurveyBlocksForSurveyId(surveyId); + } + + /** + * Deletes a survey block record. + * + * @param {number} surveyBlockId + * @return {*} {Promise} + * @memberof SurveyBlockService + */ + async deleteSurveyBlock(surveyBlockId: number): Promise { + return this.surveyBlockRepository.deleteSurveyBlockRecord(surveyBlockId); + } + + /** + * Inserts, Updates and Deletes Block records + * All passed in blocks are treated as the source of truth, + * Any pre existing blocks that do not collide with passed in blocks are deleted + * + * @param {number} surveyId + * @param {SurveyBlock[]} blocks + * @return {*} {Promise} + * @memberof SurveyBlockService + */ + async upsertSurveyBlocks(surveyId: number, blocks: PostSurveyBlock[]): Promise { + // all actions to take + const promises: Promise[] = []; + + // Get existing blocks + const existingBlocks = await this.getSurveyBlocksForSurveyId(surveyId); + + // Filter out any + const blocksToDelete = existingBlocks.filter( + (item) => !blocks.find((incoming) => incoming.survey_block_id === item.survey_block_id) + ); + + blocksToDelete.forEach((item) => { + promises.push(this.deleteSurveyBlock(item.survey_block_id)); + }); + + // update or insert block data + blocks.forEach((item: PostSurveyBlock) => { + item.survey_id = surveyId; + if (item.survey_block_id) { + promises.push(this.surveyBlockRepository.updateSurveyBlock(item)); + } else { + promises.push(this.surveyBlockRepository.insertSurveyBlock(item)); + } + }); + + await Promise.all(promises); + } +} diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index 6c9c64ec1c..b0d1683538 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 { SurveyBlockService } from './survey-block-service'; import { SurveyParticipationService } from './survey-participation-service'; import { SurveyService } from './survey-service'; import { TaxonomyService } from './taxonomy-service'; @@ -78,6 +79,7 @@ describe('SurveyService', () => { const getSurveyParticipantsStub = sinon .stub(SurveyParticipationService.prototype, 'getSurveyParticipants') .resolves([{ data: 'participantData' } as any]); + const getSurveyBlockStub = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([]); const getSurveyPartnershipsDataStub = sinon.stub(SurveyService.prototype, 'getSurveyPartnershipsData').resolves({ indigenous_partnerships: [], @@ -95,6 +97,7 @@ describe('SurveyService', () => { expect(getSurveyLocationDataStub).to.be.calledOnce; expect(getSurveyParticipantsStub).to.be.calledOnce; expect(getSurveyPartnershipsDataStub).to.be.calledOnce; + expect(getSurveyBlockStub).to.be.calledOnce; expect(response).to.eql({ survey_details: { data: 'surveyData' }, @@ -108,7 +111,8 @@ describe('SurveyService', () => { stakeholder_partnerships: [] }, participants: [{ data: 'participantData' } as any], - location: { data: 'locationData' } + location: { data: 'locationData' }, + blocks: [] }); }); }); @@ -137,6 +141,7 @@ describe('SurveyService', () => { const upsertSurveyParticipantDataStub = sinon .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); + sinon.stub(SurveyBlockService.prototype, 'upsertSurveyBlocks').resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -175,6 +180,7 @@ describe('SurveyService', () => { const upsertSurveyParticipantDataStub = sinon .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); + const upsertBlocks = sinon.stub(SurveyBlockService.prototype, 'upsertSurveyBlocks').resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -187,7 +193,8 @@ describe('SurveyService', () => { proprietor: {}, purpose_and_methodology: {}, location: {}, - participants: [{}] + participants: [{}], + blocks: [{}] }); await surveyService.updateSurvey(surveyId, putSurveyData); @@ -201,6 +208,7 @@ describe('SurveyService', () => { expect(updateSurveyProprietorDataStub).to.have.been.calledOnce; expect(updateSurveyRegionStub).to.have.been.calledOnce; expect(upsertSurveyParticipantDataStub).to.have.been.calledOnce; + expect(upsertBlocks).to.have.been.calledOnce; }); }); diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index a067280423..4c3b9a4424 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -20,6 +20,7 @@ import { } from '../models/survey-view'; import { AttachmentRepository } from '../repositories/attachment-repository'; import { PublishStatus } from '../repositories/history-publish-repository'; +import { PostSurveyBlock, SurveyBlockRecord } from '../repositories/survey-block-repository'; import { IGetLatestSurveyOccurrenceSubmission, IObservationSubmissionInsertDetails, @@ -35,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 { SurveyBlockService } from './survey-block-service'; import { SurveyParticipationService } from './survey-participation-service'; import { TaxonomyService } from './taxonomy-service'; @@ -94,10 +96,16 @@ export class SurveyService extends DBService { purpose_and_methodology: await this.getSurveyPurposeAndMethodology(surveyId), proprietor: await this.getSurveyProprietorDataForView(surveyId), location: await this.getSurveyLocationData(surveyId), - participants: await this.surveyParticipationService.getSurveyParticipants(surveyId) + participants: await this.surveyParticipationService.getSurveyParticipants(surveyId), + blocks: await this.getSurveyBlocksForSurveyId(surveyId) }; } + async getSurveyBlocksForSurveyId(surveyId: number): Promise { + const service = new SurveyBlockService(this.connection); + return service.getSurveyBlocksForSurveyId(surveyId); + } + async getSurveyPartnershipsData(surveyId: number): Promise { const [indigenousPartnerships, stakeholderPartnerships] = [ await this.surveyRepository.getIndigenousPartnershipsBySurveyId(surveyId), @@ -445,11 +453,28 @@ export class SurveyService extends DBService { promises.push(this.insertRegion(surveyId, postSurveyData.location.geometry)); } + if (postSurveyData.blocks) { + promises.push(this.upsertBlocks(surveyId, postSurveyData.blocks)); + } + await Promise.all(promises); return surveyId; } + /** + * Insert, updates and deletes Survey Blocks for a given survey id + * + * @param {number} surveyId + * @param {SurveyBlock[]} blocks + * @returns {*} {Promise} + * @memberof SurveyService + */ + async upsertBlocks(surveyId: number, blocks: PostSurveyBlock[]): Promise { + const service = new SurveyBlockService(this.connection); + return service.upsertSurveyBlocks(surveyId, blocks); + } + async insertRegion(projectId: number, features: Feature[]): Promise { const regionService = new RegionService(this.connection); return regionService.addRegionsToSurveyFromFeatures(projectId, features); @@ -654,6 +679,10 @@ export class SurveyService extends DBService { promises.push(this.upsertSurveyParticipantData(surveyId, putSurveyData)); } + if (putSurveyData?.blocks) { + promises.push(this.upsertBlocks(surveyId, putSurveyData.blocks)); + } + await Promise.all(promises); } diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 5f144316ca..4d65d2a87c 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -43,6 +43,7 @@ import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from './components/PurposeAndMethodologyForm'; import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from './components/StudyAreaForm'; +import SurveyBlockSection, { SurveyBlockInitialValues } from './components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, SurveyFundingSourceFormYupSchema @@ -136,7 +137,8 @@ const CreateSurveyPage = () => { ...SurveyPartnershipsFormInitialValues, ...ProprietaryDataInitialValues, ...AgreementsInitialValues, - ...SurveyUserJobFormInitialValues + ...SurveyUserJobFormInitialValues, + ...SurveyBlockInitialValues }); // Yup schemas for the survey form sections @@ -377,6 +379,13 @@ const CreateSurveyPage = () => { + } + /> + + { + return ( +
+ + + Name and Description + + + + + + +
+ ); +}; + +export default BlockForm; diff --git a/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx b/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx new file mode 100644 index 0000000000..7069429f4d --- /dev/null +++ b/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx @@ -0,0 +1,74 @@ +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 { + open: boolean; + onSave: (data: any) => void; + onClose: () => void; +} + +const CreateSurveyBlockDialog: React.FC = (props) => { + const { open, onSave, onClose } = props; + const [isSnackBarOpen, setIsSnackBarOpen] = useState(false); + const [blockName, setBlockName] = useState(''); + return ( + <> + , + initialValues: { + survey_block_id: null, + name: '', + description: '' + }, + validationSchema: BlockYupSchema + }} + 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)}> + + + } + /> + + ); +}; + +export default CreateSurveyBlockDialog; diff --git a/app/src/features/surveys/components/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/EditSurveyBlockDialog.tsx new file mode 100644 index 0000000000..570c03020a --- /dev/null +++ b/app/src/features/surveys/components/EditSurveyBlockDialog.tsx @@ -0,0 +1,88 @@ +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'; + +interface IEditBlockProps { + open: boolean; + initialData?: IEditBlock; + onSave: (data: any, index?: number) => void; + onClose: () => void; +} + +const EditSurveyBlockDialog: React.FC = (props) => { + const { open, initialData, onSave, onClose } = props; + const [isSnackBarOpen, setIsSnackBarOpen] = useState(false); + const [blockName, setBlockName] = useState(''); + return ( + <> + , + initialValues: { + survey_block_id: initialData?.block.survey_block_id || null, + name: initialData?.block.name || '', + description: initialData?.block.description || '' + }, + validationSchema: BlockYupSchema + }} + 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)}> + + + } + /> + + ); +}; + +export default EditSurveyBlockDialog; diff --git a/app/src/features/surveys/components/SurveyBlockSection.tsx b/app/src/features/surveys/components/SurveyBlockSection.tsx new file mode 100644 index 0000000000..cfb194e0b9 --- /dev/null +++ b/app/src/features/surveys/components/SurveyBlockSection.tsx @@ -0,0 +1,169 @@ +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 Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import { useFormikContext } from 'formik'; +import { ICreateSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import React, { useState } from 'react'; +import yup from 'utils/YupSchema'; +import CreateSurveyBlockDialog from './CreateSurveyBlockDialog'; +import EditSurveyBlockDialog from './EditSurveyBlockDialog'; + +export const SurveyBlockInitialValues = { + blocks: [] +}; + +// 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') +}); + +export const SurveyBlockYupSchema = yup.array(BlockYupSchema); + +export interface IEditBlock { + index: number; + block: { + survey_block_id: number | null; + name: string; + description: string; + }; +} + +const SurveyBlockSection: React.FC = () => { + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const [editData, setEditData] = useState(undefined); + + const formikProps = useFormikContext(); + const { values, handleSubmit, setFieldValue } = formikProps; + + const handleMenuClick = (event: React.MouseEvent, index: number) => { + setAnchorEl(event.currentTarget); + setEditData({ index: index, block: values.blocks[index] }); + }; + + const handleDelete = () => { + if (editData) { + const data = values.blocks; + data.splice(editData.index, 1); + setFieldValue('blocks', data); + } + setAnchorEl(null); + }; + + return ( + <> + {/* CREATE BLOCK DIALOG */} + setIsCreateModalOpen(false)} + onSave={(data) => { + setEditData(undefined); + setFieldValue(`blocks[${values.blocks.length}]`, data); + setIsCreateModalOpen(false); + }} + /> + + {/* EDIT BLOCK DIALOG */} + { + setIsEditModalOpen(false); + setAnchorEl(null); + }} + onSave={(data, index) => { + setIsEditModalOpen(false); + setAnchorEl(null); + setEditData(undefined); + setFieldValue(`blocks[${index}]`, data); + }} + /> + + Define Blocks + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam at porttitor sem. Aliquam erat volutpat. Donec + placerat nisl magna, et faucibus arcu condimentum sed. + + setAnchorEl(null)} + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + setIsEditModalOpen(true)}> + + + + Edit Details + + handleDelete()}> + + + + Remove + + +
+ + + {values.blocks.map((item, index) => { + return ( + + ) => + handleMenuClick(event, index) + } + aria-label="settings"> + + + } + title={item.name} + subheader={item.description} + /> + + ); + })} + +
+ + ); +}; + +export default SurveyBlockSection; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index ce38478e49..424efde03b 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -28,6 +28,7 @@ import GeneralInformationForm, { import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/ProprietaryDataForm'; import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from '../components/PurposeAndMethodologyForm'; import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from '../components/StudyAreaForm'; +import SurveyBlockSection, { SurveyBlockInitialValues } from '../components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, SurveyFundingSourceFormYupSchema @@ -96,7 +97,8 @@ const EditSurveyForm: React.FC = (props) => { foippa_requirements_accepted: 'true' as unknown as StringBoolean } }, - ...SurveyUserJobFormInitialValues + ...SurveyUserJobFormInitialValues, + ...SurveyBlockInitialValues }); // Yup schemas for the survey form sections @@ -233,6 +235,13 @@ const EditSurveyForm: React.FC = (props) => { + } + /> + +